- 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>
323 lines
21 KiB
TypeScript
323 lines
21 KiB
TypeScript
// 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<unknown>, 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: `<fake-${this.sent.length}@${MAIL_DOMAIN}>` };
|
|
}
|
|
}
|
|
|
|
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 <Hausverwaltung> 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<Parameters<typeof enqueueMail>[0]> = [];
|
|
const deps = { enqueue: async (input: Parameters<typeof enqueueMail>[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:<id>:<to>:<version>");
|
|
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<typeof enqueueMail>[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("<Hausverwaltung>"), "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: "<b>Hallo</b>" });
|
|
ok(
|
|
r.html.includes("<!doctype html>") && r.html.includes("<b>Hallo</b>") && !r.html.includes("<b>Hallo</b>") && !/[\r\n]/.test(r.subject) && r.text.includes("<b>Hallo</b>"),
|
|
`${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);
|
|
});
|