diff --git a/src/lib/email.ts b/src/lib/email.ts index cc8268d..e347177 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -85,14 +85,21 @@ async function deliver(message: { id: string; toAddress: string; subject: string }); } +const RECIPIENT_REJECTION_CODES = new Set([550, 551, 553, 556]); + /** - * SMTP-Antworten 5xx sind endgueltig (z. B. Empfaenger abgelehnt, Domain ohne - * Mailserver): erneutes Senden aendert nichts und liesse jeden Worker-Lauf - * scheitern. 4xx und Verbindungsfehler bleiben wiederholbar. + * Endgueltig ist nur eine Ablehnung des Empfaengers (nodemailer EENVELOPE, RCPT TO): + * 550/551/553/556 oder 5xx mit Status 5.1.x (Adresse/Domain falsch). Alles andere + * (Anmeldung EAUTH, Richtlinie/Blocklist EMESSAGE, 552, 4xx, Verbindung) liegt am + * Server oder Absender; die Mail bleibt PENDING und wird bis MAX_EMAIL_ATTEMPTS + * wiederholt, damit ein kaputtes SMTP-Secret keine Mails endgueltig verwirft. */ export function isPermanentMailError(error: unknown): boolean { - const code = typeof error === "object" && error !== null ? (error as { responseCode?: unknown }).responseCode : undefined; - return typeof code === "number" && code >= 500 && code < 600; + if (typeof error !== "object" || error === null) return false; + const { code, responseCode, response } = error as { code?: unknown; responseCode?: unknown; response?: unknown }; + if (code !== "EENVELOPE" || typeof responseCode !== "number") return false; + if (RECIPIENT_REJECTION_CODES.has(responseCode)) return true; + return responseCode >= 500 && responseCode < 600 && typeof response === "string" && /(^|\s)5\.1\.\d+(\s|$)/.test(response); } /** SMTP-Fehlermeldungen nennen den Empfaenger; ins Log und in lastError kommt er nicht. */ diff --git a/tests/email-outbox.integration.test.ts b/tests/email-outbox.integration.test.ts index e99c6d0..83859e5 100644 --- a/tests/email-outbox.integration.test.ts +++ b/tests/email-outbox.integration.test.ts @@ -13,8 +13,8 @@ vi.mock("nodemailer", async (importOriginal) => { return { ...actual, default: { ...actual.default, createTransport } }; }); -function smtpError(message: string, responseCode?: number) { - return Object.assign(new Error(message), { code: "EENVELOPE", responseCode }); +function smtpError(message: string, responseCode?: number, code = "EENVELOPE") { + return Object.assign(new Error(message), { code, responseCode }); } async function queueMail(to = "test@test.de") { @@ -79,4 +79,38 @@ describe("Postausgang: Fehler beim SMTP-Versand", () => { 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"); + } + }); });