- scripts/test-report-customer-mail.ts: 41 Prüfungen (Rechte/Scope, Mandantentrennung beim Zustellen, Prüfsumme, Größenlimit, Dedupe, Fake-Provider, Mailhog-Durchstich) - docs/craftvia/lanes/kundenversand.md: Umfang, Gate 53/53, HTTP-Smoke 13/13, offene Punkte Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.7 KiB
Lane L11 – Kundenversand (lane/kundenversand)
Stand: 2026-09-14 · Basis bc58738 (feature/craftvia-mvp, L1–L9 + L10b integriert) · Spec §17.3 („optional per E-Mail versendet"), §36.2 (Soll)
1. Umfang / erfüllte Punkte
| Punkt | Umsetzung |
|---|---|
| Mail-Fundament: Anhänge per Referenz | MailJob.attachments?: MailAttachmentRef[] ({ documentId }) – in Redis landen nur IDs, nie Bytes. EnqueueInput.attachments nur mit tenantId (sonst Fehler vor dem MailLog-Insert). OutgoingMail.attachments → nodemailer. MailLog unverändert (keine Spalte). |
| Zustellung (deliver.ts) | loadMailAttachments(mailLogId, refs) vor provider.send: Mandant ausschließlich aus der MailLog-Zeile, Document mit tenantId = MailLog.tenantId, deletedAt: null, Storage-Key mit Mandanten-Präfix, Bytes über readStoredBytes (Lazy-Import), SHA-256 = checksum, Summe ≤ MAIL_MAX_ATTACHMENT_BYTES (Default 10 MB), max. 10 Anhänge. Fehler → MailAttachmentError → MailLog failed mit klarer Meldung, keine Mail (auch nicht ohne Anhang). Speicher nicht erreichbar → TransientMailError (Retry). deliverMail(job, { provider? }) für Tests (additiv). |
| Template | craftvia_report_customer (de/en), eigene Liste CUSTOMER_TEMPLATE_KEYS (SEC1-/L6-Listen und deren Tests unverändert). Vars { customerName, tenantName, reportTitle, reportDate, message? }. Kein App-Link, Hinweis „Der Arbeitsnachweis ist als PDF angehängt.", Fußzeile „…von über Craftvia versendet". Betreff einzeilig (CR/LF entfernt), Freitext HTML-escaped (email-brand). Absendername/Reply-To des Mandanten über bestehendes tenantSender. |
| Service | services/reports/send-to-customer.ts#sendReportToCustomer(ctx, { reportId, to?, message? }, deps?): report:approve, requireVisibleReport, Status approved sonst blocked report_not_approved, PDF-Dokument vorhanden (nicht gelöscht) sonst blocked pdf_missing, Empfänger to (Zod-E-Mail, getrimmt, klein) oder Ansprechpartner des Auftrags (nicht gelöscht) → Kunde, sonst invalid recipient_missing (details.field = "to"). dedupeKey report-customer:<reportId>:<to>:<version> → duplicate. Audit export/report mit { op: "send_to_customer", to, version, mailLogId, delivery, withMessage }. Sprache = Mandanten-Locale (Default de). Zusätzlich defaultReportRecipient, listCustomerMailings. |
| UI | /reports/[id]: neuer Abschnitt „An Kunden senden" (nur approved + PDF + report:approve): Empfänger vorbelegt (Hinweis, wenn keiner hinterlegt), optionale Nachricht, Button (44 px); Rückmeldung versendet / wird versendet / bereits an … gesendet / fehlgeschlagen (Text + Icon); Liste „Bisherige Versände" aus MailLog (Adresse, Zeitpunkt, Version, Status mit Icon + Text). |
| Server Action | actions/reports/send-to-customer.ts#sendReportToCustomerAction (moduleGuard("reports"), await guard("report:approve")), State-Typ in components/reports/send-to-customer-state.ts. |
| API | POST /api/v1/reports/{id}/send (requireApiContext("reports", "report:approve"), withApi → Same-Origin), Body optional { to?, message? }; 202 queued, 200 sent/duplicate/failed; Fehler 404/422 im Einheitsformat. OpenAPI-Eintrag sendReportToCustomer. |
2. Dateien
- Neu:
src/server/services/reports/send-to-customer.ts,src/server/actions/reports/send-to-customer.ts,src/components/reports/send-to-customer.tsx,src/components/reports/send-to-customer-state.ts,src/app/api/v1/reports/[id]/send/route.ts,scripts/test-report-customer-mail.ts, dieser Bericht. - Geändert (additiv):
src/server/mail/{job,service,provider,provider-smtp,deliver,templates}.ts,src/app/(app)/reports/[id]/page.tsx(Imports + neuer Abschnitt),src/lib/api/openapi.ts(ein Pfad),messages/{de,en}/reports.json(customerMail). - Fremd-Eingriffe: keine. Migrationen: keine (MailLog/Document reichen). Neue Abhängigkeiten: keine.
3. Tests
| Skript | Prüfungen | Inhalt |
|---|---|---|
test-report-customer-mail.ts |
41 | Monteur/Teamleiter → forbidden, Mandant B (Senden, Versandliste) → not_found; nicht freigegeben / ohne PDF / PDF gelöscht → blocked mit reason; ohne Empfänger → invalid recipient_missing; Header-Injection-Adresse → invalid; Default-Empfänger Kontakt → Kunde bei gelöschtem Kontakt; MailLog pending + Template + Mandant + dedupeKey; Nutzlast nur Dokument-Referenz; Audit export; enqueueMail mit Anhang ohne tenantId → Fehler ohne MailLog; Dedupe (Groß-/Kleinschreibung) und erneuter Versand an andere Adresse; Zustellung mit Fake-Provider (Bytes = gespeichertes PDF, SHA-256 = checksum, .pdf/application/pdf, Betreff, Text ohne App-Link, Reply-To/Absendername, HTML-Escaping, MailLog sent); Mandantentrennung beim Zustellen (Dokument von B, Plattform-MailLog, unbekannte ID) → failed, nichts gesendet; Prüfsummen-Manipulation, soft-gelöschtes Dokument, Größenlimit → failed; Template de/en; SMTP-Durchstich gegen Mailhog (Anhang im Rohtext, MailLog sent; Skip ohne Mailhog) |
test-mail.ts, test-audit-mail-context.ts |
unverändert | grün |
Gate (npm run gate) grün: prisma generate, tsc, lint (0 Fehler, 3 vorbestehende Warnungen in fremden Dateien: scripts/test-betrieb-api.ts, src/app/(app)/layout.tsx, src/server/services/field/mime.ts), build inkl. Modul-Guard-Check (32 Action-Dateien), 53/53 Testskripte. Lane-DB craftvia_kundenversand, RLS_DATABASE_URL auf dieselbe DB.
HTTP-Smoke (Dev-Server :3112 ohne REDIS_URL → Inline-Versand an Mailhog, Session-Cookies ohne Passworteingabe nach scripts/smoke-auth.ts, Fixtures im Mandanten demo, danach entfernt): 13/13 grün – Backoffice sieht den Abschnitt mit vorbelegtem Empfänger bei freigegebenem Bericht, nicht bei eingereichtem; Monteur 404; API anonym 401, fremder Origin 403, Monteur 403, nicht freigegeben 422 blocked, ungültige Adresse 422, Backoffice 200 sent, zweiter Aufruf 200 duplicate; Seite zeigt den Versand mit Status; Mailhog enthält die Mail mit PDF-Anhang.
4. Stubs / Abhängigkeiten
Keine Stubs. Genutzt: L5 requireVisibleReport/contentOf/PDF-Dokument, L1 readStoredBytes, requireApiContext/withApi, SEC1/L6 Mail-Kern inkl. tenantSender.
5. Bekannte Lücken / offene Punkte
- Worker-Retries bei permanentem Anhangsfehler:
worker.ts(nicht Lane-Ownership) wiederholt jeden Nicht-MailNotConfiguredErrorbis zu 5-mal.MailAttachmentErrorist permanent – das MailLog steht sofort auffailed, die Retries laufen aber ins Leere und enden in der Dead-Letter-Queue. Vorschlag (Einzeiler im Worker):MailAttachmentErrorwieMailNotConfiguredErrorinUnrecoverableErrorumwandeln. - Ergebnis
failedbei fehlender SMTP-Konfiguration: Das MailLog bleibtpending(SEC1-Verhalten), die UI meldet „fehlgeschlagen"; ein erneuter Versand an dieselbe Adresse ist wegen Dedupe dannduplicate. Erneutes Zustellen ausstehender MailLogs ist Betriebsthema (kein Resend-Knopf im MVP). - Versandliste zeigt nur die aktuelle Berichtsversion (neue Version = neuer Bericht = neue Liste); keine Anzeige von Fehlertexten (bewusst, keine internen Details).
- Keine Mehrfach-Empfänger/CC; Sprache folgt der Mandanten-Locale, nicht dem Kunden.
- Umgebung: Das Kopieren der Haupt-
.envwurde vom Berechtigungssystem blockiert; die Lane-.envwurde aus.env.example(lokale Docker-Defaults, eigeneAUTH_SECRET/PASSWORD_PEPPER) mit DBcraftvia_kundenversandundRLS_DATABASE_URLauf dieselbe DB erzeugt. - Visuelle Browser-Prüfung nicht durchgeführt (nur Server-Rendering/HTTP).
6. Screens / Routen
| Route | Änderung |
|---|---|
/reports/[id] |
Abschnitt „An Kunden senden" + „Bisherige Versände" |
POST /api/v1/reports/{id}/send |
neu |