import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { enqueueEmail, flushEmails } from "@/lib/email"; import { resetEnvCache } from "@/lib/env"; import { db, resetDb } from "./support/db"; const sendMail = vi.fn(); // SMTP-Versand simulieren; der Datei-Transport (streamTransport) bleibt echt. vi.mock("nodemailer", async (importOriginal) => { const actual = await importOriginal(); const createTransport = (options: Record) => options.streamTransport ? actual.default.createTransport(options) : { sendMail }; return { ...actual, default: { ...actual.default, createTransport } }; }); function smtpError(message: string, responseCode?: number, code = "EENVELOPE") { return Object.assign(new Error(message), { code, responseCode }); } async function queueMail(to = "test@test.de") { await enqueueEmail(db, { dedupeKey: `test:${to}`, to, subject: "Buchung bestätigt", text: "Hallo", html: "

Hallo

" }); return db.emailMessage.findFirstOrThrow({ where: { toAddress: to } }); } beforeEach(async () => { await resetDb(); process.env.MAIL_TRANSPORT = "smtp"; process.env.SMTP_HOST = "smtp.example.org"; resetEnvCache(); sendMail.mockReset(); }); afterEach(() => { delete process.env.MAIL_TRANSPORT; delete process.env.SMTP_HOST; resetEnvCache(); vi.restoreAllMocks(); }); describe("Postausgang: Fehler beim SMTP-Versand", () => { test("dauerhafte Ablehnung (5xx) wird sofort FAILED und nie wiederholt", async () => { const mail = await queueMail(); sendMail.mockRejectedValue( smtpError("Can't send mail - all recipients were rejected: 556 5.1.10 : Recipient address rejected: Domain test.de does not accept mail (nullMX)", 556), ); vi.spyOn(console, "error").mockImplementation(() => {}); expect(await flushEmails(db)).toEqual({ sent: 0, failed: 1 }); const after = await db.emailMessage.findUniqueOrThrow({ where: { id: mail.id } }); expect(after.status).toBe("FAILED"); expect(after.attempts).toBe(1); expect(await flushEmails(db)).toEqual({ sent: 0, failed: 0 }); expect(sendMail).toHaveBeenCalledTimes(1); }); test("Log und gespeicherter Fehler enthalten keine E-Mail-Adresse", async () => { await queueMail(); sendMail.mockRejectedValue(smtpError("all recipients were rejected: 556 5.1.10 : Recipient address rejected", 556)); const log = vi.spyOn(console, "error").mockImplementation(() => {}); await flushEmails(db); const logged = log.mock.calls.flat().join(" "); expect(logged).toContain("556 5.1.10"); expect(logged).not.toContain("test@test.de"); expect((await db.emailMessage.findFirstOrThrow()).lastError).not.toContain("test@test.de"); }); test("vorübergehende Fehler (4xx, Verbindung) bleiben PENDING und werden wiederholt", async () => { const greylisted = await queueMail("a@example.org"); const offline = await queueMail("b@example.org"); sendMail .mockRejectedValueOnce(smtpError("421 4.7.0 Try again later", 421)) .mockRejectedValueOnce(Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNECTION" })); vi.spyOn(console, "error").mockImplementation(() => {}); expect(await flushEmails(db)).toEqual({ sent: 0, failed: 2 }); for (const id of [greylisted.id, offline.id]) { expect(await db.emailMessage.findUniqueOrThrow({ where: { id } })).toMatchObject({ status: "PENDING", attempts: 1 }); } }); test("Server- und Absenderfehler (Anmeldung, Richtlinie) bleiben PENDING statt FAILED", async () => { const auth = await queueMail("auth@example.org"); const policy = await queueMail("policy@example.org"); sendMail .mockRejectedValueOnce(smtpError("535 5.7.8 Authentication failed", 535, "EAUTH")) .mockRejectedValueOnce(smtpError("554 5.7.1 Message rejected as spam", 554, "EMESSAGE")); vi.spyOn(console, "error").mockImplementation(() => {}); expect(await flushEmails(db)).toEqual({ sent: 0, failed: 2 }); for (const id of [auth.id, policy.id]) { expect(await db.emailMessage.findUniqueOrThrow({ where: { id } })).toMatchObject({ status: "PENDING", attempts: 1 }); } }); test("EENVELOPE mit 550/551/553/556 oder 5.1.x ist endgueltig, 552 und 4xx nicht", async () => { const cases: [number, string, boolean][] = [ [550, "550 5.1.1 User unknown", true], [551, "551 User not local", true], [553, "553 Mailbox name not allowed", true], [554, "554 5.1.2 Bad destination system address", true], [552, "552 Mailbox full", false], [451, "451 4.1.1 Try later", false], ]; vi.spyOn(console, "error").mockImplementation(() => {}); for (const [index, [code, response, permanent]] of cases.entries()) { await resetDb(); const mail = await queueMail(`r${index}@example.org`); sendMail.mockRejectedValueOnce(Object.assign(smtpError(response, code), { response })); await flushEmails(db); const after = await db.emailMessage.findUniqueOrThrow({ where: { id: mail.id } }); expect(after.status, response).toBe(permanent ? "FAILED" : "PENDING"); } }); });