""" E-Mail-Template Routes — Benachrichtigungsvorlagen fuer DSGVO-Compliance. Verwaltet Templates fuer DSR, Consent, Breach, Vendor und Training E-Mails. Inklusive Versionierung, Approval-Workflow, Vorschau und Send-Logging. """ import uuid from datetime import datetime from typing import Optional, Dict from fastapi import APIRouter, Depends, HTTPException, Query, Header from pydantic import BaseModel from sqlalchemy.orm import Session from classroom_engine.database import get_db from ..db.email_template_models import ( EmailTemplateDB, EmailTemplateVersionDB, EmailTemplateApprovalDB, EmailSendLogDB, EmailTemplateSettingsDB, ) router = APIRouter(prefix="/email-templates", tags=["compliance-email-templates"]) DEFAULT_TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" # Template-Typen und zugehoerige Variablen TEMPLATE_TYPES = { "welcome": {"name": "Willkommen", "category": "general", "variables": ["user_name", "company_name", "login_url"]}, "verification": {"name": "E-Mail-Verifizierung", "category": "general", "variables": ["user_name", "verification_url", "expiry_hours"]}, "password_reset": {"name": "Passwort zuruecksetzen", "category": "general", "variables": ["user_name", "reset_url", "expiry_hours"]}, "dsr_receipt": {"name": "DSR Eingangsbestaetigung", "category": "dsr", "variables": ["requester_name", "reference_number", "request_type", "deadline"]}, "dsr_identity_request": {"name": "DSR Identitaetsanfrage", "category": "dsr", "variables": ["requester_name", "reference_number"]}, "dsr_completion": {"name": "DSR Abschluss", "category": "dsr", "variables": ["requester_name", "reference_number", "request_type", "completion_date"]}, "dsr_rejection": {"name": "DSR Ablehnung", "category": "dsr", "variables": ["requester_name", "reference_number", "rejection_reason", "legal_basis"]}, "dsr_extension": {"name": "DSR Fristverlaengerung", "category": "dsr", "variables": ["requester_name", "reference_number", "new_deadline", "extension_reason"]}, "consent_request": {"name": "Einwilligungsanfrage", "category": "consent", "variables": ["user_name", "purpose", "consent_url"]}, "consent_confirmation": {"name": "Einwilligungsbestaetigung", "category": "consent", "variables": ["user_name", "purpose", "consent_date"]}, "consent_withdrawal": {"name": "Widerruf bestaetigt", "category": "consent", "variables": ["user_name", "purpose", "withdrawal_date"]}, "consent_reminder": {"name": "Einwilligungs-Erinnerung", "category": "consent", "variables": ["user_name", "purpose", "expiry_date"]}, "breach_notification_authority": {"name": "Datenpanne Aufsichtsbehoerde", "category": "breach", "variables": ["incident_date", "incident_description", "affected_count", "measures_taken", "authority_name"]}, "breach_notification_affected": {"name": "Datenpanne Betroffene", "category": "breach", "variables": ["user_name", "incident_date", "incident_description", "measures_taken", "contact_info"]}, "breach_internal": {"name": "Datenpanne intern", "category": "breach", "variables": ["reporter_name", "incident_date", "incident_description", "severity"]}, "vendor_dpa_request": {"name": "AVV-Anfrage", "category": "vendor", "variables": ["vendor_name", "contact_name", "deadline", "requirements"]}, "vendor_review_reminder": {"name": "Vendor-Pruefung Erinnerung", "category": "vendor", "variables": ["vendor_name", "review_due_date", "last_review_date"]}, "training_invitation": {"name": "Schulungseinladung", "category": "training", "variables": ["user_name", "training_title", "training_date", "training_url"]}, "training_reminder": {"name": "Schulungs-Erinnerung", "category": "training", "variables": ["user_name", "training_title", "deadline"]}, "training_completion": {"name": "Schulung abgeschlossen", "category": "training", "variables": ["user_name", "training_title", "completion_date", "certificate_url"]}, } VALID_STATUSES = ["draft", "review", "approved", "published"] VALID_CATEGORIES = ["general", "dsr", "consent", "breach", "vendor", "training"] # ============================================================================= # Pydantic Schemas # ============================================================================= class TemplateCreate(BaseModel): template_type: str name: Optional[str] = None description: Optional[str] = None category: Optional[str] = None is_active: bool = True class VersionCreate(BaseModel): version: str = "1.0" language: str = "de" subject: str body_html: str body_text: Optional[str] = None class VersionUpdate(BaseModel): subject: Optional[str] = None body_html: Optional[str] = None body_text: Optional[str] = None class PreviewRequest(BaseModel): variables: Optional[Dict[str, str]] = None class SendTestRequest(BaseModel): recipient: str variables: Optional[Dict[str, str]] = None class SettingsUpdate(BaseModel): sender_name: Optional[str] = None sender_email: Optional[str] = None reply_to: Optional[str] = None logo_url: Optional[str] = None primary_color: Optional[str] = None secondary_color: Optional[str] = None footer_text: Optional[str] = None company_name: Optional[str] = None company_address: Optional[str] = None # ============================================================================= # Helpers # ============================================================================= def _get_tenant(x_tenant_id: Optional[str] = Header(None, alias='X-Tenant-ID')) -> str: return x_tenant_id or DEFAULT_TENANT def _template_to_dict(t: EmailTemplateDB, latest_version=None) -> dict: result = { "id": str(t.id), "tenant_id": str(t.tenant_id), "template_type": t.template_type, "name": t.name, "description": t.description, "category": t.category, "is_active": t.is_active, "sort_order": t.sort_order, "variables": t.variables or [], "created_at": t.created_at.isoformat() if t.created_at else None, "updated_at": t.updated_at.isoformat() if t.updated_at else None, } if latest_version: result["latest_version"] = _version_to_dict(latest_version) return result def _version_to_dict(v: EmailTemplateVersionDB) -> dict: return { "id": str(v.id), "template_id": str(v.template_id), "version": v.version, "language": v.language, "subject": v.subject, "body_html": v.body_html, "body_text": v.body_text, "status": v.status, "submitted_at": v.submitted_at.isoformat() if v.submitted_at else None, "submitted_by": v.submitted_by, "published_at": v.published_at.isoformat() if v.published_at else None, "published_by": v.published_by, "created_at": v.created_at.isoformat() if v.created_at else None, "created_by": v.created_by, } def _render_template(html: str, variables: Dict[str, str]) -> str: """Replace {{variable}} placeholders with values.""" result = html for key, value in variables.items(): result = result.replace(f"{{{{{key}}}}}", str(value)) return result # ============================================================================= # Template Type Info (MUST be before parameterized routes) # ============================================================================= @router.get("/types") async def get_template_types(): """Gibt alle verfuegbaren Template-Typen mit Variablen zurueck.""" return [ { "type": ttype, "name": info["name"], "category": info["category"], "variables": info["variables"], } for ttype, info in TEMPLATE_TYPES.items() ] @router.get("/stats") async def get_stats( tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Statistiken ueber E-Mail-Templates.""" tid = uuid.UUID(tenant_id) base = db.query(EmailTemplateDB).filter(EmailTemplateDB.tenant_id == tid) total = base.count() active = base.filter(EmailTemplateDB.is_active).count() # Count templates with published versions published_count = 0 templates = base.all() for t in templates: has_published = db.query(EmailTemplateVersionDB).filter( EmailTemplateVersionDB.template_id == t.id, EmailTemplateVersionDB.status == "published", ).count() > 0 if has_published: published_count += 1 # By category by_category = {} for cat in VALID_CATEGORIES: by_category[cat] = base.filter(EmailTemplateDB.category == cat).count() # Send logs stats total_sent = db.query(EmailSendLogDB).filter(EmailSendLogDB.tenant_id == tid).count() return { "total": total, "active": active, "published": published_count, "draft": total - published_count, "by_category": by_category, "total_sent": total_sent, } @router.get("/settings") async def get_settings( tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Globale E-Mail-Einstellungen laden.""" tid = uuid.UUID(tenant_id) settings = db.query(EmailTemplateSettingsDB).filter( EmailTemplateSettingsDB.tenant_id == tid, ).first() if not settings: return { "sender_name": "Datenschutzbeauftragter", "sender_email": "datenschutz@example.de", "reply_to": None, "logo_url": None, "primary_color": "#4F46E5", "secondary_color": "#7C3AED", "footer_text": "Datenschutzhinweis: Diese E-Mail enthaelt vertrauliche Informationen.", "company_name": None, "company_address": None, } return { "sender_name": settings.sender_name, "sender_email": settings.sender_email, "reply_to": settings.reply_to, "logo_url": settings.logo_url, "primary_color": settings.primary_color, "secondary_color": settings.secondary_color, "footer_text": settings.footer_text, "company_name": settings.company_name, "company_address": settings.company_address, } @router.put("/settings") async def update_settings( body: SettingsUpdate, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Globale E-Mail-Einstellungen speichern.""" tid = uuid.UUID(tenant_id) settings = db.query(EmailTemplateSettingsDB).filter( EmailTemplateSettingsDB.tenant_id == tid, ).first() if not settings: settings = EmailTemplateSettingsDB(tenant_id=tid) db.add(settings) for field in ["sender_name", "sender_email", "reply_to", "logo_url", "primary_color", "secondary_color", "footer_text", "company_name", "company_address"]: val = getattr(body, field, None) if val is not None: setattr(settings, field, val) settings.updated_at = datetime.utcnow() db.commit() db.refresh(settings) return { "sender_name": settings.sender_name, "sender_email": settings.sender_email, "reply_to": settings.reply_to, "logo_url": settings.logo_url, "primary_color": settings.primary_color, "secondary_color": settings.secondary_color, "footer_text": settings.footer_text, "company_name": settings.company_name, "company_address": settings.company_address, } @router.get("/logs") async def get_send_logs( limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), template_type: Optional[str] = Query(None), tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Send-Logs (paginiert).""" tid = uuid.UUID(tenant_id) query = db.query(EmailSendLogDB).filter(EmailSendLogDB.tenant_id == tid) if template_type: query = query.filter(EmailSendLogDB.template_type == template_type) total = query.count() logs = query.order_by(EmailSendLogDB.sent_at.desc()).offset(offset).limit(limit).all() return { "logs": [ { "id": str(l.id), "template_type": l.template_type, "recipient": l.recipient, "subject": l.subject, "status": l.status, "variables": l.variables or {}, "error_message": l.error_message, "sent_at": l.sent_at.isoformat() if l.sent_at else None, } for l in logs ], "total": total, "limit": limit, "offset": offset, } @router.post("/initialize") async def initialize_defaults( tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Default-Templates fuer einen Tenant initialisieren.""" tid = uuid.UUID(tenant_id) existing = db.query(EmailTemplateDB).filter(EmailTemplateDB.tenant_id == tid).count() if existing > 0: return {"message": "Templates already initialized", "count": existing} created = 0 for idx, (ttype, info) in enumerate(TEMPLATE_TYPES.items()): t = EmailTemplateDB( tenant_id=tid, template_type=ttype, name=info["name"], category=info["category"], sort_order=idx * 10, variables=info["variables"], ) db.add(t) created += 1 db.commit() return {"message": f"{created} templates created", "count": created} @router.get("/default/{template_type}") async def get_default_content(template_type: str): """Default-Content fuer einen Template-Typ.""" if template_type not in TEMPLATE_TYPES: raise HTTPException(status_code=404, detail=f"Unknown template type: {template_type}") info = TEMPLATE_TYPES[template_type] vars_html = " ".join([f'{{{{{v}}}}}' for v in info["variables"]]) return { "template_type": template_type, "name": info["name"], "category": info["category"], "variables": info["variables"], "default_subject": f"{info['name']} - {{{{company_name}}}}", "default_body_html": f"

Sehr geehrte(r) {{{{user_name}}}},

\n

[Inhalt hier einfuegen]

\n

Verfuegbare Variablen: {vars_html}

\n

Mit freundlichen Gruessen
{{{{sender_name}}}}

", } # ============================================================================= # Template CRUD (MUST be before /{id} parameterized routes) # ============================================================================= @router.get("") async def list_templates( category: Optional[str] = Query(None), tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Alle Templates mit letzter publizierter Version.""" tid = uuid.UUID(tenant_id) query = db.query(EmailTemplateDB).filter(EmailTemplateDB.tenant_id == tid) if category: query = query.filter(EmailTemplateDB.category == category) templates = query.order_by(EmailTemplateDB.sort_order).all() result = [] for t in templates: latest = db.query(EmailTemplateVersionDB).filter( EmailTemplateVersionDB.template_id == t.id, ).order_by(EmailTemplateVersionDB.created_at.desc()).first() result.append(_template_to_dict(t, latest)) return result @router.post("") async def create_template( body: TemplateCreate, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Template erstellen.""" if body.template_type not in TEMPLATE_TYPES: raise HTTPException(status_code=400, detail=f"Unknown template type: {body.template_type}") tid = uuid.UUID(tenant_id) existing = db.query(EmailTemplateDB).filter( EmailTemplateDB.tenant_id == tid, EmailTemplateDB.template_type == body.template_type, ).first() if existing: raise HTTPException(status_code=409, detail=f"Template type '{body.template_type}' already exists") info = TEMPLATE_TYPES[body.template_type] t = EmailTemplateDB( tenant_id=tid, template_type=body.template_type, name=body.name or info["name"], description=body.description, category=body.category or info["category"], is_active=body.is_active, variables=info["variables"], ) db.add(t) db.commit() db.refresh(t) return _template_to_dict(t) # ============================================================================= # Version Management (static paths before parameterized) # ============================================================================= @router.post("/versions") async def create_version( body: VersionCreate, template_id: str = Query(..., alias="template_id"), tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Neue Version erstellen (via query param template_id).""" try: tid = uuid.UUID(template_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid template ID") template = db.query(EmailTemplateDB).filter( EmailTemplateDB.id == tid, EmailTemplateDB.tenant_id == uuid.UUID(tenant_id), ).first() if not template: raise HTTPException(status_code=404, detail="Template not found") v = EmailTemplateVersionDB( template_id=tid, version=body.version, language=body.language, subject=body.subject, body_html=body.body_html, body_text=body.body_text, status="draft", ) db.add(v) db.commit() db.refresh(v) return _version_to_dict(v) # ============================================================================= # Single Template (parameterized — after all static paths) # ============================================================================= @router.get("/{template_id}") async def get_template( template_id: str, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Template-Detail.""" try: tid = uuid.UUID(template_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid template ID") t = db.query(EmailTemplateDB).filter( EmailTemplateDB.id == tid, EmailTemplateDB.tenant_id == uuid.UUID(tenant_id), ).first() if not t: raise HTTPException(status_code=404, detail="Template not found") latest = db.query(EmailTemplateVersionDB).filter( EmailTemplateVersionDB.template_id == t.id, ).order_by(EmailTemplateVersionDB.created_at.desc()).first() return _template_to_dict(t, latest) @router.get("/{template_id}/versions") async def get_versions( template_id: str, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Versionen eines Templates.""" try: tid = uuid.UUID(template_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid template ID") template = db.query(EmailTemplateDB).filter( EmailTemplateDB.id == tid, EmailTemplateDB.tenant_id == uuid.UUID(tenant_id), ).first() if not template: raise HTTPException(status_code=404, detail="Template not found") versions = db.query(EmailTemplateVersionDB).filter( EmailTemplateVersionDB.template_id == tid, ).order_by(EmailTemplateVersionDB.created_at.desc()).all() return [_version_to_dict(v) for v in versions] @router.post("/{template_id}/versions") async def create_version_for_template( template_id: str, body: VersionCreate, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Neue Version fuer ein Template erstellen.""" try: tid = uuid.UUID(template_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid template ID") template = db.query(EmailTemplateDB).filter( EmailTemplateDB.id == tid, EmailTemplateDB.tenant_id == uuid.UUID(tenant_id), ).first() if not template: raise HTTPException(status_code=404, detail="Template not found") v = EmailTemplateVersionDB( template_id=tid, version=body.version, language=body.language, subject=body.subject, body_html=body.body_html, body_text=body.body_text, status="draft", ) db.add(v) db.commit() db.refresh(v) return _version_to_dict(v) # ============================================================================= # Version Workflow (parameterized by version_id) # ============================================================================= @router.get("/versions/{version_id}") async def get_version( version_id: str, db: Session = Depends(get_db), ): """Version-Detail.""" try: vid = uuid.UUID(version_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid version ID") v = db.query(EmailTemplateVersionDB).filter( EmailTemplateVersionDB.id == vid, ).first() if not v: raise HTTPException(status_code=404, detail="Version not found") return _version_to_dict(v) @router.put("/versions/{version_id}") async def update_version( version_id: str, body: VersionUpdate, db: Session = Depends(get_db), ): """Draft aktualisieren.""" try: vid = uuid.UUID(version_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid version ID") v = db.query(EmailTemplateVersionDB).filter( EmailTemplateVersionDB.id == vid, ).first() if not v: raise HTTPException(status_code=404, detail="Version not found") if v.status != "draft": raise HTTPException(status_code=400, detail="Only draft versions can be edited") if body.subject is not None: v.subject = body.subject if body.body_html is not None: v.body_html = body.body_html if body.body_text is not None: v.body_text = body.body_text db.commit() db.refresh(v) return _version_to_dict(v) @router.post("/versions/{version_id}/submit") async def submit_version( version_id: str, db: Session = Depends(get_db), ): """Zur Pruefung einreichen.""" try: vid = uuid.UUID(version_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid version ID") v = db.query(EmailTemplateVersionDB).filter( EmailTemplateVersionDB.id == vid, ).first() if not v: raise HTTPException(status_code=404, detail="Version not found") if v.status != "draft": raise HTTPException(status_code=400, detail="Only draft versions can be submitted") v.status = "review" v.submitted_at = datetime.utcnow() v.submitted_by = "admin" db.commit() db.refresh(v) return _version_to_dict(v) @router.post("/versions/{version_id}/approve") async def approve_version( version_id: str, comment: Optional[str] = None, db: Session = Depends(get_db), ): """Genehmigen.""" try: vid = uuid.UUID(version_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid version ID") v = db.query(EmailTemplateVersionDB).filter( EmailTemplateVersionDB.id == vid, ).first() if not v: raise HTTPException(status_code=404, detail="Version not found") if v.status != "review": raise HTTPException(status_code=400, detail="Only review versions can be approved") v.status = "approved" approval = EmailTemplateApprovalDB( version_id=vid, action="approve", comment=comment, approved_by="admin", ) db.add(approval) db.commit() db.refresh(v) return _version_to_dict(v) @router.post("/versions/{version_id}/reject") async def reject_version( version_id: str, comment: Optional[str] = None, db: Session = Depends(get_db), ): """Ablehnen.""" try: vid = uuid.UUID(version_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid version ID") v = db.query(EmailTemplateVersionDB).filter( EmailTemplateVersionDB.id == vid, ).first() if not v: raise HTTPException(status_code=404, detail="Version not found") if v.status != "review": raise HTTPException(status_code=400, detail="Only review versions can be rejected") v.status = "draft" # Back to draft approval = EmailTemplateApprovalDB( version_id=vid, action="reject", comment=comment, approved_by="admin", ) db.add(approval) db.commit() db.refresh(v) return _version_to_dict(v) @router.post("/versions/{version_id}/publish") async def publish_version( version_id: str, db: Session = Depends(get_db), ): """Publizieren.""" try: vid = uuid.UUID(version_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid version ID") v = db.query(EmailTemplateVersionDB).filter( EmailTemplateVersionDB.id == vid, ).first() if not v: raise HTTPException(status_code=404, detail="Version not found") if v.status not in ("approved", "review", "draft"): raise HTTPException(status_code=400, detail="Version cannot be published") now = datetime.utcnow() v.status = "published" v.published_at = now v.published_by = "admin" db.commit() db.refresh(v) return _version_to_dict(v) @router.post("/versions/{version_id}/preview") async def preview_version( version_id: str, body: PreviewRequest, db: Session = Depends(get_db), ): """Vorschau mit Test-Variablen.""" try: vid = uuid.UUID(version_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid version ID") v = db.query(EmailTemplateVersionDB).filter( EmailTemplateVersionDB.id == vid, ).first() if not v: raise HTTPException(status_code=404, detail="Version not found") variables = body.variables or {} # Fill in defaults for missing variables template = db.query(EmailTemplateDB).filter( EmailTemplateDB.id == v.template_id, ).first() if template and template.variables: for var in template.variables: if var not in variables: variables[var] = f"[{var}]" rendered_subject = _render_template(v.subject, variables) rendered_html = _render_template(v.body_html, variables) return { "subject": rendered_subject, "body_html": rendered_html, "variables_used": variables, } @router.post("/versions/{version_id}/send-test") async def send_test_email( version_id: str, body: SendTestRequest, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Test-E-Mail senden (Simulation — loggt nur).""" try: vid = uuid.UUID(version_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid version ID") v = db.query(EmailTemplateVersionDB).filter( EmailTemplateVersionDB.id == vid, ).first() if not v: raise HTTPException(status_code=404, detail="Version not found") template = db.query(EmailTemplateDB).filter( EmailTemplateDB.id == v.template_id, ).first() variables = body.variables or {} rendered_subject = _render_template(v.subject, variables) # Log the send attempt log = EmailSendLogDB( tenant_id=uuid.UUID(tenant_id), template_type=template.template_type if template else "unknown", version_id=vid, recipient=body.recipient, subject=rendered_subject, status="test_sent", variables=variables, ) db.add(log) db.commit() return { "success": True, "message": f"Test-E-Mail an {body.recipient} gesendet (Simulation)", "subject": rendered_subject, }