Files
Artista-Filmcamp/tests/email-outbox.integration.test.ts
T
Nick SzaunigandClaude Opus 5.5 c3fdfbdc77
Docker-Images bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
fix(worker): endgueltig abgelehnte Mails nicht wiederholen, Adressen nicht loggen
Eine Mail an eine Adresse ohne Mailserver (SMTP 556, nullMX) wurde bis zu
8-mal erneut versendet und liess jeden Worker-Lauf mit Exit-Code 1 enden;
das SMTP-Fehlerlog enthielt dabei die Empfaengeradresse.

- SMTP-Antworten 5xx markieren die Mail sofort als FAILED, 4xx und
  Verbindungsfehler bleiben wiederholbar.
- Adressen werden aus Log und lastError entfernt.
- Worker-CronJob: backoffLimit 0 (kein zweiter Pod je Fehlschlag) und
  ttlSecondsAfterFinished 1 Tag, damit Fehler-Pods nicht stehen bleiben.

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

83 lines
3.4 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) {
return Object.assign(new Error(message), { code: "EENVELOPE", 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 });
}
});
});