diff --git a/k8s/07-worker-cronjob.yaml b/k8s/07-worker-cronjob.yaml index b9aa2bd..1057979 100644 --- a/k8s/07-worker-cronjob.yaml +++ b/k8s/07-worker-cronjob.yaml @@ -10,8 +10,13 @@ spec: concurrencyPolicy: Forbid jobTemplate: 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 + # Beendete Jobs samt Pods nach einem Tag entfernen; fehlgeschlagene + # blieben sonst bis zum naechsten Fehlschlag stehen (failedJobsHistoryLimit 3). + ttlSecondsAfterFinished: 86400 template: metadata: labels: diff --git a/src/lib/email.ts b/src/lib/email.ts index 251b094..cc8268d 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -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. */ export async function flushEmails(db: Db, params: { bookingId?: string; limit?: number } = {}): Promise<{ sent: number; failed: number }> { const pending = await db.emailMessage.findMany({ @@ -111,15 +126,17 @@ export async function flushEmails(db: Db, params: { bookingId?: string; limit?: sent++; } catch (error) { 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({ where: { id: message.id }, data: { 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 }; diff --git a/tests/email-outbox.integration.test.ts b/tests/email-outbox.integration.test.ts new file mode 100644 index 0000000..e99c6d0 --- /dev/null +++ b/tests/email-outbox.integration.test.ts @@ -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(); + const createTransport = (options: Record) => + 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 = "test@test.de") { + await enqueueEmail(db, { dedupeKey: `test:${to}`, to, subject: "Buchung bestätigt", text: "Hallo", html: "

Hallo

" }); + 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 : 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 : 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("test@test.de"); + expect((await db.emailMessage.findFirstOrThrow()).lastError).not.toContain("test@test.de"); + }); + + test("vorübergehende Fehler (4xx, Verbindung) bleiben PENDING und werden wiederholt", async () => { + const greylisted = await queueMail("a@example.org"); + const offline = await queueMail("b@example.org"); + 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 }); + } + }); +});