# mypy: disable-error-code="arg-type,assignment,union-attr" """ Email Template service — templates CRUD, settings, logs, stats, initialization. Phase 1 Step 4: extracted from ``compliance.api.email_template_routes``. Version workflow (draft/review/approve/reject/publish/preview/send-test) lives in ``compliance.services.email_template_version_service``. """ import uuid from datetime import datetime, timezone from typing import Any, Optional from sqlalchemy.orm import Session from compliance.db.email_template_models import ( EmailSendLogDB, EmailTemplateDB, EmailTemplateSettingsDB, EmailTemplateVersionDB, ) from compliance.domain import ConflictError, NotFoundError, ValidationError from compliance.schemas.email_template import SettingsUpdate, TemplateCreate # Template type catalog — shared across both services and the route module. TEMPLATE_TYPES: dict[str, dict[str, Any]] = { "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"] def _template_to_dict( t: EmailTemplateDB, latest_version: Optional[EmailTemplateVersionDB] = None ) -> dict[str, Any]: result: dict[str, Any] = { "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[str, Any]: 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 class EmailTemplateService: """Business logic for templates, settings, logs, stats, initialization.""" def __init__(self, db: Session) -> None: self.db = db # ------------------------------------------------------------------ # Type catalog + defaults # ------------------------------------------------------------------ @staticmethod def list_types() -> list[dict[str, Any]]: return [ { "type": ttype, "name": info["name"], "category": info["category"], "variables": info["variables"], } for ttype, info in TEMPLATE_TYPES.items() ] @staticmethod def default_content(template_type: str) -> dict[str, Any]: if template_type not in TEMPLATE_TYPES: raise NotFoundError(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" f"

[Inhalt hier einfuegen]

\n" f"

Verfuegbare Variablen: {vars_html}

\n" f"

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

" ), } # ------------------------------------------------------------------ # Stats # ------------------------------------------------------------------ def stats(self, tenant_id: str) -> dict[str, Any]: tid = uuid.UUID(tenant_id) base = self.db.query(EmailTemplateDB).filter(EmailTemplateDB.tenant_id == tid) total = base.count() active = base.filter(EmailTemplateDB.is_active).count() published_count = 0 for t in base.all(): has_published = ( self.db.query(EmailTemplateVersionDB) .filter( EmailTemplateVersionDB.template_id == t.id, EmailTemplateVersionDB.status == "published", ) .count() > 0 ) if has_published: published_count += 1 by_category: dict[str, int] = {} for cat in VALID_CATEGORIES: by_category[cat] = base.filter(EmailTemplateDB.category == cat).count() total_sent = ( self.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, } # ------------------------------------------------------------------ # Settings # ------------------------------------------------------------------ @staticmethod def _settings_defaults() -> dict[str, Any]: 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, } @staticmethod def _settings_to_dict(s: EmailTemplateSettingsDB) -> dict[str, Any]: return { "sender_name": s.sender_name, "sender_email": s.sender_email, "reply_to": s.reply_to, "logo_url": s.logo_url, "primary_color": s.primary_color, "secondary_color": s.secondary_color, "footer_text": s.footer_text, "company_name": s.company_name, "company_address": s.company_address, } def get_settings(self, tenant_id: str) -> dict[str, Any]: tid = uuid.UUID(tenant_id) settings = ( self.db.query(EmailTemplateSettingsDB) .filter(EmailTemplateSettingsDB.tenant_id == tid) .first() ) if not settings: return self._settings_defaults() return self._settings_to_dict(settings) def update_settings(self, tenant_id: str, body: SettingsUpdate) -> dict[str, Any]: tid = uuid.UUID(tenant_id) settings = ( self.db.query(EmailTemplateSettingsDB) .filter(EmailTemplateSettingsDB.tenant_id == tid) .first() ) if not settings: settings = EmailTemplateSettingsDB(tenant_id=tid) self.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.now(timezone.utc) self.db.commit() self.db.refresh(settings) return self._settings_to_dict(settings) # ------------------------------------------------------------------ # Send logs # ------------------------------------------------------------------ def send_logs( self, tenant_id: str, limit: int, offset: int, template_type: Optional[str], ) -> dict[str, Any]: tid = uuid.UUID(tenant_id) q = self.db.query(EmailSendLogDB).filter(EmailSendLogDB.tenant_id == tid) if template_type: q = q.filter(EmailSendLogDB.template_type == template_type) total = q.count() logs = ( q.order_by(EmailSendLogDB.sent_at.desc()) .offset(offset) .limit(limit) .all() ) return { "logs": [ { "id": str(lg.id), "template_type": lg.template_type, "recipient": lg.recipient, "subject": lg.subject, "status": lg.status, "variables": lg.variables or {}, "error_message": lg.error_message, "sent_at": lg.sent_at.isoformat() if lg.sent_at else None, } for lg in logs ], "total": total, "limit": limit, "offset": offset, } # ------------------------------------------------------------------ # Initialization + template CRUD # ------------------------------------------------------------------ def initialize_defaults(self, tenant_id: str) -> dict[str, Any]: tid = uuid.UUID(tenant_id) existing = ( self.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()): self.db.add( EmailTemplateDB( tenant_id=tid, template_type=ttype, name=info["name"], category=info["category"], sort_order=idx * 10, variables=info["variables"], ) ) created += 1 self.db.commit() return {"message": f"{created} templates created", "count": created} def list_templates( self, tenant_id: str, category: Optional[str] ) -> list[dict[str, Any]]: tid = uuid.UUID(tenant_id) q = self.db.query(EmailTemplateDB).filter(EmailTemplateDB.tenant_id == tid) if category: q = q.filter(EmailTemplateDB.category == category) templates = q.order_by(EmailTemplateDB.sort_order).all() result = [] for t in templates: latest = ( self.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 def create_template(self, tenant_id: str, body: TemplateCreate) -> dict[str, Any]: if body.template_type not in TEMPLATE_TYPES: raise ValidationError(f"Unknown template type: {body.template_type}") tid = uuid.UUID(tenant_id) existing = ( self.db.query(EmailTemplateDB) .filter( EmailTemplateDB.tenant_id == tid, EmailTemplateDB.template_type == body.template_type, ) .first() ) if existing: raise ConflictError( 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"], ) self.db.add(t) self.db.commit() self.db.refresh(t) return _template_to_dict(t) def get_template(self, tenant_id: str, template_id: str) -> dict[str, Any]: try: tid = uuid.UUID(template_id) except ValueError as exc: raise ValidationError("Invalid template ID") from exc t = ( self.db.query(EmailTemplateDB) .filter( EmailTemplateDB.id == tid, EmailTemplateDB.tenant_id == uuid.UUID(tenant_id), ) .first() ) if not t: raise NotFoundError("Template not found") latest = ( self.db.query(EmailTemplateVersionDB) .filter(EmailTemplateVersionDB.template_id == t.id) .order_by(EmailTemplateVersionDB.created_at.desc()) .first() ) return _template_to_dict(t, latest)