From df3c4fafa3f27cb26b44bcecc42cc923f9acb24c Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Wed, 10 Jun 2026 21:58:00 +0200 Subject: [PATCH] fix(mail): force full-sweep on idle-daemon recovery (cold-start + downtime>5min) Eine global-blocklistete Casino-Mail (mpmgame.com) rutschte in die Inbox, obwohl die Absender-Domain Layer-2-Hard-Block ist. Ursache: kein Erkennungs- Fehler, sondern ein Scan-Gap. Der idle-daemon crash-loopte heute (Port-3016- Race, ~49 Restarts) und machte nach jedem Reconnect/Cold-Start nur einen INKREMENTELLEN Scan (lastUid). Mails, die waehrend einer Downtime ankamen, fielen damit dauerhaft durch das last-seen-UID-Raster. Fix in backend/imap-idle/index.mjs: - Cold-Start (Prozess-/pm2-Restart): immer forceFullSweep - Reconnect nach Error mit Downtime > 5min (RECOVERY_SWEEP_THRESHOLD_MS): forceFullSweep beim naechsten erfolgreichen Connect - Routinemaessiger IDLE-Renew (10min Close-Reopen): bleibt inkrementell -> kein teurer Dauer-Sweep im Normalbetrieb - triggerScan(conn, {forceFullSweep}) + Coalesce-State "full"/"incremental" (ein full-Trigger wird nicht von einem spaeteren inkrementellen degradiert) scan-internal.post.ts wertet body.forceFullSweep bereits korrekt aus. Im Zweifel Full: eine global-blocklistete Domain darf nie durchrutschen. Co-Authored-By: Claude Opus 4.8 --- backend/imap-idle/index.mjs | 109 ++++++++++++++++++++++++++++++++---- 1 file changed, 97 insertions(+), 12 deletions(-) diff --git a/backend/imap-idle/index.mjs b/backend/imap-idle/index.mjs index 6effaa9..478e49e 100644 --- a/backend/imap-idle/index.mjs +++ b/backend/imap-idle/index.mjs @@ -70,6 +70,18 @@ const IDLE_NOOP_INTERVAL_MS = 30 * 1000; // 30 s // dem IMAP-Connect refreshen. Verhindert Mid-Session-Expiry. const TOKEN_EXPIRY_THRESHOLD_MS = 5 * 60 * 1000; // 5 min +// Recovery-Sweep-Schwelle: wenn eine Session länger als diese Zeitspanne +// unterbrochen war (Downtime durch ECONNRESET, Prozess-Crash, pm2-Restart), +// wird beim nächsten erfolgreichen Connect ein forceFullSweep ausgelöst. +// Rationale: Eine Downtime > 5min bedeutet dass Mails angekommen sein können +// ohne dass der IDLE-Daemon sie via exists-Event sah. Ein Full-Sweep stellt +// sicher dass global-blocklistete Domains (z.B. mpmgame.com) auch dann +// abgefangen werden wenn sie während der Downtime ankamen. +// Cold-Start (Prozess-Neustart): immer forceFullSweep, unabhängig dieser Schwelle. +// Routinemäßiger IDLE-Renew (10min Close-Reopen innerhalb aktiver Session): +// kein forceFullSweep — Session war nie wirklich down. +const RECOVERY_SWEEP_THRESHOLD_MS = 5 * 60 * 1000; // 5 min + // Bei AUTHENTICATIONFAILED: max. 3 Refresh-Versuche bevor Connection als // auth_broken markiert und der Daemon sie aufgibt. const MAX_AUTH_RETRIES = 3; @@ -498,14 +510,33 @@ let shuttingDown = false; const scanInFlight = new Map(); // Map const coalescePending = new Map(); // Map -async function triggerScan(conn) { +/** + * Feuert POST /api/mail/scan-internal für eine Connection. + * + * opts.forceFullSweep: wenn true, wird scan-internal angewiesen einen + * Full-Sweep aller Ordner durchzuführen (ignoriert lastUid / lastFullSweepAt- + * Schwelle). Wird bei Recovery nach Downtime gesetzt (Cold-Start oder + * Reconnect nach > RECOVERY_SWEEP_THRESHOLD_MS). + * + * Beim Coalesce-Fall (Scan läuft, neuer Trigger kommt rein): wenn irgendein + * Trigger mit forceFullSweep=true kam, wird das Flag für den nachgeholten + * Scan beibehalten — ein inkrementeller Trigger darf einen pending-Full-Sweep + * nicht degradieren. + */ +async function triggerScan(conn, opts = {}) { const connId = conn.id; + const forceFullSweep = opts.forceFullSweep === true; if (scanInFlight.get(connId)) { - // Scan läuft bereits — coalescer merkt sich den Wunsch, stapelt nicht + // Scan läuft bereits — coalescer merkt sich den Wunsch, stapelt nicht. + // forceFullSweep wird gemerkt: einmal gesetzt bleibt es gesetzt bis der + // nachgeholte Scan läuft (downgrade von full→incremental verboten). if (!coalescePending.get(connId)) { - log(conn.email, "scan-trigger coalesced (in-flight)"); - coalescePending.set(connId, true); + log(conn.email, `scan-trigger coalesced (in-flight)${forceFullSweep ? " [force-full]" : ""}`); + coalescePending.set(connId, forceFullSweep ? "full" : "incremental"); + } else if (forceFullSweep && coalescePending.get(connId) !== "full") { + // Upgrade: incremental→full wenn neuer Trigger full verlangt + coalescePending.set(connId, "full"); } return; } @@ -513,21 +544,24 @@ async function triggerScan(conn) { scanInFlight.set(connId, true); try { + const body = { userId: conn.userId }; + if (forceFullSweep) body.forceFullSweep = true; + const res = await fetch(`${BACKEND_URL}/api/mail/scan-internal`, { method: "POST", headers: { "Content-Type": "application/json", "x-admin-secret": ADMIN_SECRET, }, - body: JSON.stringify({ userId: conn.userId }), + body: JSON.stringify(body), }); if (!res.ok) { - log(conn.email, `scan-trigger HTTP ${res.status}`); + log(conn.email, `scan-trigger HTTP ${res.status}${forceFullSweep ? " [force-full]" : ""}`); } else { const data = await res.json().catch(() => ({})); log( conn.email, - `scan-triggered → scanned=${data.scanned ?? "?"} blocked=${data.blocked ?? "?"}`, + `scan-triggered → scanned=${data.scanned ?? "?"} blocked=${data.blocked ?? "?"}${forceFullSweep ? " [force-full]" : ""}`, ); } } catch (err) { @@ -535,11 +569,14 @@ async function triggerScan(conn) { } finally { scanInFlight.set(connId, false); - // Coalesced Trigger nachholen: einmalig, fire-and-forget - if (coalescePending.get(connId)) { + // Coalesced Trigger nachholen: einmalig, fire-and-forget. + // forceFullSweep-Flag aus dem gemerkten Zustand übernehmen. + const pendingState = coalescePending.get(connId); + if (pendingState) { coalescePending.set(connId, false); - log(conn.email, "scan-trigger coalesced fire (post-flight)"); - triggerScan(conn).catch(() => {}); + const pendingForce = pendingState === "full"; + log(conn.email, `scan-trigger coalesced fire (post-flight)${pendingForce ? " [force-full]" : ""}`); + triggerScan(conn, { forceFullSweep: pendingForce }).catch(() => {}); } } } @@ -565,6 +602,17 @@ async function runSession(conn) { let attempt = 0; let authRetries = 0; + // Recovery-Sweep-Tracking: + // isColdStart=true beim ersten Loop-Durchlauf (Prozess-Start oder Session-Erststart). + // isRecovery wird im catch-Block gesetzt wenn die Session länger als + // RECOVERY_SWEEP_THRESHOLD_MS unterbrochen war. + // Beide Flags lösen beim nächsten erfolgreichen Connect forceFullSweep aus. + // lastConnectedAt: Zeitpunkt des letzten erfolgreichen imap.connect() — + // damit wissen wir im catch-Block wie lange die Downtime dauerte. + let isColdStart = true; + let isRecovery = false; + let lastConnectedAt = 0; // ms-Timestamp, 0 = noch nie verbunden gewesen + while (!shuttingDown) { // Credentials holen (proaktiver Token-Refresh wenn nötig) let creds; @@ -621,6 +669,7 @@ async function runSession(conn) { try { await imap.connect(); log(conn.email, `connected (${conn.imapHost}:${conn.imapPort}, auth=${creds.type})`); + lastConnectedAt = Date.now(); attempt = 0; // Reset nach erfolgreicher Verbindung authRetries = 0; // Auth-Retry-Counter ebenfalls reset // Initial-Heartbeat: sofort nach erfolgreichem Connect schreiben damit @@ -639,10 +688,29 @@ async function runSession(conn) { // Initial-Sweep: einmalig nach erfolgreichem Connect scan-internal anstoßen. // Damit werden bestehende Gambling-Mails in allen Folders sofort gelöscht, // statt auf das erste exists-Event zu warten (das nur bei neuen Mails kommt). + // + // Recovery-Sweep-Logik: + // isColdStart=true → Prozess frisch gestartet (pm2-Restart, Deploy) → forceFullSweep + // isRecovery=true → Reconnect nach Downtime > RECOVERY_SWEEP_THRESHOLD_MS → forceFullSweep + // sonst → routinemäßiger IDLE-Renew oder kurzer Reconnect → inkrementell + // + // Cold-Start + Recovery sind die einzigen Fälle wo Mails während Downtime + // ankamen ohne von einem exists-Event erfasst zu werden. Full-Sweep stellt sicher + // dass global-blocklistete Domains (Layer-2-Hard-Block) nie durchrutschen. + // // scan-internal baut eine eigene IMAP-Connection auf → kein Lock-Konflikt. // Consent-Gate sitzt in scan-internal selbst → kein doppeltes Check hier. // fire-and-forget: Fehler werden intern geloggt, Session läuft weiter. - triggerScan(conn).catch(() => {}); + const sweepReason = isColdStart ? "cold-start" : isRecovery ? "recovery" : null; + const needsSweepForce = isColdStart || isRecovery; + if (sweepReason) { + log(conn.email, `initial sweep [force-full, reason=${sweepReason}]`); + } + triggerScan(conn, { forceFullSweep: needsSweepForce }).catch(() => {}); + + // Flags für diesen Connect-Durchlauf konsumieren + isColdStart = false; + isRecovery = false; // Outlook/XOAUTH2 hat den Edge-Case dass getMailboxLock lautlos hängt // wenn der Server in einen ungültigen Zustand kommt — die Session @@ -732,6 +800,23 @@ async function runSession(conn) { logError(conn.email, "connection error", err); try { imap.close(); } catch { /* ignore */ } + // ── Recovery-Sweep-Gating ───────────────────────────────────────────── + // Jeder Fehler hier bedeutet: die Session war unterbrochen. Wenn sie + // lange genug unterbrochen war (> RECOVERY_SWEEP_THRESHOLD_MS), setzen + // wir isRecovery=true damit der nächste erfolgreiche Connect einen + // forceFullSweep auslöst. + // lastConnectedAt=0: Session hat noch nie erfolgreich verbunden (z.B. + // Backend war beim Cold-Start noch nicht ready) → immer forceFullSweep + // (isColdStart deckt diesen Fall, aber doppelte Absicherung schadet nicht). + const downtimeMs = lastConnectedAt > 0 ? Date.now() - lastConnectedAt : Infinity; + if (downtimeMs > RECOVERY_SWEEP_THRESHOLD_MS) { + isRecovery = true; + log( + conn.email, + `downtime ${Math.round(downtimeMs / 1000)}s > threshold — next connect will force full sweep`, + ); + } + // ── AUTHENTICATIONFAILED-Recovery (OAuth-spezifisch) ────────────────── // Cold-Path: Token zwischen zwei IDLE-Renewals abgelaufen (>1h Session). // Oder: proaktiver Refresh ist fehlgeschlagen und wir landen hier.