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 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-10 21:58:00 +02:00
parent 8697fee2e8
commit df3c4fafa3

View File

@ -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<connId, boolean>
const coalescePending = new Map(); // Map<connId, boolean>
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.