""" 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 import re from datetime import datetime from typing import Optional, List, Dict, Any from fastapi import APIRouter, Depends, HTTPException, Query, Header from pydantic import BaseModel from sqlalchemy.orm import Session from sqlalchemy import func 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 == True).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, }