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:
parent
8697fee2e8
commit
df3c4fafa3
@ -70,6 +70,18 @@ const IDLE_NOOP_INTERVAL_MS = 30 * 1000; // 30 s
|
|||||||
// dem IMAP-Connect refreshen. Verhindert Mid-Session-Expiry.
|
// dem IMAP-Connect refreshen. Verhindert Mid-Session-Expiry.
|
||||||
const TOKEN_EXPIRY_THRESHOLD_MS = 5 * 60 * 1000; // 5 min
|
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
|
// Bei AUTHENTICATIONFAILED: max. 3 Refresh-Versuche bevor Connection als
|
||||||
// auth_broken markiert und der Daemon sie aufgibt.
|
// auth_broken markiert und der Daemon sie aufgibt.
|
||||||
const MAX_AUTH_RETRIES = 3;
|
const MAX_AUTH_RETRIES = 3;
|
||||||
@ -498,14 +510,33 @@ let shuttingDown = false;
|
|||||||
const scanInFlight = new Map(); // Map<connId, boolean>
|
const scanInFlight = new Map(); // Map<connId, boolean>
|
||||||
const coalescePending = 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 connId = conn.id;
|
||||||
|
const forceFullSweep = opts.forceFullSweep === true;
|
||||||
|
|
||||||
if (scanInFlight.get(connId)) {
|
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)) {
|
if (!coalescePending.get(connId)) {
|
||||||
log(conn.email, "scan-trigger coalesced (in-flight)");
|
log(conn.email, `scan-trigger coalesced (in-flight)${forceFullSweep ? " [force-full]" : ""}`);
|
||||||
coalescePending.set(connId, true);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@ -513,21 +544,24 @@ async function triggerScan(conn) {
|
|||||||
scanInFlight.set(connId, true);
|
scanInFlight.set(connId, true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const body = { userId: conn.userId };
|
||||||
|
if (forceFullSweep) body.forceFullSweep = true;
|
||||||
|
|
||||||
const res = await fetch(`${BACKEND_URL}/api/mail/scan-internal`, {
|
const res = await fetch(`${BACKEND_URL}/api/mail/scan-internal`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"x-admin-secret": ADMIN_SECRET,
|
"x-admin-secret": ADMIN_SECRET,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ userId: conn.userId }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
log(conn.email, `scan-trigger HTTP ${res.status}`);
|
log(conn.email, `scan-trigger HTTP ${res.status}${forceFullSweep ? " [force-full]" : ""}`);
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
log(
|
log(
|
||||||
conn.email,
|
conn.email,
|
||||||
`scan-triggered → scanned=${data.scanned ?? "?"} blocked=${data.blocked ?? "?"}`,
|
`scan-triggered → scanned=${data.scanned ?? "?"} blocked=${data.blocked ?? "?"}${forceFullSweep ? " [force-full]" : ""}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -535,11 +569,14 @@ async function triggerScan(conn) {
|
|||||||
} finally {
|
} finally {
|
||||||
scanInFlight.set(connId, false);
|
scanInFlight.set(connId, false);
|
||||||
|
|
||||||
// Coalesced Trigger nachholen: einmalig, fire-and-forget
|
// Coalesced Trigger nachholen: einmalig, fire-and-forget.
|
||||||
if (coalescePending.get(connId)) {
|
// forceFullSweep-Flag aus dem gemerkten Zustand übernehmen.
|
||||||
|
const pendingState = coalescePending.get(connId);
|
||||||
|
if (pendingState) {
|
||||||
coalescePending.set(connId, false);
|
coalescePending.set(connId, false);
|
||||||
log(conn.email, "scan-trigger coalesced fire (post-flight)");
|
const pendingForce = pendingState === "full";
|
||||||
triggerScan(conn).catch(() => {});
|
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 attempt = 0;
|
||||||
let authRetries = 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) {
|
while (!shuttingDown) {
|
||||||
// Credentials holen (proaktiver Token-Refresh wenn nötig)
|
// Credentials holen (proaktiver Token-Refresh wenn nötig)
|
||||||
let creds;
|
let creds;
|
||||||
@ -621,6 +669,7 @@ async function runSession(conn) {
|
|||||||
try {
|
try {
|
||||||
await imap.connect();
|
await imap.connect();
|
||||||
log(conn.email, `connected (${conn.imapHost}:${conn.imapPort}, auth=${creds.type})`);
|
log(conn.email, `connected (${conn.imapHost}:${conn.imapPort}, auth=${creds.type})`);
|
||||||
|
lastConnectedAt = Date.now();
|
||||||
attempt = 0; // Reset nach erfolgreicher Verbindung
|
attempt = 0; // Reset nach erfolgreicher Verbindung
|
||||||
authRetries = 0; // Auth-Retry-Counter ebenfalls reset
|
authRetries = 0; // Auth-Retry-Counter ebenfalls reset
|
||||||
// Initial-Heartbeat: sofort nach erfolgreichem Connect schreiben damit
|
// 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.
|
// Initial-Sweep: einmalig nach erfolgreichem Connect scan-internal anstoßen.
|
||||||
// Damit werden bestehende Gambling-Mails in allen Folders sofort gelöscht,
|
// 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).
|
// 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.
|
// scan-internal baut eine eigene IMAP-Connection auf → kein Lock-Konflikt.
|
||||||
// Consent-Gate sitzt in scan-internal selbst → kein doppeltes Check hier.
|
// Consent-Gate sitzt in scan-internal selbst → kein doppeltes Check hier.
|
||||||
// fire-and-forget: Fehler werden intern geloggt, Session läuft weiter.
|
// 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
|
// Outlook/XOAUTH2 hat den Edge-Case dass getMailboxLock lautlos hängt
|
||||||
// wenn der Server in einen ungültigen Zustand kommt — die Session
|
// 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);
|
logError(conn.email, "connection error", err);
|
||||||
try { imap.close(); } catch { /* ignore */ }
|
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) ──────────────────
|
// ── AUTHENTICATIONFAILED-Recovery (OAuth-spezifisch) ──────────────────
|
||||||
// Cold-Path: Token zwischen zwei IDLE-Renewals abgelaufen (>1h Session).
|
// Cold-Path: Token zwischen zwei IDLE-Renewals abgelaufen (>1h Session).
|
||||||
// Oder: proaktiver Refresh ist fehlgeschlagen und wir landen hier.
|
// Oder: proaktiver Refresh ist fehlgeschlagen und wir landen hier.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user