Files
craftvia/docs/SEC1-MAIL.md
T
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

8.2 KiB

SEC1 — SMTP-Mail-Fundament

Branch dev-sec1-mail-smtp (Basis dev) · Grundlage: docs/sicherheit/SEC1-Mail-Fundament-Detail.md Fundament für Passwort-Reset (SEC2), Einladung (SEC4), MFA-Hinweise (SEC3) und Ticket-/Freigabe-/Fristen-Benachrichtigungen.


1. Architektur

Aufrufer (Server-Action / Event)
   │ enqueueMail({template,to,vars,tenantId,locale,dedupeKey})
   ▼
service.ts ──► MailLog(pending)          Worker-Prozess (npm run worker:mail)
   │            (dedupeKey = Sperre)     ┌──────────────────────────────────┐
   ├─ Queue erreichbar? ──ja──► Redis ──►│ BullMQ "mail"                    │
   │                                     │  → renderTemplate(locale)        │
   └─ nein ─────────────────────────────►│  → provider.send()  (deliver.ts) │
       (inline, gleicher deliver-Pfad)   │  → MailLog(sent|failed)          │
                                         │  → Retry/Backoff → mail-dead-letter
                                         └──────────────────────────────────┘
                       Provider-Interface ← SMTP (nodemailer, Pool + TLS)

Vier Schichten, wie im Aufgabenpaket vorgesehen: Provider (provider.ts, provider-smtp.ts), Templates (templates.ts + src/lib/email-brand.ts), Queue/Worker (queue.ts, worker.ts), Service/Trigger (service.ts, notifications.ts).


2. Betriebsmodus (Entscheidung)

Das Aufgabenpaket verlangt eine Festlegung — hier ist sie:

REDIS_URL Modus Verhalten
gesetzt und erreichbar Queue (Produktivmodus) Asynchron über BullMQ; ein eigener Worker-Prozess (npm run worker:mail, eigener Container in Coolify) stellt zu. Retry mit exponentiellem Backoff (5 Versuche), Dead-Letter-Queue, Limiter (20 Mails/10 s, Concurrency 5), Graceful Shutdown.
gesetzt, aber nicht erreichbar Inline (degradiert) Der Versand fällt auf den direkten Pfad zurück, damit bei einem Redis-Ausfall keine Mail verloren geht — ohne Retry/DLQ. Wird im Log vermerkt.
nicht gesetzt Inline Lokale Entwicklung und Demo laufen ohne Redis.

Die Erreichbarkeit wird vor dem Einstellen geprüft (isQueueReady()). Ein Fallback nach einem fehlgeschlagenen add() wäre riskant: der Job könnte angekommen sein und nur die Bestätigung verloren gegangen — das ergäbe einen Doppelversand. Deshalb wird ein Fehler beim Einstellen nur protokolliert.

Der Worker registriert zusätzlich den Fristen-Job (täglich 07:00 Europe/Berlin) auf einer eigenen Queue mail-scheduler. Grund: ein BullMQ-Worker konsumiert alle Jobs seiner Queue unabhängig vom Job-Namen — auf einer gemeinsamen Queue könnte der Zustell-Worker den Fristen-Job abgreifen.


3. Konfiguration

Führend sind die bereits im Repo etablierten Namen; die im Aufgabenpaket genannten Aliasse werden zusätzlich akzeptiert.

Variable Alias Zweck
SMTP_HOST, SMTP_PORT — Relay
SMTP_SECURE — true = implizites TLS (465), sonst STARTTLS. Ohne Angabe aus dem Port abgeleitet.
SMTP_USER, SMTP_PASSWORD SMTP_PASS Authentifizierung (optional)
SMTP_FROM MAIL_FROM Absenderadresse
MAIL_FROM_NAME — Anzeigename (Default Certvia)
MAIL_REPLY_TO — optionale Antwortadresse
APP_BASE_URL AUTH_URL Basis für absolute Links in Mails

Fehlt die Konfiguration, wird nicht still versendet: getMailConfig() liefert null samt Begründung, das MailLog bleibt pending mit Fehlertext, und die Admin-Konsole zeigt „Nicht konfiguriert". TLS wird erzwungen — gelockert nur gegen localhost/mailpit/mailhog, weil der lokale Test-SMTP kein gültiges Zertifikat hat.


4. Datenmodelle

  • MailLog — Versandprotokoll: Empfänger, Template, Locale, Status (pending|sent|failed|bounced|suppressed), providerMessageId, Fehlertext, Versuchszähler, dedupeKey (unique). tenantId nullable für Plattform-Mails (scope=platform, analog AuditLog). Bewusst nicht gespeichert: Mail-Inhalt und jegliche Tokens.
  • NotificationPreference — je Nutzer und Ereignistyp. Default opt-in: fehlt die Zeile, wird versendet; erst ein ausdrückliches email = false unterdrückt. Die Pflege-UI folgt später — Modell und Auflösung stehen.

Beide in TENANT_MODELS und unter RLS (ENABLE + FORCE + USING/WITH CHECK, Migration 20260730180000_mail_fundament, inkl. GRANT für isms_app).


5. Templates

Acht Templates, jeweils de und en, HTML und Text: invitation, password_reset, password_changed, email_change_verify, email_changed_notice, mfa_changed, notification, test.

Layout, Farben und die Fußzeile „Certvia — ein Produkt von GEFIM" kommen aus src/lib/email-brand.ts (Inline-Styles, Tabellenlayout für Outlook, absolute Asset-URLs).

i18n-Abweichung mit Begründung: die Sprachen liegen in einem eigenen Katalog (src/server/mail/templates.ts), nicht in messages/*.json. Die Mails werden im Worker gerendert — außerhalb eines Requests; die next-intl-Server-APIs (getTranslations) setzen einen Request-Scope voraus und stehen dort nicht zur Verfügung.

Tokens: Templates bekommen fertige actionUrls. Erzeugt werden die Tokens in SEC2/SEC3/SEC4 — sie erscheinen weder im MailLog noch im Server-Log.


6. Benachrichtigungen

Ereignis Auslöser Empfänger
task_assigned createTask, updateTask (nur bei Wechsel des Zuständigen) neuer Zuständiger
task_approval_requested submitForApproval (Richtlinien) ausgewählter Freigeber
task_decided approveTask / rejectTask Einreicher
task_due Fristen-Job (täglich) Zuständiger

Regeln: strikt mandantenisoliert, Sprache je Empfänger (Präferenz → Mandanten- Locale → de), kein Selbstversand über die eigene Handlung, Idempotenz über dedupeKey (der Fristen-Job hängt das Datum an → höchstens eine Erinnerung je Aufgabe und Tag). Fehler beim Versand werden geloggt, kippen aber nie die auslösende Fachaktion.

Benachrichtigungen tragen einen Präferenzhinweis in der Fußzeile; sicherheitsrelevante Transaktionsmails (Reset, Passwortwechsel) bewusst nicht — sie sind nicht abbestellbar.


7. Admin-Testversand

/admin zeigt Konfigurationszustand, Modus (Queue/inline) und die letzten zehn MailLog-Zeilen; der Button „Test-Mail senden" schickt an die eigene Adresse des angemeldeten Plattform-Admins (kein Empfängerfeld — die Funktion ist eine Zustellprüfung, kein Relay). Autorisierung über requirePlatformSession(); mail.ts ist in scripts/check-module-guards.ts als EXEMPT registriert.


8. Verifikation

docker compose up -d mailhog     # SMTP :1025, Weboberfläche :8025
npx tsx scripts/test-mail.ts     # Abnahmetest

Der Test prüft: Rendering aller Templates in de/en (HTML + Text, Branding + Dachmarken-Fußzeile), Präferenzhinweis nur bei Benachrichtigungen, echten Versand mit MailLog=sent + providerMessageId, Idempotenz über dedupeKey und das Verhalten bei fehlender SMTP-Konfiguration.

Nachgewiesen am 30.07.2026 gegen Mailhog: alle Prüfungen grün, Nachricht kommt als multipart/alternative (HTML + Text) mit Absender Certvia <…>, Auto-Submitted: auto-generated und korrekter Fußzeile an.

Zusätzlich: npx tsc --noEmit → npm run lint → npm run build (Guard-Check).


9. Offene Punkte / Vorbedingungen

  • DNS: SPF, DKIM und DMARC für die Absenderdomain müssen vor dem ersten Produktivversand aktiv sein (Ops, kein Code).
  • Bounce-Verarbeitung: MailLog.status kennt bounced, es gibt aber noch keinen Rückkanal (Webhook/IMAP). Erst mit einem Provider sinnvoll, der Bounces meldet.
  • Präferenz-UI: NotificationPreference ist modelliert und wird ausgewertet; die Pflegeoberfläche im Profil fehlt noch.
  • Worker-Deployment: npm run worker:mail muss in docker-compose.coolify.yml als eigener Service ergänzt werden (der vorhandene worker-Service im lokalen Compose zeigt noch auf keinen Befehl).