fix(worker): endgueltig abgelehnte Mails nicht wiederholen, Adressen nicht loggen
Docker-Images bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Docker-Images bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
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]>
This commit is contained in:
1 parent
f2b548cb98
commit
c3fdfbdc77
3 files changed
+108
-4
No files matched your search
@@ -10,8 +10,13 @@ spec:
|
|||||||
concurrencyPolicy: Forbid
|
concurrencyPolicy: Forbid
|
||||||
jobTemplate:
|
jobTemplate:
|
||||||
spec:
|
spec:
|
||||||
backoffLimit: 1
|
# Kein Sofort-Neustart: der naechste Lauf kommt nach 10 Minuten ohnehin,
|
||||||
|
# ein zweiter Pod je Fehlschlag verdoppelt nur Fehler-Pods und Versuche.
|
||||||
|
backoffLimit: 0
|
||||||
activeDeadlineSeconds: 900
|
activeDeadlineSeconds: 900
|
||||||
|
# Beendete Jobs samt Pods nach einem Tag entfernen; fehlgeschlagene
|
||||||
|
# blieben sonst bis zum naechsten Fehlschlag stehen (failedJobsHistoryLimit 3).
|
||||||
|
ttlSecondsAfterFinished: 86400
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
+20
-3
@@ -85,6 +85,21 @@ async function deliver(message: { id: string; toAddress: string; subject: string
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SMTP-Fehlermeldungen nennen den Empfaenger; ins Log und in lastError kommt er nicht. */
|
||||||
|
export function redactAddresses(text: string): string {
|
||||||
|
return text.replace(/[^\s<>"',;:()]+@[^\s<>"',;:()]+/g, "[Adresse]");
|
||||||
|
}
|
||||||
|
|
||||||
/** Versendet offene Mails. Fehler werden gezaehlt, nie mit Adresse geloggt. */
|
/** Versendet offene Mails. Fehler werden gezaehlt, nie mit Adresse geloggt. */
|
||||||
export async function flushEmails(db: Db, params: { bookingId?: string; limit?: number } = {}): Promise<{ sent: number; failed: number }> {
|
export async function flushEmails(db: Db, params: { bookingId?: string; limit?: number } = {}): Promise<{ sent: number; failed: number }> {
|
||||||
const pending = await db.emailMessage.findMany({
|
const pending = await db.emailMessage.findMany({
|
||||||
@@ -111,15 +126,17 @@ export async function flushEmails(db: Db, params: { bookingId?: string; limit?:
|
|||||||
sent++;
|
sent++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed++;
|
failed++;
|
||||||
const reason = error instanceof Error ? error.message.slice(0, 300) : "unbekannter Fehler";
|
const permanent = isPermanentMailError(error);
|
||||||
|
const reason = redactAddresses(error instanceof Error ? error.message : "unbekannter Fehler").slice(0, 300);
|
||||||
await db.emailMessage.update({
|
await db.emailMessage.update({
|
||||||
where: { id: message.id },
|
where: { id: message.id },
|
||||||
data: {
|
data: {
|
||||||
lastError: reason,
|
lastError: reason,
|
||||||
status: message.attempts + 1 >= MAX_EMAIL_ATTEMPTS ? "FAILED" : "PENDING",
|
status: permanent || message.attempts + 1 >= MAX_EMAIL_ATTEMPTS ? "FAILED" : "PENDING",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.error(`[mail] Versand fehlgeschlagen (Nachricht ${message.id}): ${reason}`);
|
const final = permanent ? ", endgueltig abgelehnt" : "";
|
||||||
|
console.error(`[mail] Versand fehlgeschlagen (Nachricht ${message.id}${final}): ${reason}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { sent, failed };
|
return { sent, failed };
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user