feat: EmailDeliveryService + professional DSR email templates

- EmailDeliveryService: load template → find published version →
  render {{variables}} → send via SMTP → audit log. Fallback to
  inline HTML when no published template exists.
- Migration 117: Professional HTML/text content for all 5 DSR
  templates (receipt, completion, rejection, identity, extension)
  with branded styling and proper Art. references
- DSRArt11Service now uses EmailDeliveryService with dsr_rejection
  template instead of hardcoded HTML

[migration-approved]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-03 23:38:32 +02:00
parent 060f351da7
commit eb4ea8bc42
3 changed files with 305 additions and 26 deletions
@@ -75,33 +75,26 @@ class DSRArt11Service:
if not dsr.requester_email: if not dsr.requester_email:
return return
try: try:
from compliance.services.smtp_sender import send_email from compliance.services.email_delivery_service import EmailDeliveryService
send_email( delivery = EmailDeliveryService(self._db)
variables = {
"requester_name": dsr.requester_name or "Antragsteller/in",
"reference_number": dsr.request_number or "",
"rejection_reason": "Identifikation nicht moeglich — Art. 11 Abs. 1 DSGVO",
"legal_basis": "Art. 11 Abs. 1 DSGVO",
"sender_name": "Datenschutzbeauftragter",
}
# Use published dsr_rejection template, fallback to inline
delivery.send(
tenant_id=str(dsr.tenant_id),
template_type="dsr_rejection",
recipient=dsr.requester_email, recipient=dsr.requester_email,
subject=f"Zu Ihrer Anfrage {dsr.request_number} — Art. 11 DSGVO", variables=variables,
body_html=f""" fallback_subject=f"Zu Ihrer Anfrage {dsr.request_number} — Art. 11 DSGVO",
<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;"> fallback_html=f"""<p>Sehr geehrte/r {dsr.requester_name or 'Antragsteller/in'},</p>
<div style="background:#6b7280;color:white;padding:20px 24px;border-radius:12px 12px 0 0;"> <p>wir koennen die bei uns gespeicherten Daten keiner identifizierbaren Person zuordnen.
<h1 style="margin:0;font-size:20px;">Mitteilung zu Ihrer Anfrage</h1> Gemaess Art. 11 Abs. 1 DSGVO ist eine Auskunftserteilung nicht moeglich.</p>
</div> <p>Mit freundlichen Gruessen<br/>Datenschutzbeauftragter</p>""",
<div style="background:white;padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 12px 12px;">
<p>Sehr geehrte/r Antragsteller/in,</p>
<p>wir haben Ihre Anfrage ({dsr.request_number}) gemaess Art. 15 DSGVO
erhalten und geprueft.</p>
<p>Leider koennen wir die bei uns gespeicherten Daten (anonymisierte
Cookies, IP-Hashes) <strong>keiner identifizierbaren Person zuordnen</strong>.</p>
<p>Gemaess <strong>Art. 11 Abs. 1 DSGVO</strong> sind wir nicht verpflichtet,
zusaetzliche Informationen zu erheben, um Sie zu identifizieren.
Eine Auskunftserteilung ist daher nicht moeglich.</p>
<p>Sollten Sie ueber ein Kundenkonto bei uns verfuegen, koennen Sie
die Anfrage unter Angabe Ihrer Kundennummer erneut einreichen.</p>
<p style="margin-top:24px;color:#6b7280;font-size:12px;">
Mit freundlichen Gruessen<br>
<strong>Datenschutzbeauftragter</strong>
</p>
</div>
</div>
""",
) )
except Exception as e: except Exception as e:
logger.warning("Art. 11 notification failed: %s", e) logger.warning("Art. 11 notification failed: %s", e)
@@ -0,0 +1,122 @@
"""
Email Template Delivery Service — the missing integration layer.
Combines: template loading → published version → variable rendering → SMTP → audit log.
Used by DSR workflow, document reviews, and other modules that need to send
templated emails.
"""
import logging
import uuid
from typing import Any, Optional
from sqlalchemy.orm import Session
from compliance.db.email_template_models import (
EmailSendLogDB,
EmailTemplateDB,
EmailTemplateVersionDB,
)
logger = logging.getLogger(__name__)
def _render(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 EmailDeliveryService:
"""Load template → render → send via SMTP → log."""
def __init__(self, db: Session) -> None:
self.db = db
def get_published_version(
self, tenant_id: str, template_type: str,
) -> Optional[EmailTemplateVersionDB]:
"""Get the latest published version of a template by type."""
tid = uuid.UUID(tenant_id)
template = (
self.db.query(EmailTemplateDB)
.filter(EmailTemplateDB.tenant_id == tid, EmailTemplateDB.template_type == template_type)
.first()
)
if not template:
return None
return (
self.db.query(EmailTemplateVersionDB)
.filter(
EmailTemplateVersionDB.template_id == template.id,
EmailTemplateVersionDB.status == "published",
)
.order_by(EmailTemplateVersionDB.created_at.desc())
.first()
)
def send(
self,
tenant_id: str,
template_type: str,
recipient: str,
variables: dict[str, str],
fallback_subject: Optional[str] = None,
fallback_html: Optional[str] = None,
) -> dict[str, Any]:
"""Send a templated email. Falls back to inline HTML if no published template.
Args:
tenant_id: Tenant UUID string.
template_type: E.g. 'dsr_receipt', 'dsr_completion'.
recipient: Email address.
variables: Dict of {{key}}: value for rendering.
fallback_subject: Subject if no template found.
fallback_html: HTML body if no template found.
"""
from compliance.services.smtp_sender import send_email
tid = uuid.UUID(tenant_id)
version = self.get_published_version(tenant_id, template_type)
if version:
subject = _render(version.subject, variables)
body_html = _render(version.body_html, variables)
version_id = version.id
elif fallback_subject and fallback_html:
subject = _render(fallback_subject, variables)
body_html = _render(fallback_html, variables)
version_id = None
else:
logger.warning("No published template for '%s' and no fallback provided", template_type)
return {"success": False, "error": f"No template for {template_type}"}
result = send_email(recipient=recipient, subject=subject, body_html=body_html)
# Audit log
try:
log = EmailSendLogDB(
tenant_id=tid,
template_type=template_type,
version_id=version_id,
recipient=recipient,
subject=subject,
status=result.get("status", "unknown"),
variables=variables,
error_message=result.get("error"),
)
self.db.add(log)
self.db.commit()
except Exception as e:
logger.warning("Failed to log email send: %s", e)
return {
"success": result.get("status") == "sent",
"template_type": template_type,
"recipient": recipient,
"subject": subject,
"used_template": version is not None,
"status": result.get("status"),
}
@@ -0,0 +1,164 @@
-- Migration 117: Professional DSR email template content
-- Updates published versions with proper HTML for all 5 DSR template types
-- Note: This updates existing template versions. If no versions exist yet,
-- the email_delivery_service falls back to inline HTML.
-- dsr_receipt: Eingangsbestaetigung
UPDATE compliance_email_template_versions SET
subject = 'Eingangsbestaetigung Ihrer Anfrage {{reference_number}} ({{request_type}})',
body_html = '<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#7c3aed;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;font-size:20px;">Eingangsbestaetigung</h1>
<p style="margin:4px 0 0;opacity:0.9;font-size:14px;">Ihre Anfrage nach {{request_type}}</p>
</div>
<div style="background:white;padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 12px 12px;">
<p>Sehr geehrte/r <strong>{{requester_name}}</strong>,</p>
<p>wir bestaetigen den Eingang Ihrer Anfrage auf Datenauskunft gemaess DSGVO.</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr><td style="padding:8px 12px;border-bottom:1px solid #f3f4f6;color:#6b7280;">Vorgangsnummer:</td><td style="padding:8px 12px;border-bottom:1px solid #f3f4f6;font-weight:bold;">{{reference_number}}</td></tr>
<tr><td style="padding:8px 12px;border-bottom:1px solid #f3f4f6;color:#6b7280;">Art der Anfrage:</td><td style="padding:8px 12px;border-bottom:1px solid #f3f4f6;">{{request_type}}</td></tr>
<tr><td style="padding:8px 12px;border-bottom:1px solid #f3f4f6;color:#6b7280;">Frist:</td><td style="padding:8px 12px;border-bottom:1px solid #f3f4f6;">{{deadline}}</td></tr>
</table>
<p>Wir werden Ihre Anfrage schnellstmoeglich bearbeiten und Ihnen die angeforderten Informationen innerhalb der gesetzlichen Frist zukommen lassen.</p>
<p>Bei Rueckfragen wenden Sie sich bitte unter Angabe der Vorgangsnummer an unseren Datenschutzbeauftragten.</p>
<p style="margin-top:24px;color:#6b7280;font-size:12px;">Mit freundlichen Gruessen<br/><strong>{{sender_name}}</strong></p>
</div></div>',
body_text = 'Eingangsbestaetigung - {{reference_number}}
Sehr geehrte/r {{requester_name}},
wir bestaetigen den Eingang Ihrer Anfrage ({{reference_number}}) auf {{request_type}}.
Frist: {{deadline}}
Mit freundlichen Gruessen
{{sender_name}}',
updated_at = NOW()
WHERE template_id IN (
SELECT id FROM compliance_email_templates WHERE template_type = 'dsr_receipt'
) AND status = 'published';
-- dsr_completion: Abschluss / Datenauskunft
UPDATE compliance_email_template_versions SET
subject = 'Ihre Datenauskunft {{reference_number}} — abgeschlossen',
body_html = '<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#16a34a;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;font-size:20px;">Datenauskunft</h1>
<p style="margin:4px 0 0;opacity:0.9;font-size:14px;">Vorgangsnummer {{reference_number}}</p>
</div>
<div style="background:white;padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 12px 12px;">
<p>Sehr geehrte/r <strong>{{requester_name}}</strong>,</p>
<p>Ihre Anfrage ({{reference_number}}) vom Typ <strong>{{request_type}}</strong> wurde am <strong>{{completion_date}}</strong> abgeschlossen.</p>
<p>Die angeforderten Daten stehen Ihnen in folgenden Formaten zur Verfuegung:</p>
<ul><li><strong>PDF</strong> — druckbare Uebersicht</li><li><strong>JSON</strong> — maschinenlesbar (Art. 20 DSGVO)</li><li><strong>CSV</strong> — tabellarisch</li></ul>
<p>Sollten Sie Fragen haben oder weitere Rechte ausueben wollen (Berichtigung Art. 16, Loeschung Art. 17, Einschraenkung Art. 18), wenden Sie sich bitte erneut an uns.</p>
<p style="margin-top:24px;color:#6b7280;font-size:12px;">Mit freundlichen Gruessen<br/><strong>{{sender_name}}</strong></p>
</div></div>',
body_text = 'Datenauskunft - {{reference_number}}
Sehr geehrte/r {{requester_name}},
Ihre Anfrage ({{reference_number}}) wurde am {{completion_date}} abgeschlossen.
Die Daten stehen als PDF, JSON und CSV zur Verfuegung.
Mit freundlichen Gruessen
{{sender_name}}',
updated_at = NOW()
WHERE template_id IN (
SELECT id FROM compliance_email_templates WHERE template_type = 'dsr_completion'
) AND status = 'published';
-- dsr_rejection: Ablehnung
UPDATE compliance_email_template_versions SET
subject = 'Zu Ihrer Anfrage {{reference_number}} — Entscheidung',
body_html = '<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#6b7280;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;font-size:20px;">Mitteilung zu Ihrer Anfrage</h1>
</div>
<div style="background:white;padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 12px 12px;">
<p>Sehr geehrte/r <strong>{{requester_name}}</strong>,</p>
<p>wir haben Ihre Anfrage ({{reference_number}}) geprueft und muessen Ihnen leider mitteilen, dass wir dieser nicht nachkommen koennen.</p>
<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:16px;margin:16px 0;">
<p style="margin:0;font-weight:bold;color:#991b1b;">Grund:</p>
<p style="margin:4px 0 0;">{{rejection_reason}}</p>
<p style="margin:8px 0 0;font-size:13px;color:#6b7280;">Rechtsgrundlage: {{legal_basis}}</p>
</div>
<p>Sie haben das Recht, eine Beschwerde bei der zustaendigen Aufsichtsbehoerde einzureichen.</p>
<p style="margin-top:24px;color:#6b7280;font-size:12px;">Mit freundlichen Gruessen<br/><strong>{{sender_name}}</strong></p>
</div></div>',
body_text = 'Mitteilung zu {{reference_number}}
Sehr geehrte/r {{requester_name}},
wir koennen Ihrer Anfrage leider nicht nachkommen.
Grund: {{rejection_reason}}
Rechtsgrundlage: {{legal_basis}}
Mit freundlichen Gruessen
{{sender_name}}',
updated_at = NOW()
WHERE template_id IN (
SELECT id FROM compliance_email_templates WHERE template_type = 'dsr_rejection'
) AND status = 'published';
-- dsr_identity_request: Identitaetspruefung
UPDATE compliance_email_template_versions SET
subject = 'Identitaetspruefung erforderlich — {{reference_number}}',
body_html = '<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#ca8a04;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;font-size:20px;">Identitaetspruefung erforderlich</h1>
</div>
<div style="background:white;padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 12px 12px;">
<p>Sehr geehrte/r <strong>{{requester_name}}</strong>,</p>
<p>fuer die Bearbeitung Ihrer Anfrage ({{reference_number}}) benoetigen wir gemaess Art. 12 Abs. 6 DSGVO eine Bestaetigung Ihrer Identitaet.</p>
<p>Bitte senden Sie uns <strong>eines</strong> der folgenden Dokumente:</p>
<ul><li>Kopie Ihres Personalausweises (Vorder-/Rueckseite, Adresse + Foto duerfen geschwärzt werden)</li><li>Screenshot Ihres Kundenkontos mit sichtbarer E-Mail-Adresse</li><li>Antwort von der bei uns registrierten E-Mail-Adresse</li></ul>
<p style="margin-top:24px;color:#6b7280;font-size:12px;">Mit freundlichen Gruessen<br/><strong>{{sender_name}}</strong></p>
</div></div>',
body_text = 'Identitaetspruefung - {{reference_number}}
Sehr geehrte/r {{requester_name}},
fuer Ihre Anfrage ({{reference_number}}) benoetigen wir eine Identitaetsbestaetigung.
Mit freundlichen Gruessen
{{sender_name}}',
updated_at = NOW()
WHERE template_id IN (
SELECT id FROM compliance_email_templates WHERE template_type = 'dsr_identity_request'
) AND status = 'published';
-- dsr_extension: Fristverlaengerung
UPDATE compliance_email_template_versions SET
subject = 'Fristverlaengerung Ihrer Anfrage {{reference_number}}',
body_html = '<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#2563eb;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;font-size:20px;">Fristverlaengerung</h1>
</div>
<div style="background:white;padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 12px 12px;">
<p>Sehr geehrte/r <strong>{{requester_name}}</strong>,</p>
<p>wir informieren Sie, dass die Bearbeitung Ihrer Anfrage ({{reference_number}}) mehr Zeit in Anspruch nimmt als urspruenglich geplant.</p>
<p>Gemaess Art. 12 Abs. 3 DSGVO verlaengern wir die Frist:</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr><td style="padding:8px 12px;border-bottom:1px solid #f3f4f6;color:#6b7280;">Neue Frist:</td><td style="padding:8px 12px;border-bottom:1px solid #f3f4f6;font-weight:bold;">{{new_deadline}}</td></tr>
<tr><td style="padding:8px 12px;border-bottom:1px solid #f3f4f6;color:#6b7280;">Grund:</td><td style="padding:8px 12px;border-bottom:1px solid #f3f4f6;">{{extension_reason}}</td></tr>
</table>
<p style="margin-top:24px;color:#6b7280;font-size:12px;">Mit freundlichen Gruessen<br/><strong>{{sender_name}}</strong></p>
</div></div>',
body_text = 'Fristverlaengerung - {{reference_number}}
Sehr geehrte/r {{requester_name}},
die Frist fuer Ihre Anfrage wurde verlaengert.
Neue Frist: {{new_deadline}}
Grund: {{extension_reason}}
Mit freundlichen Gruessen
{{sender_name}}',
updated_at = NOW()
WHERE template_id IN (
SELECT id FROM compliance_email_templates WHERE template_type = 'dsr_extension'
) AND status = 'published';