# 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).