import "dotenv/config"; import { prisma } from "../src/server/db"; import { enqueueMail } from "../src/server/mail/service"; import { renderTemplate, TEMPLATE_KEYS, formatWhen } from "../src/server/mail/templates"; import { getMailConfig, resetMailConfigCache } from "../src/server/mail/config"; import { closeQueues, isQueueEnabled, isQueueReady } from "../src/server/mail/queue"; import { closeMailProvider } from "../src/server/mail/provider-smtp"; /** * SEC1 — Abnahmetest der Mail-Strecke (`npx tsx scripts/test-mail.ts`). * * Prüft ohne laufende App: * 1. Alle Templates rendern in de und en, HTML **und** Text, mit Craftvia- * Branding und Dachmarken-Fußzeile. * 2. Transaktionsmails tragen KEINEN Abmelde-Hinweis, Benachrichtigungen schon. * 3. Ein echter Versand landet im lokalen SMTP (Mailhog/Mailpit) und das * MailLog steht auf `sent` mit providerMessageId. * 4. Idempotenz: derselbe dedupeKey erzeugt nur eine Mail. * 5. Fehlende SMTP-Konfiguration führt zu `pending` + Begründung, nicht zu * einem scheinbar erfolgreichen Versand. * * Voraussetzung für 3./4.: lokaler SMTP auf SMTP_HOST/SMTP_PORT * (`docker compose up -d mailhog` → localhost:1025). */ let failures = 0; function check(name: string, ok: boolean, detail?: string) { if (ok) { console.log(` ✓ ${name}`); } else { failures++; console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`); } } const SAMPLE = { invitation: { name: "Erika Muster", tenantName: "Muster GmbH", actionUrl: "https://example.test/a", expires: "morgen" }, password_reset: { name: "Erika Muster", actionUrl: "https://example.test/r", expires: "in 60 Minuten" }, password_changed: { name: "Erika Muster", when: "heute", ip: "203.0.113.7" }, email_change_verify: { name: "Erika Muster", actionUrl: "https://example.test/v", expires: "in 60 Minuten", newEmail: "neu@example.test" }, email_changed_notice: { name: "Erika Muster", newEmail: "neu@example.test", when: "heute" }, mfa_changed: { name: "Erika Muster", change: "aktiviert", when: "heute" }, notification: { name: "Erika Muster", subject: "Neuer Auftrag", body: "Text", actionUrl: "https://example.test/t", eventType: "work_order_assigned" }, test: { name: "Erika Muster", when: "heute" }, } as const; async function main() { console.log("1) Template-Rendering (de/en, HTML + Text)"); for (const key of TEMPLATE_KEYS) { for (const locale of ["de", "en"] as const) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const r = renderTemplate(key, locale, SAMPLE[key] as any); const ok = r.subject.length > 0 && r.html.includes("") && r.html.includes("Craftvia") && r.html.includes("Handwerk. Digital auf Kurs.") && r.text.length > 0 && !r.text.includes("<"); check(`${key}/${locale}`, ok, `subject="${r.subject}"`); } } console.log("2) Abmelde-Hinweis nur bei Benachrichtigungen"); const notif = renderTemplate("notification", "de", SAMPLE.notification); const reset = renderTemplate("password_reset", "de", SAMPLE.password_reset); check("notification trägt Präferenz-Hinweis", notif.text.includes("Einstellungen dazu")); check("password_reset trägt KEINEN Abmelde-Hinweis", !reset.text.includes("Einstellungen dazu")); const { config } = getMailConfig(); if (!config) { console.log("3-4) übersprungen — keine SMTP-Konfiguration gesetzt."); } else { const mode = isQueueEnabled() && isQueueReady() ? "Queue (BullMQ)" : "inline"; console.log(`3) Versand über ${config.host}:${config.port} — Modus: ${mode}`); const key = `sec1-test:${process.pid}`; const first = await enqueueMail({ template: "test", to: "sec1-abnahme@example.test", tenantId: null, locale: "de", dedupeKey: key, vars: { name: "Abnahme", when: formatWhen(new Date(), "de") }, }); check("Versand erfolgreich", first.status === "sent" || first.status === "queued", first.status); if ("mailLogId" in first) { const row = await prisma.mailLog.findUnique({ where: { id: first.mailLogId } }); check("MailLog-Status", row?.status === "sent" || row?.status === "pending", row?.status); check("scope=platform bei tenantId=null", row?.scope === "platform"); if (row?.status === "sent") { check("providerMessageId gesetzt", Boolean(row.providerMessageId)); check("keine Klartext-Secrets im Log", !JSON.stringify(row).includes("password")); } } console.log("4) Idempotenz"); const second = await enqueueMail({ template: "test", to: "sec1-abnahme@example.test", tenantId: null, locale: "de", dedupeKey: key, vars: { name: "Abnahme", when: formatWhen(new Date(), "de") }, }); check("zweiter Aufruf mit gleichem dedupeKey → duplicate", second.status === "duplicate", second.status); // Aufräumen if ("mailLogId" in first) { await prisma.mailLog.delete({ where: { id: first.mailLogId } }).catch(() => {}); } } console.log("5) Fehlende SMTP-Konfiguration"); const saved = process.env.SMTP_HOST; delete process.env.SMTP_HOST; resetMailConfigCache(); const missing = getMailConfig(); check("Konfiguration wird als unvollständig erkannt", missing.config === null); check("Begründung vorhanden", Boolean(missing.reason?.includes("SMTP_HOST")), missing.reason); if (saved) process.env.SMTP_HOST = saved; resetMailConfigCache(); await cleanup(); if (failures > 0) { console.error(`\n✗ ${failures} Prüfung(en) fehlgeschlagen.`); process.exit(1); } console.log("\n✓ SEC1-Mailstrecke: alle Prüfungen bestanden."); // Explizit beenden: offene Sockets (SMTP-Pool, Redis) halten sonst den // Event-Loop offen, obwohl alle Prüfungen durch sind. process.exit(0); } async function cleanup() { await prisma.$disconnect(); await closeMailProvider(); await closeQueues(); } main().catch(async (err) => { console.error(err); await cleanup(); process.exit(1); });