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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 11:20:34 +02:00
1 parent e6fa383934
commit 34ccbc3b9a
2 files changed
+48 -7

No files matched your search

+12 -5
View File
@@ -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 * Endgueltig ist nur eine Ablehnung des Empfaengers (nodemailer EENVELOPE, RCPT TO):
* Mailserver): erneutes Senden aendert nichts und liesse jeden Worker-Lauf * 550/551/553/556 oder 5xx mit Status 5.1.x (Adresse/Domain falsch). Alles andere
* scheitern. 4xx und Verbindungsfehler bleiben wiederholbar. * (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 { export function isPermanentMailError(error: unknown): boolean {
const code = typeof error === "object" && error !== null ? (error as { responseCode?: unknown }).responseCode : undefined; if (typeof error !== "object" || error === null) return false;
return typeof code === "number" && code >= 500 && code < 600; 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. */ /** SMTP-Fehlermeldungen nennen den Empfaenger; ins Log und in lastError kommt er nicht. */
+36 -2
View File
@@ -13,8 +13,8 @@ vi.mock("nodemailer", async (importOriginal) => {
return { ...actual, default: { ...actual.default, createTransport } }; return { ...actual, default: { ...actual.default, createTransport } };
}); });
function smtpError(message: string, responseCode?: number) { function smtpError(message: string, responseCode?: number, code = "EENVELOPE") {
return Object.assign(new Error(message), { code: "EENVELOPE", responseCode }); return Object.assign(new Error(message), { code, responseCode });
} }
async function queueMail(to = "[email protected]") { async function queueMail(to = "[email protected]") {
@@ -79,4 +79,38 @@ describe("Postausgang: Fehler beim SMTP-Versand", () => {
expect(await db.emailMessage.findUniqueOrThrow({ where: { id } })).toMatchObject({ status: "PENDING", attempts: 1 }); 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");
}
});
}); });