From fe891b1b79fe486a1aff92bad8baa2629141958c Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:49:06 +0200 Subject: [PATCH] L11 Kundenversand: Tests und Lane-Bericht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/craftvia/lanes/kundenversand.md | 52 +++++ scripts/test-report-customer-mail.ts | 322 +++++++++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 docs/craftvia/lanes/kundenversand.md create mode 100644 scripts/test-report-customer-mail.ts diff --git a/docs/craftvia/lanes/kundenversand.md b/docs/craftvia/lanes/kundenversand.md new file mode 100644 index 0000000..b4b28b6 --- /dev/null +++ b/docs/craftvia/lanes/kundenversand.md @@ -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 ü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:::` → `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 | diff --git a/scripts/test-report-customer-mail.ts b/scripts/test-report-customer-mail.ts new file mode 100644 index 0000000..428a8b8 --- /dev/null +++ b/scripts/test-report-customer-mail.ts @@ -0,0 +1,322 @@ +// L11 Kundenversand — Bericht-PDF per E-Mail an Kunden (Spec §17.3, §36.2). +// +// Deckt ab: Rechte/Scope (Monteur/Teamleiter → forbidden, Mandant B → not_found), Status-/PDF-Sperren, +// Empfängerermittlung, MailLog pending + Nutzlast nur mit Dokument-Referenz, Zustellung mit Fake-Provider +// (Bytes = gespeichertes PDF, Prüfsumme, Dateiname), Mandantentrennung beim Zustellen, Prüfsummen- +// Manipulation, gelöschtes Dokument, Größenlimit, Dedupe, Versandliste, Template-Escaping, optional +// echter SMTP-Durchstich gegen Mailhog. +// +// Lauf: npx tsx scripts/test-report-customer-mail.ts + +import "dotenv/config"; +import { createHash } from "node:crypto"; +import type { Prisma } from "@prisma/client"; + +// Inline mode without SMTP for the service part: the MailLog must stay `pending` (no queue, no real mail). +const SAVED_SMTP_HOST = process.env.SMTP_HOST; +delete process.env.REDIS_URL; +delete process.env.SMTP_HOST; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +const SLUG_A = "zz-kundenversand-a"; +const SLUG_B = "zz-kundenversand-b"; +const MAIL_DOMAIN = "zz-kundenversand.test"; +const EMAIL = (s: string) => `${s}@${MAIL_DOMAIN}`; +const PDF_BYTES = Buffer.from("%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n", "latin1"); +const sha = (b: Buffer) => createHash("sha256").update(b).digest("hex"); + +async function main() { + const { prisma, dbForTenant } = await import("../src/server/db"); + const { ROLE_DEFS } = await import("../src/server/rbac"); + const { ServiceError } = await import("../src/server/services/context"); + const { buildReportContent } = await import("../src/server/services/reports/build-content"); + const { storeFile } = await import("../src/server/services/documents/store"); + const { readStoredBytes } = await import("../src/server/services/documents/read"); + const svc = await import("../src/server/services/reports/send-to-customer"); + const { enqueueMail } = await import("../src/server/mail/service"); + const { deliverMail } = await import("../src/server/mail/deliver"); + const { resetMailConfigCache, getMailConfig } = await import("../src/server/mail/config"); + const { renderTemplate, CUSTOMER_TEMPLATE_KEYS } = await import("../src/server/mail/templates"); + const { closeMailProvider } = await import("../src/server/mail/provider-smtp"); + type ServiceCtx = import("../src/server/services/context").ServiceCtx; + type RoleKey = import("../src/server/rbac").RoleKey; + type MailJob = import("../src/server/mail/job").MailJob; + type OutgoingMail = import("../src/server/mail/provider").OutgoingMail; + + const expectCode = async (fn: () => Promise, code: string, msg: string, reason?: string) => { + try { + await fn(); + ok(false, `${msg} — kein Fehler`); + } catch (err) { + const got = err instanceof ServiceError ? err.code : (err as Error).name === "ZodError" ? "invalid" : (err as Error).message; + const gotReason = err instanceof ServiceError ? (err.details as { reason?: string } | undefined)?.reason : undefined; + ok(got === code && (!reason || gotReason === reason), `${msg} (${got}${gotReason ? `/${gotReason}` : ""})`); + } + }; + + async function cleanup() { + const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } }); + const ids = tenants.map((t) => t.id); + await prisma.mailLog.deleteMany({ where: { to: { endsWith: `@${MAIL_DOMAIN}` } } }); + if (ids.length) { + const w = { where: { tenantId: { in: ids } } }; + await prisma.mailLog.deleteMany(w); + await prisma.report.deleteMany(w); + await prisma.workOrderStatusChange.deleteMany(w); + await prisma.workOrderAssignee.deleteMany(w); + await prisma.document.deleteMany(w); + await prisma.workOrder.deleteMany(w); + await prisma.teamMember.deleteMany(w); + await prisma.team.deleteMany(w); + await prisma.contact.deleteMany(w); + await prisma.customer.deleteMany(w); + await prisma.numberSequence.deleteMany(w); + await prisma.auditLog.deleteMany(w); + await prisma.tenantSettings.deleteMany(w); + await prisma.user.deleteMany(w); + await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); + } + await prisma.identity.deleteMany({ where: { email: { endsWith: `@${MAIL_DOMAIN}` } } }); + } + + const mkUser = async (tenantId: string, key: string, name: string) => { + const identity = await prisma.identity.create({ data: { email: EMAIL(`${key}-${tenantId.slice(-6)}`), passwordHash: "x" } }); + return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name } }); + }; + const ctxOf = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({ + db: dbForTenant(tenantId), + tenantId, + userId, + permissions: new Set(ROLE_DEFS[role].permissions), + }); + + class FakeProvider { + sent: OutgoingMail[] = []; + async send(msg: OutgoingMail) { + this.sent.push(msg); + return { messageId: `` }; + } + } + + try { + await cleanup(); + + // ---------- fixtures ---------- + const tA = await prisma.tenant.create({ data: { name: "Kundenversand A", slug: SLUG_A } }); + const tB = await prisma.tenant.create({ data: { name: "Kundenversand B", slug: SLUG_B } }); + await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Musterbau A GmbH", mailFromName: "Musterbau A", mailReplyTo: EMAIL("buero") } }); + await prisma.tenantSettings.create({ data: { tenantId: tB.id, orgName: "Betrieb B" } }); + + const office = await mkUser(tA.id, "office", "Bernd Büro"); + const tech = await mkUser(tA.id, "tech", "Max Monteur"); + const lead = await mkUser(tA.id, "lead", "Tina Teamleiter"); + const officeB = await mkUser(tB.id, "officeb", "Zoe Büro B"); + const officeCtx = ctxOf(tA.id, office.id, "backoffice"); + const techCtx = ctxOf(tA.id, tech.id, "technician"); + const leadCtx = ctxOf(tA.id, lead.id, "team-lead"); + const officeBCtx = ctxOf(tB.id, officeB.id, "backoffice"); + + const team = await prisma.team.create({ data: { tenantId: tA.id, name: "Team Kunde", leaderUserId: lead.id } }); + await prisma.teamMember.create({ data: { tenantId: tA.id, teamId: team.id, userId: tech.id, validFrom: new Date("2026-01-01") } }); + const customer = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Kunde GmbH", customerNumber: "K-09100", email: EMAIL("kunde") } }); + const contact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Frau Kontakt", email: EMAIL("Kontakt") } }); + const customerNoMail = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Ohne Mail KG", customerNumber: "K-09101" } }); + + const mkOrder = (number: string, customerId: string, contactId: string | null) => + prisma.workOrder.create({ + data: { + tenantId: tA.id, number, customerId, contactId, title: "Heizung warten", status: "in_review", assignedTeamId: team.id, + assignees: { create: [{ tenantId: tA.id, userId: tech.id }] }, + }, + }); + const wo = await mkOrder("A-09100", customer.id, contact.id); + const woNoMail = await mkOrder("A-09101", customerNoMail.id, null); + + const storePdf = (ctx: ServiceCtx, workOrderId: string | null, name: string) => + storeFile(ctx, { + bytes: PDF_BYTES, fileName: name, declaredMime: "application/pdf", category: "completion_report", visibility: "customer_report", + ...(workOrderId ? { links: { workOrderId } } : {}), + }); + const mkReport = async (workOrderId: string, number: string, status: "approved" | "submitted", pdfDocumentId: string | null) => { + const content = await buildReportContent(officeCtx, { workOrderId, type: "completion", reportDate: "2026-09-10", reportNumber: number, version: 1, technicianUserId: tech.id }); + return prisma.report.create({ + data: { + tenantId: tA.id, workOrderId, type: "completion", reportDate: new Date("2026-09-10T00:00:00Z"), version: 1, lineageId: `zz-${number}`, + status, content: content as unknown as Prisma.InputJsonValue, pdfDocumentId, pdfChecksum: null, approvedAt: status === "approved" ? new Date() : null, + }, + }); + }; + + const pdfDoc = await storePdf(officeCtx, wo.id, "B-09100-v1.pdf"); + const stored = await readStoredBytes(pdfDoc.storageKey); + ok(Boolean(stored && stored.equals(PDF_BYTES)), "Fixture: PDF im Objektspeicher abgelegt und lesbar"); + const rApproved = await mkReport(wo.id, "B-09100", "approved", pdfDoc.id); + const rSubmitted = await mkReport(wo.id, "B-09101", "submitted", null); + const rNoPdf = await mkReport(wo.id, "B-09102", "approved", null); + const pdfNoMail = await storePdf(officeCtx, woNoMail.id, "B-09103-v1.pdf"); + const rNoMail = await mkReport(woNoMail.id, "B-09103", "approved", pdfNoMail.id); + + // ---------- rights / scope ---------- + console.log("\n— Rechte, Scope, Mandantentrennung —"); + await expectCode(() => svc.sendReportToCustomer(techCtx, { reportId: rApproved.id }), "forbidden", "Monteur → forbidden"); + await expectCode(() => svc.sendReportToCustomer(leadCtx, { reportId: rApproved.id }), "forbidden", "Teamleiter (ohne report:approve) → forbidden"); + await expectCode(() => svc.sendReportToCustomer(officeBCtx, { reportId: rApproved.id, to: EMAIL("b") }), "not_found", "Mandant B: Bericht von A senden → not_found"); + await expectCode(() => svc.listCustomerMailings(officeBCtx, rApproved.id), "not_found", "Mandant B: Versandliste von A → not_found"); + + // ---------- blockers ---------- + console.log("\n— Sperren & Empfänger —"); + await expectCode(() => svc.sendReportToCustomer(officeCtx, { reportId: rSubmitted.id, to: EMAIL("x") }), "blocked", "Nicht freigegeben → blocked", "report_not_approved"); + await expectCode(() => svc.sendReportToCustomer(officeCtx, { reportId: rNoPdf.id, to: EMAIL("x") }), "blocked", "Ohne PDF → blocked", "pdf_missing"); + await expectCode(() => svc.sendReportToCustomer(officeCtx, { reportId: rNoMail.id }), "invalid", "Ohne Empfänger (kein Kontakt, Kunde ohne Mail) → invalid", "recipient_missing"); + await expectCode(() => svc.sendReportToCustomer(officeCtx, { reportId: rApproved.id, to: "kein-mail\r\nBcc: x@y.z" }), "invalid", "Ungültige/Header-Injection-Adresse → invalid"); + ok((await svc.defaultReportRecipient(officeCtx, { workOrderId: wo.id })) === EMAIL("kontakt"), "Default-Empfänger = Ansprechpartner des Auftrags (normalisiert)"); + await prisma.contact.update({ where: { id: contact.id }, data: { deletedAt: new Date() } }); + ok((await svc.defaultReportRecipient(officeCtx, { workOrderId: wo.id })) === EMAIL("kunde"), "Gelöschter Ansprechpartner → Kunde als Empfänger"); + await prisma.contact.update({ where: { id: contact.id }, data: { deletedAt: null } }); + + // ---------- send (inline, SMTP disabled → pending) ---------- + console.log("\n— Einstellen —"); + const captured: Array[0]> = []; + const deps = { enqueue: async (input: Parameters[0]) => (captured.push(input), enqueueMail(input)) }; + const first = await svc.sendReportToCustomer(officeCtx, { reportId: rApproved.id, message: "Vielen Dank für den Auftrag.\n\nMit freundlichen Grüßen" }, deps); + ok(first.to === EMAIL("kontakt") && first.version === 1 && Boolean(first.mailLogId), `Versand angestoßen an Ansprechpartner (${first.status})`); + const row = first.mailLogId ? await prisma.mailLog.findUnique({ where: { id: first.mailLogId } }) : null; + ok(row?.status === "pending" && row.template === "craftvia_report_customer" && row.tenantId === tA.id && row.scope === "tenant", "MailLog pending, Template craftvia_report_customer, Mandant A"); + ok(row?.dedupeKey === `report-customer:${rApproved.id}:${EMAIL("kontakt")}:1`, "dedupeKey = report-customer:::"); + const payload = JSON.stringify(captured[0]); + ok( + JSON.stringify(captured[0]?.attachments) === JSON.stringify([{ documentId: pdfDoc.id }]) && !payload.includes("%PDF") && payload.length < 2000, + "Nutzlast enthält nur die Dokument-Referenz, keine Bytes", + ); + const audit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "report", entityId: rApproved.id, action: "export" } }); + const after = audit?.after as { op?: string; to?: string; version?: number } | null; + ok(after?.op === "send_to_customer" && after.to === EMAIL("kontakt") && after.version === 1, "Audit export mit op/to/version"); + + let enqueueThrew = false; + try { + await enqueueMail({ tenantId: null, template: "craftvia_report_customer", to: EMAIL("p"), vars: { customerName: "x", tenantName: "x", reportTitle: "x", reportDate: "x" }, attachments: [{ documentId: pdfDoc.id }] }); + } catch { + enqueueThrew = true; + } + ok(enqueueThrew && (await prisma.mailLog.count({ where: { to: EMAIL("p") } })) === 0, "enqueueMail: Anhänge ohne tenantId → Fehler, kein MailLog"); + + // ---------- dedupe ---------- + console.log("\n— Dedupe & Versandliste —"); + const dup = await svc.sendReportToCustomer(officeCtx, { reportId: rApproved.id, to: EMAIL("KONTAKT") }, deps); + ok(dup.status === "duplicate", `Gleiche Adresse (andere Schreibweise) + Version → duplicate (${dup.status})`); + const second = await svc.sendReportToCustomer(officeCtx, { reportId: rApproved.id, to: EMAIL("zweit") }, deps); + ok(second.status !== "duplicate" && Boolean(second.mailLogId), "Andere Adresse → erneuter Versand erlaubt"); + const mailings = await svc.listCustomerMailings(officeCtx, rApproved.id); + ok(mailings.length === 2 && mailings.every((m) => m.version === 1) && mailings.some((m) => m.to === EMAIL("zweit")), "Versandliste zeigt beide Versände mit Version"); + + // ---------- delivery with fake provider ---------- + console.log("\n— Zustellung —"); + process.env.SMTP_HOST = SAVED_SMTP_HOST || "localhost"; + resetMailConfigCache(); + ok(Boolean(getMailConfig().config), "SMTP-Konfiguration für Zustelltests aktiv"); + const jobOf = (mailLogId: string, input: Parameters[0]): MailJob => + ({ mailLogId, template: input.template, to: input.to.trim().toLowerCase(), locale: input.locale === "en" ? "en" : "de", vars: input.vars, attachments: input.attachments }) as MailJob; + + const fake = new FakeProvider(); + await deliverMail(jobOf(first.mailLogId!, captured[0]), { provider: fake }); + const msg = fake.sent[0]; + const att = msg?.attachments?.[0]; + ok(fake.sent.length === 1 && msg.attachments?.length === 1, "Fake-Provider erhält genau eine Mail mit einem Anhang"); + ok(Boolean(att && att.content.equals(PDF_BYTES)), "Anhang-Bytes = gespeichertes PDF"); + ok(Boolean(att && sha(att.content) === pdfDoc.checksum), "SHA-256 des Anhangs = Document.checksum"); + ok(Boolean(att && att.filename.endsWith(".pdf") && att.contentType === "application/pdf"), `Dateiname .pdf, Typ application/pdf (${att?.filename})`); + ok(msg.subject.includes("B-09100") && msg.subject.includes("Musterbau A GmbH") && !/[\r\n]/.test(msg.subject), `Betreff mit Berichtsnummer und Betrieb (${msg.subject})`); + ok(msg.text.includes("als PDF angehängt") && msg.text.includes("Vielen Dank für den Auftrag.") && !msg.text.includes("/reports/"), "Text: Hinweis Anhang, Nachricht, kein App-Link"); + ok(msg.replyTo === EMAIL("buero") && msg.from.startsWith("Musterbau A"), "Antwortadresse/Absendername des Mandanten"); + ok(msg.html.includes("Kunde <Hausverwaltung> GmbH") && !msg.html.includes(""), "Kundenname im HTML escaped"); + const sentRow = await prisma.mailLog.findUnique({ where: { id: first.mailLogId! } }); + ok(sentRow?.status === "sent" && Boolean(sentRow.providerMessageId), "MailLog → sent"); + + const deliverExpectFail = async (job: MailJob, msgText: string, errorPart: string) => { + const p = new FakeProvider(); + let threw = false; + try { + await deliverMail(job, { provider: p }); + } catch { + threw = true; + } + const r = await prisma.mailLog.findUnique({ where: { id: job.mailLogId } }); + ok(threw && p.sent.length === 0 && r?.status === "failed" && Boolean(r.error?.includes(errorPart)), `${msgText} (${r?.status}: ${r?.error})`); + }; + const newLog = async (tenantId: string | null) => + (await prisma.mailLog.create({ data: { tenantId, scope: tenantId ? "tenant" : "platform", to: EMAIL("iso"), template: "craftvia_report_customer" } })).id; + const vars = captured[0].vars; + const mkJob = (mailLogId: string, documentId: string): MailJob => + ({ mailLogId, template: "craftvia_report_customer", to: EMAIL("iso"), locale: "de", vars, attachments: [{ documentId }] }) as MailJob; + + console.log("\n— Mandantentrennung & Integrität beim Zustellen —"); + const docB = await storePdf(officeBCtx, null, "fremd.pdf"); + await deliverExpectFail(mkJob(await newLog(tA.id), docB.id), "Anhang-Referenz auf Dokument von Mandant B → failed, nichts gesendet", "nicht verfügbar"); + await deliverExpectFail(mkJob(await newLog(null), pdfDoc.id), "Plattform-MailLog mit Anhang → failed, nichts gesendet", "Mandanten-Mails"); + await deliverExpectFail(mkJob(await newLog(tA.id), "does-not-exist"), "Unbekannte Dokument-ID → failed", "nicht verfügbar"); + + const tampered = await storePdf(officeCtx, wo.id, "manipuliert.pdf"); + await prisma.document.update({ where: { id: tampered.id }, data: { checksum: "0".repeat(64) } }); + await deliverExpectFail(mkJob(await newLog(tA.id), tampered.id), "Prüfsummen-Manipulation → failed, nichts gesendet", "Prüfsumme"); + + const deleted = await storePdf(officeCtx, wo.id, "geloescht.pdf"); + await prisma.document.update({ where: { id: deleted.id }, data: { deletedAt: new Date() } }); + await deliverExpectFail(mkJob(await newLog(tA.id), deleted.id), "Soft-gelöschtes Dokument → failed", "nicht verfügbar"); + + process.env.MAIL_MAX_ATTACHMENT_BYTES = "10"; + await deliverExpectFail(mkJob(await newLog(tA.id), pdfDoc.id), "Anhang über MAIL_MAX_ATTACHMENT_BYTES → failed", "Größe"); + delete process.env.MAIL_MAX_ATTACHMENT_BYTES; + + // service-level: PDF soft-deleted after approval + await prisma.document.update({ where: { id: pdfNoMail.id }, data: { deletedAt: new Date() } }); + await expectCode(() => svc.sendReportToCustomer(officeCtx, { reportId: rNoMail.id, to: EMAIL("x") }), "blocked", "PDF-Dokument gelöscht → blocked", "pdf_missing"); + + // ---------- templates ---------- + console.log("\n— Template —"); + for (const key of CUSTOMER_TEMPLATE_KEYS) { + for (const locale of ["de", "en"] as const) { + const r = renderTemplate(key, locale, { customerName: "K", tenantName: "Betrieb\r\nBcc: x", reportTitle: "Abschlussbericht B-1", reportDate: "10.09.2026", message: "Hallo" }); + ok( + r.html.includes("") && r.html.includes("<b>Hallo</b>") && !r.html.includes("Hallo") && !/[\r\n]/.test(r.subject) && r.text.includes("Hallo"), + `${key}/${locale}: rendert, Nachricht escaped, Betreff einzeilig`, + ); + } + } + + // ---------- real SMTP (Mailhog) ---------- + console.log("\n— SMTP-Durchstich (Mailhog, optional) —"); + const mailhog = await fetch("http://localhost:8025/api/v2/messages?limit=1").then((r) => r.ok).catch(() => false); + if (!mailhog || !SAVED_SMTP_HOST) { + console.log("↷ übersprungen: Mailhog/SMTP nicht erreichbar"); + } else { + const smtpTo = EMAIL(`smtp-${Date.now()}`); + const smtpLog = (await prisma.mailLog.create({ data: { tenantId: tA.id, to: smtpTo, template: "craftvia_report_customer" } })).id; + await deliverMail({ ...mkJob(smtpLog, pdfDoc.id), to: smtpTo } as MailJob); + const found = (await fetch(`http://localhost:8025/api/v2/search?kind=to&query=${encodeURIComponent(smtpTo)}`).then((r) => r.json())) as { + items?: Array<{ Raw?: { Data?: string } }>; + }; + const raw = found.items?.[0]?.Raw?.Data ?? ""; + ok(/Content-Disposition: attachment/i.test(raw) && raw.includes("B-09100-v1.pdf") && raw.includes(PDF_BYTES.toString("base64").slice(0, 20)), "Mailhog: Mail mit PDF-Anhang zugestellt"); + ok((await prisma.mailLog.findUnique({ where: { id: smtpLog } }))?.status === "sent", "MailLog nach SMTP-Versand → sent"); + } + } finally { + await cleanup().catch((e) => console.error("cleanup failed", e)); + await closeMailProvider().catch(() => {}); + await prisma.$disconnect(); + } +} + +main() + .catch((err) => { + console.error(err); + failures++; + }) + .finally(() => { + console.log(failures ? `\n✗ ${failures} Fehler` : "\n✓ Alle Kundenversand-Tests grün"); + process.exit(failures ? 1 : 0); + });