- docker-compose.coolify(.prebuilt).yml: Service craftvia-worker (Target worker, Chromium, shm_size 1gb, gleiche Härtung), Craftvia-Variablen für app und worker. - Dockerfile: worker-Stage mit HOME=/home/app (Chromium-Profil für non-root); lokaler docker build der Targets runner und worker erfolgreich, PDF-Erzeugung im Image geprüft. - .env.example/.env.prod.example/.env.coolify.example: alle Craftvia-Variablen inkl. RLS, KI-Provider, PDF_CHROMIUM_PATH, OFFLINE_MAX_DAYS, API_RATE_LIMIT_*, AI_GENERATION_RETENTION_DAYS, AI_MONTHLY_TOKEN_LIMIT. - CI (.github, .gitea): Job gate mit Postgres (pgvector) und Redis als Service: migrate deploy, seed, Passwort für craftvia_app, tsc, lint, build, npm run test. - docs/craftvia/DEPLOY.md (aus den Certvia-Deploy-Docs abgeleitet): Architektur, Domains, Secrets, Worker, Migrationen, RLS-Aktivierung, Backup/Restore, KI, Rate Limits, Aufbewahrung, Smoke, Update/Rollback. build-and-push-images.sh baut craftvia-worker. - Certvia-/ISMS-Dokumente aus docs/ nach docs/_certvia-archiv/ (mit README); Verweise in README.md und Skript-Kommentaren angepasst. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.2 KiB
SEC1 — SMTP-Mail-Fundament
Branch
dev-sec1-mail-smtp(Basisdev) · Grundlage:docs/sicherheit/SEC1-Mail-Fundament-Detail.mdFundament 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).tenantIdnullable für Plattform-Mails (scope=platform, analogAuditLog). Bewusst nicht gespeichert: Mail-Inhalt und jegliche Tokens.NotificationPreference— je Nutzer und Ereignistyp. Default opt-in: fehlt die Zeile, wird versendet; erst ein ausdrücklichesemail = falseunterdrü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.statuskenntbounced, es gibt aber noch keinen Rückkanal (Webhook/IMAP). Erst mit einem Provider sinnvoll, der Bounces meldet. - Präferenz-UI:
NotificationPreferenceist modelliert und wird ausgewertet; die Pflegeoberfläche im Profil fehlt noch. - Worker-Deployment:
npm run worker:mailmuss indocker-compose.coolify.ymlals eigener Service ergänzt werden (der vorhandeneworker-Service im lokalen Compose zeigt noch auf keinen Befehl).