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>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user