Email-Schnittstelle
Status: Entwurf · Spec-Kandidat: ja
Zweck
Beschreibt die externe Schnittstelle des Email-Subsystems: Provider-Konfiguration, Bounce-/Complaint-Webhook-Endpunkt, Test-Mode, Locale-Auflösung und Anhänge-Format. Datenmodell und Worker-Verhalten sind separat:
- Datenmodell: AA_EMAIL_TEMPLATE / AA_EMAIL_SUPPRESSION / AA_EMAIL_LOG
- Job-Tabelle: AA_JOB
- Worker-Verhalten: Asynchrone Jobs / Worker-Pool
Provider-Abstraktion
Der Worker spricht einen Provider über ein einheitliches Interface an:
sendEmail({ from, to, cc, bcc, subject, body_text, body_html, headers, attachments }) → { provider_message_id, accepted_at } | errorV1 unterstützt drei Adapter:
| Provider-Kind | Konfig (provider_config) | Hinweise |
|---|---|---|
smtp | host, port, username, password-secret-ref, starttls-flag | Default für self-hosted Postfix/Mailcow |
ses | region, access-key-secret-ref, configuration-set | AWS SES, unterstützt Bounce-/Complaint-Webhooks via SNS |
sendgrid | api-key-secret-ref | SendGrid API v3 |
Secrets liegen außerhalb der Datenbank (Coolify-Env, KMS, Vault). Tabellen halten nur Referenzen wie secret_ref="env:SES_ACCESS_KEY".
Tenant-Konfiguration
Jeder Tenant hat in AA_TENANT.metadata einen email-Block:
{ "email": { "provider": "ses", "provider_config": { "region": "eu-central-1", "configuration_set": "mdm-prod", "access_key_secret_ref": "env:SES_ACCESS_KEY" }, "from_address": "no-reply@acme.example.com", "from_name": "Acme MDM", "reply_to": "support@acme.example.com", "default_locale": "de", "supported_locales": ["de", "en"], "mode": "live" }}mode='live' versendet echt; mode='dryrun' (siehe Test-Mode) protokolliert ausschließlich. Plattform-Default-Provider greift, wenn der Tenant keinen eigenen Block hat.
Job-Payload (job_kind='email_send')
{ "to": ["jane@example.com"], "cc": [], "bcc": [], "template_key": "case_created", "template_version": 3, "locale": "de", "variables": { "case_code": "CASE-2026-001", "owner_name": "Erika Mustermann" }, "from_override": null, "headers": { "X-MDM-Correlation": "..." }, "attachments": [ /* siehe Abschnitt unten */ ]}template_version wird beim Enqueue gepinnt (siehe AA_EMAIL_TEMPLATE – Versionierung & Pin). Das Feld ist Pflicht — der Service liest die zur Enqueue-Zeit aktive Version (is_active=true, höchste version) und schreibt sie in den Payload. Worker liest exakt diese Version, ohne erneut die “aktuelle” zu suchen.
Anhänge (gesetzt: FK auf DDM_ATTACHMENT oder Inline-Base64)
Anhänge werden im Job-Payload mitgegeben, in zwei Varianten:
"attachments": [ { "kind": "attachment_id", "attachment_id": "9b0d7e2a-..." }, { "kind": "inline_base64", "filename": "kundenmappe.pdf", "mime": "application/pdf", "content_base64": "JVBERi0xLjQKJ..." }]Regeln:
kind='attachment_id'(Standard-Pfad): FK aufDDM_ATTACHMENT.id. Service prüft beim Enqueue: Anhang existiert,deleted_at IS NULL,virus_scan_status IN ('clean','skipped'), aufrufendes Subjekt hat Lese-Berechtigung. Worker resolved Storage-URI zur Versandzeit + macht Permission-Re-Check (verspäteter Versand eines inzwischen gelöschten Vertrags scheitert mitfailed/DLQ).kind='inline_base64': für Ad-hoc-Anhänge ohne Lebenszyklus im MDM. Hartes Limit pro Job: 5 MB Gesamt-Payload-Größe. Service rejected größere beim Enqueue.- Frühere Variante
kind='storage_uri'mit Free-Text-URI ist entfernt — externe Quellen müssen erst alsDDM_ATTACHMENTregistriert werden, dann per FK referenziert. attachment_countinAA_EMAIL_LOGreflektiert die Zahl tatsächlich angefügter Dateien (per FK aufgelöst + inline). Inhalt wird nicht protokolliert (Volumen + DSGVO).- Virus-Scan:
attachment_id-Variante setzt voraus, dassDDM_ATTACHMENT.virus_scan_statusfinal ist (Tenant-Konfig steuert, obskippedakzeptiert wird). Inline-Base64 wird V1 nicht gescannt — pro Tenant per Konfiguration deaktivierbar oderclamav-stream-Hook aktivierbar (V2+).
Locale-Auflösung (gesetzt)
Reihenfolge bei der Suche der Template-Version:
- Job-Payload:
payload.locale, falls gesetzt. - Empfänger-Locale: bei Versand an einen
AA_APP_USERdessenmetadata.locale. - Tenant-Default:
AA_TENANT.metadata.email.default_locale. - Plattform-Fallback:
'en'.
Findet sich keine is_active=true-Version für die ermittelte Locale, fällt der Worker auf den Tenant-Default zurück; danach auf en. Findet er auch das nicht, schreibt er AA_EMAIL_LOG.status='failed' mit error='no_template' und der Job wandert nach Backoff in den DLQ — die Erst-Provisionierung muss mindestens en enthalten.
Die tatsächlich gerenderte Locale wird in AA_EMAIL_LOG.locale und in der Audit-Zeile festgehalten.
Bounce- / Complaint-Webhook-Endpunkt (gesetzt)
Eigener interner Endpunkt pro Provider, nicht Teil der öffentlichen REST-API:
POST /internal/email/events/{provider}Beispiele: /internal/email/events/ses, /internal/email/events/sendgrid, /internal/email/events/smtp (für lokales Bounce-Parsing aus IMAP-Bounce-Mailbox, falls eingerichtet).
Sicherheit
- Endpunkt liegt unter dem internen Pfad und ist nicht über das öffentliche
/api-Prefix exponiert. Reverse-Proxy (Coolify/Traefik) routet/internal/*nur von einer Allow-List (Provider-IP-Ranges) oder hinter VPN/Internal-Network. - Zusätzlich Provider-eigene Signatur-Verifikation:
- SES: SNS-Signature-Header gegen AWS-Public-Key prüfen.
- SendGrid:
X-Twilio-Email-Event-Webhook-Signaturemit konfiguriertem Public Key prüfen. - SMTP: HMAC mit pro-Tenant-Secret aus
provider_config.
- Verifikation schlägt →
401, kein State-Change.
Verarbeitung
-
Body parsen, Provider-spezifisches Event-Schema auf normalisiertes Internal-Schema mappen:
{"provider": "ses","provider_message_id": "<id>","event_kind": "bounce" | "complaint" | "delivery" | "open" | "click","to_email": "...","subtype": "permanent" | "transient" | null,"occurred_at": "...","raw": { ... }} -
Email-Log via
(provider, provider_message_id)finden. -
Felder updaten:
delivery→delivered_at,status='delivered'.bounce(permanent) →bounced_at,status='bounced'undINSERT ... ON CONFLICTinAA_EMAIL_SUPPRESSIONmitreason='bounce'.bounce(transient) →bounced_at,status='bounced', kein Suppression-Eintrag.complaint→status='bounced', Suppression mitreason='complaint'.open/click(V2+, Tracking-Pixel/Link-Wrap) → optionale Tabelle, V1 ignorieren oder im Log als JSONB-Feld ergänzen.
-
Audit-Zeile schreiben:
action='email_bounced'/'email_complained'/'email_delivered'mitpayload->>'email_log_id'. -
Antwort:
200 OK(oder Provider-spezifisch erwartete Form). Bei nicht zuordenbarer Message:200 OKmit Log, kein Fehler — Provider würde sonst retryen.
Unsubscribe-Link
Templates können einen Unsubscribe-Token einbetten ({{unsubscribe_url}}). Generierung beim Render: signierter, zeitlich begrenzter Token (HMAC(tenant_id|email|template_key|exp)). Endpoint:
GET /unsubscribe?token=...Validiert, schreibt AA_EMAIL_SUPPRESSION mit reason='unsubscribe', antwortet mit Bestätigungs-Page. Detail: nicht V1-Pflicht, hier nur als Anker dokumentiert.
Test-Mode (gesetzt)
AA_TENANT.metadata.email.mode='dryrun' aktiviert den Test-Modus für den ganzen Tenant. Im Dryrun:
- Worker läuft ganz normal durch, inklusive Template-Render und Variablen-Validierung.
- Kein Provider-Call.
provider='dryrun'wird inAA_EMAIL_LOGgeschrieben. AA_EMAIL_LOG.status='sent'undprovider_message_id=NULL.- Audit-Aktion
email_sent_dryrun(eigene Action, damit Auswertungen Dryruns von Live-Sends trennen können).
Override pro Job ist möglich: payload.mode='dryrun' zwingt Test-Modus auch im Live-Tenant — etwa für Smoke-Tests in Produktion. Nicht erlaubt umgekehrt: ein Dryrun-Tenant kann nicht über payload.mode='live' echt versenden, das Tenant-Setting ist die obere Schranke.
Email-API für interne Aufrufer
Service-Layer-Schnittstelle (kein REST), nicht Teil der öffentlichen API:
emailService.enqueue(tenant_id, to, template_key, locale_hint?, variables, attachments?, idempotency_key?, priority?) → AA_JOB.idPflichten:
- Auflösen
template_versionaus(tenant_id, key, locale)mitis_active=trueund höchsterversion→ Pin in Payload. - Suppression-Vorabprüfung: liefert
to_emaileinen Suppression-Treffer, wird der Job nicht angelegt; stattdessen direkt eineAA_EMAIL_LOG-Zeile mitstatus='suppressed'(provider='dryrun'als Marker) und Auditaction='email_suppressed'. Begründung: vermeidet wegwerfbare Job-Zeilen für vorhersehbar gesperrte Adressen. - Variablen-Whitelist gegen
template.variablesvalidieren — fehlenderequired-Variablen →400ohne Enqueue.