L11 Kundenversand: Tests und Lane-Bericht

- 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>
This commit is contained in:
2026-09-14 18:49:06 +02:00
co-authored by Claude Opus 5
parent 29c80a0e73
commit fe891b1b79
2 changed files with 374 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
# 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 <Betrieb> ü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
1. **Worker-Retries bei permanentem Anhangsfehler:** `worker.ts` (nicht Lane-Ownership) wiederholt jeden Nicht-`MailNotConfiguredError` bis zu 5-mal. `MailAttachmentError` ist permanent – das MailLog steht sofort auf `failed`, die Retries laufen aber ins Leere und enden in der Dead-Letter-Queue. Vorschlag (Einzeiler im Worker): `MailAttachmentError` wie `MailNotConfiguredError` in `UnrecoverableError` umwandeln.
2. **Ergebnis `failed` bei fehlender SMTP-Konfiguration:** Das MailLog bleibt `pending` (SEC1-Verhalten), die UI meldet „fehlgeschlagen"; ein erneuter Versand an dieselbe Adresse ist wegen Dedupe dann `duplicate`. Erneutes Zustellen ausstehender MailLogs ist Betriebsthema (kein Resend-Knopf im MVP).
3. Versandliste zeigt nur die aktuelle Berichtsversion (neue Version = neuer Bericht = neue Liste); keine Anzeige von Fehlertexten (bewusst, keine internen Details).
4. Keine Mehrfach-Empfänger/CC; Sprache folgt der Mandanten-Locale, nicht dem Kunden.
5. Umgebung: Das Kopieren der Haupt-`.env` wurde vom Berechtigungssystem blockiert; die Lane-`.env` wurde aus `.env.example` (lokale Docker-Defaults, eigene `AUTH_SECRET`/`PASSWORD_PEPPER`) mit DB `craftvia_kundenversand` und `RLS_DATABASE_URL` auf dieselbe DB erzeugt.
6. 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 |