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

178 lines
8.2 KiB
Markdown

# 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 `actionUrl`s. 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
```bash
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).