Zum Inhalt springen

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:

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 } | error

V1 unterstützt drei Adapter:

Provider-KindKonfig (provider_config)Hinweise
smtphost, port, username, password-secret-ref, starttls-flagDefault für self-hosted Postfix/Mailcow
sesregion, access-key-secret-ref, configuration-setAWS SES, unterstützt Bounce-/Complaint-Webhooks via SNS
sendgridapi-key-secret-refSendGrid 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 auf DDM_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 mit failed/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 als DDM_ATTACHMENT registriert werden, dann per FK referenziert.
  • attachment_count in AA_EMAIL_LOG reflektiert 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, dass DDM_ATTACHMENT.virus_scan_status final ist (Tenant-Konfig steuert, ob skipped akzeptiert wird). Inline-Base64 wird V1 nicht gescannt — pro Tenant per Konfiguration deaktivierbar oder clamav-stream-Hook aktivierbar (V2+).

Locale-Auflösung (gesetzt)

Reihenfolge bei der Suche der Template-Version:

  1. Job-Payload: payload.locale, falls gesetzt.
  2. Empfänger-Locale: bei Versand an einen AA_APP_USER dessen metadata.locale.
  3. Tenant-Default: AA_TENANT.metadata.email.default_locale.
  4. 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-Signature mit konfiguriertem Public Key prüfen.
    • SMTP: HMAC mit pro-Tenant-Secret aus provider_config.
  • Verifikation schlägt → 401, kein State-Change.

Verarbeitung

  1. 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": { ... }
    }
  2. Email-Log via (provider, provider_message_id) finden.

  3. Felder updaten:

    • deliverydelivered_at, status='delivered'.
    • bounce (permanent) → bounced_at, status='bounced' und INSERT ... ON CONFLICT in AA_EMAIL_SUPPRESSION mit reason='bounce'.
    • bounce (transient) → bounced_at, status='bounced', kein Suppression-Eintrag.
    • complaintstatus='bounced', Suppression mit reason='complaint'.
    • open / click (V2+, Tracking-Pixel/Link-Wrap) → optionale Tabelle, V1 ignorieren oder im Log als JSONB-Feld ergänzen.
  4. Audit-Zeile schreiben: action='email_bounced' / 'email_complained' / 'email_delivered' mit payload->>'email_log_id'.

  5. Antwort: 200 OK (oder Provider-spezifisch erwartete Form). Bei nicht zuordenbarer Message: 200 OK mit Log, kein Fehler — Provider würde sonst retryen.

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:

  1. Worker läuft ganz normal durch, inklusive Template-Render und Variablen-Validierung.
  2. Kein Provider-Call. provider='dryrun' wird in AA_EMAIL_LOG geschrieben.
  3. AA_EMAIL_LOG.status='sent' und provider_message_id=NULL.
  4. 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.id

Pflichten:

  • Auflösen template_version aus (tenant_id, key, locale) mit is_active=true und höchster version → Pin in Payload.
  • Suppression-Vorabprüfung: liefert to_email einen Suppression-Treffer, wird der Job nicht angelegt; stattdessen direkt eine AA_EMAIL_LOG-Zeile mit status='suppressed' (provider='dryrun' als Marker) und Audit action='email_suppressed'. Begründung: vermeidet wegwerfbare Job-Zeilen für vorhersehbar gesperrte Adressen.
  • Variablen-Whitelist gegen template.variables validieren — fehlende required-Variablen → 400 ohne Enqueue.

Verwandte Dokumente