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:
@@ -75,33 +75,26 @@ class DSRArt11Service:
|
||||
if not dsr.requester_email:
|
||||
return
|
||||
try:
|
||||
from compliance.services.smtp_sender import send_email
|
||||
send_email(
|
||||
from compliance.services.email_delivery_service import EmailDeliveryService
|
||||
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,
|
||||
subject=f"Zu Ihrer Anfrage {dsr.request_number} — Art. 11 DSGVO",
|
||||
body_html=f"""
|
||||
<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 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>
|
||||
""",
|
||||
variables=variables,
|
||||
fallback_subject=f"Zu Ihrer Anfrage {dsr.request_number} — Art. 11 DSGVO",
|
||||
fallback_html=f"""<p>Sehr geehrte/r {dsr.requester_name or 'Antragsteller/in'},</p>
|
||||
<p>wir koennen die bei uns gespeicherten Daten keiner identifizierbaren Person zuordnen.
|
||||
Gemaess Art. 11 Abs. 1 DSGVO ist eine Auskunftserteilung nicht moeglich.</p>
|
||||
<p>Mit freundlichen Gruessen<br/>Datenschutzbeauftragter</p>""",
|
||||
)
|
||||
except Exception as 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';
|
||||
Reference in New Issue
Block a user