Files
Artista-Filmcamp/tests/email-outbox.integration.test.ts
T
Nick SzaunigandClaude Opus 5.5 34ccbc3b9a fix: SMTP-Fehler nur bei Empfaengerablehnung endgueltig
Bisher setzte jeder SMTP-5xx die Mail sofort auf FAILED, auch Anmelde-
und Richtlinienfehler (EAUTH, EMESSAGE, 552). Ein kaputtes SMTP-Secret
haette alle Buchungs- und Zahlungsmails verworfen, ohne Rueckweg
(dedupeKey verhindert erneutes Einreihen).

Endgueltig ist jetzt nur EENVELOPE mit 550, 551, 553, 556 oder 5xx mit
Status 5.1.x. Alles andere bleibt PENDING und wird bis
MAX_EMAIL_ATTEMPTS wiederholt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:20:34 +02:00

117 lines
5.1 KiB
TypeScript

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<typeof import("nodemailer")>();
const createTransport = (options: Record<string, unknown>) =>
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 = "[email protected]") {
await enqueueEmail(db, { dedupeKey: `test:${to}`, to, subject: "Buchung bestätigt", text: "Hallo", html: "<p>Hallo</p>" });
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 <[email protected]>: 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 <[email protected]>: 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("[email protected]");
expect((await db.emailMessage.findFirstOrThrow()).lastError).not.toContain("[email protected]");
});
test("vorübergehende Fehler (4xx, Verbindung) bleiben PENDING und werden wiederholt", async () => {
const greylisted = await queueMail("[email protected]");
const offline = await queueMail("[email protected]");
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("[email protected]");
const policy = await queueMail("[email protected]");
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");
}
});
});