/** * rebreak-imap-idle — IMAP IDLE Daemon * * Hält pro aktivem MailConnection-Eintrag eine persistente IMAP-IDLE-Session. * Wenn der Server "EXISTS" meldet (neue Mail), feuert der Daemon sofort * POST /api/mail/scan-internal gegen das lokale Backend — ohne 30min-Warte. * * Auth-Methoden: * app_password — Gmail, iCloud, GMX, etc. (App-Password / IMAP-Passwort) * oauth2_microsoft — Outlook / Hotmail / O365 via XOAUTH2 (ImapFlow-nativ) * * Env-Vars (via Infisical-Wrapper): * DATABASE_URL — Postgres-Connection-String * ADMIN_SECRET — Header-Secret für /api/mail/scan-internal * ENCRYPTION_KEY — AES-256 Key (gleicher wie im Backend, 32+ Zeichen) * BACKEND_URL — z.B. http://127.0.0.1:3016 (default: 3016) * MS_OAUTH_CLIENT_ID — Azure App Registration Client ID * NODE_ENV — production / staging * * Starten: * node index.mjs * * Prozess-Signale: * SIGTERM / SIGINT → graceful shutdown (alle IMAP-Sessions schließen) */ import { ImapFlow } from "imapflow"; import pg from "pg"; import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; // ─── Config ────────────────────────────────────────────────────────────────── const BACKEND_URL = process.env.BACKEND_URL || (process.env.NODE_ENV === "production" ? "http://127.0.0.1:3015" : "http://127.0.0.1:3016"); const ADMIN_SECRET = process.env.NUXT_ADMIN_SECRET || process.env.ADMIN_SECRET || ""; const MS_OAUTH_CLIENT_ID = process.env.MS_OAUTH_CLIENT_ID || ""; const DB_REFRESH_INTERVAL_MS = 5 * 60 * 1000; // 5 min — neue Connections entdecken // IDLE_RENEW von 25min → 10min: GMX dropped IDLE-connections silent vor 25min // → exists-events kommen nie an + ImapFlow.idle() hängt ohne reject. 10min // deckt alle bekannten Provider-Timeouts ab: // GMX ~10-15min (aggressivster Provider) // Gmail ~29min // iCloud ~29min // outlook.office365.com ~29min (Microsoft dokumentiert 29min IDLE-Timeout) // Trade-off: alle 10min full reconnect-cycle. Vertretbar. const IDLE_RENEW_INTERVAL_MS = 10 * 60 * 1000; // 10 min // NOOP-heartbeat alle 2min während IDLE: detect silent-drops (GMX-pattern). // Wenn NOOP fehlschlägt → close → loop iteriert → reconnect. const IDLE_NOOP_INTERVAL_MS = 2 * 60 * 1000; // 2 min // Token-Refresh-Schwelle: wenn Access-Token in weniger als 5min abläuft, vor // dem IMAP-Connect refreshen. Verhindert Mid-Session-Expiry. const TOKEN_EXPIRY_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; const RECONNECT_DELAYS_MS = [1000, 5000, 30_000]; // exponential backoff const RECONNECT_LOOP_DELAY_MS = 60 * 1000; // ─── DB-Pool (direktes pg, kein Prisma — Daemon ist kein Nitro-Kontext) ───── const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); async function updateConnectionError(connId, errorText) { await pool.query( `UPDATE rebreak.mail_connections SET last_connect_error = $1, last_connect_error_at = NOW() WHERE id = $2`, [errorText, connId], ); } async function clearConnectionError(connId) { await pool.query( `UPDATE rebreak.mail_connections SET last_connect_error = NULL, last_connect_error_at = NULL WHERE id = $1`, [connId], ); } async function updateIdleHeartbeat(connId) { await pool.query( `UPDATE rebreak.mail_connections SET last_idle_heartbeat_at = NOW() WHERE id = $1`, [connId], ); } /** * Lädt alle aktiven MailConnections inkl. OAuth-Felder und consent_at. * Consent-Gate: connections ohne consent_at werden geladen aber vom * IDLE-Loop nicht gescannt (nur gehalten — kein Delete). */ async function loadActiveConnections() { const { rows } = await pool.query( `SELECT id, user_id AS "userId", email, imap_host AS "imapHost", imap_port AS "imapPort", password_encrypted AS "passwordEncrypted", reject_unauthorized AS "rejectUnauthorized", use_starttls AS "useStarttls", auth_method AS "authMethod", oauth_access_token AS "oauthAccessToken", oauth_refresh_token AS "oauthRefreshToken", oauth_token_expiry AS "oauthTokenExpiry", consent_at AS "consentAt" FROM rebreak.mail_connections WHERE is_active = true`, ); return rows; } // ─── Crypto (analog zu server/utils/crypto.ts) ─────────────────────────────── const AES_ALGO = "aes-256-gcm"; const KEY_LENGTH = 32; function getKey() { const raw = process.env.NUXT_ENCRYPTION_KEY || process.env.ENCRYPTION_KEY || ""; if (!raw || raw.length < KEY_LENGTH) { return Buffer.alloc( KEY_LENGTH, raw.padEnd(KEY_LENGTH, "0").slice(0, KEY_LENGTH), ); } return Buffer.from(raw.slice(0, KEY_LENGTH), "utf8"); } function decrypt(stored) { const parts = stored.split(":"); if (parts.length !== 3) throw new Error("Invalid encrypted format"); const [ivHex, tagHex, dataHex] = parts; const decipher = createDecipheriv( AES_ALGO, getKey(), Buffer.from(ivHex, "hex"), ); decipher.setAuthTag(Buffer.from(tagHex, "hex")); return decipher.update(Buffer.from(dataHex, "hex")) + decipher.final("utf8"); } function encrypt(plaintext) { const key = getKey(); const iv = randomBytes(12); // 96-bit IV für AES-256-GCM const cipher = createCipheriv(AES_ALGO, key, iv); const encrypted = Buffer.concat([ cipher.update(plaintext, "utf8"), cipher.final(), ]); const tag = cipher.getAuthTag(); return `${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`; } // ─── Microsoft Token Refresh (inline, kein Prisma-Kontext) ─────────────────── const MS_TOKEN_ENDPOINT = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; // MUSS identisch zu backend/server/utils/ms-oauth.ts MS_OAUTH_SCOPES sein. // Microsoft V2.0 erlaubt im /token-Exchange nur Scopes EINES Resource-Servers // — `User.Read` (graph.microsoft.com) mit `IMAP.AccessAsUser.All` // (outlook.office.com) wirft AADSTS70011. `email` ist ein OIDC-Standard-Scope // und damit cross-resource-kompatibel; liefert den email-Claim ins id_token. const MS_OAUTH_SCOPES = [ "https://outlook.office.com/IMAP.AccessAsUser.All", "offline_access", "openid", "email", ].join(" "); /** * Refresht Microsoft Access+Refresh-Token direkt via HTTP. * Race-Condition-Strategie (Optimistic Concurrency): * 1. Lese aktuelle oauth_token_expiry aus DB als "Fingerprint" des aktuellen Stands. * 2. POST an MS Token-Endpoint. * 3. UPDATE ... WHERE oauth_token_expiry = * → affected_rows = 0: anderer Prozess hat parallel refresht. * Dann: frischen token lesen + zurückgeben (kein doppelter Refresh!). * Doppelter Refresh würde MS-Refresh-Token-Rotation verletzen → * AADSTS70043 beim nächsten Versuch. * 4. Gibt plaintext Access-Token zurück, sofort für IMAP nutzbar. * * Wirft wenn: * - Connection nicht gefunden / kein Refresh-Token * - MS Token-Endpoint antwortet mit Fehler (revoked/expired Refresh-Token) */ async function refreshAndSaveTokensDaemon(connectionId, clientId) { // Step 1: Aktuellen Token-Stand lesen const { rows } = await pool.query( `SELECT oauth_refresh_token, oauth_access_token, oauth_token_expiry FROM rebreak.mail_connections WHERE id = $1 AND auth_method = 'oauth2_microsoft'`, [connectionId], ); const conn = rows[0]; if (!conn?.oauth_refresh_token) { throw new Error( `Connection ${connectionId} has no oauth_refresh_token — cannot refresh`, ); } const currentExpiry = conn.oauth_token_expiry; const decryptedRefreshToken = decrypt(conn.oauth_refresh_token); // Step 2: MS Token-Endpoint const body = new URLSearchParams({ grant_type: "refresh_token", client_id: clientId, refresh_token: decryptedRefreshToken, scope: MS_OAUTH_SCOPES, }); const res = await fetch(MS_TOKEN_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), }); if (!res.ok) { const errText = await res.text().catch(() => "unknown"); throw new Error(`MS token refresh failed (${res.status}): ${errText}`); } const data = await res.json(); if (!data.access_token) { throw new Error("MS refresh response missing access_token"); } const newExpiry = new Date(Date.now() + data.expires_in * 1000); // MS rotiert Refresh-Tokens — wenn kein neuer kommt, den alten behalten const newRefreshToken = data.refresh_token ?? decryptedRefreshToken; const encryptedAccess = encrypt(data.access_token); const encryptedRefresh = encrypt(newRefreshToken); // Step 3: Optimistic update — nur wenn oauth_token_expiry noch gleich wie gelesen // IS NOT DISTINCT FROM deckt NULL=NULL korrekt ab (vs. = das NULL!=NULL behandelt) const result = await pool.query( `UPDATE rebreak.mail_connections SET oauth_access_token = $1, oauth_refresh_token = $2, oauth_token_expiry = $3 WHERE id = $4 AND oauth_token_expiry IS NOT DISTINCT FROM $5`, [encryptedAccess, encryptedRefresh, newExpiry, connectionId, currentExpiry], ); if (result.rowCount === 0) { // Step 4: Anderer Prozess hat parallel refresht — lese deren frischen Token const { rows: fresh } = await pool.query( `SELECT oauth_access_token FROM rebreak.mail_connections WHERE id = $1`, [connectionId], ); if (!fresh[0]?.oauth_access_token) { throw new Error( `Concurrent refresh for ${connectionId} detected but no token found`, ); } return decrypt(fresh[0].oauth_access_token); } return data.access_token; } /** * Markiert eine Connection als "auth_broken" — kein weiterer Retry im Daemon. * User sieht den Error im Frontend (last_connect_error = 'auth_revoked'). */ async function markConnectionAuthBroken(connectionId) { await pool.query( `UPDATE rebreak.mail_connections SET last_connect_error = 'auth_revoked', last_connect_error_at = NOW() WHERE id = $1`, [connectionId], ); } // ─── Credentials-Resolution ────────────────────────────────────────────────── /** * Gibt die IMAP-Credentials für eine Connection zurück. * auth_method-aware: * * app_password → { type: 'password', user, pass } * oauth2_microsoft → { type: 'xoauth2', user, accessToken } * * Für OAuth: wenn Token in <5min abläuft, wird vor Connect refresht. * Das ist der "proaktive" Refresh-Path (Hot-Path). * * Der "reaktive" Refresh-Path (Cold-Path) liegt in runSession(): * AUTHENTICATIONFAILED während laufender Session → refreshAndSaveTokensDaemon() * → neuer ImapFlow-Connect mit frischem Token. * * FORCE_REFRESH_FOR_TEST: wenn env IDLE_FORCE_TOKEN_REFRESH=1 gesetzt ist, * wird IMMER refresht unabhängig von Expiry. Nur für manuelle Tests. */ async function getCredentialsForConnection(conn) { if (conn.authMethod === "oauth2_microsoft") { const forceRefresh = process.env.IDLE_FORCE_TOKEN_REFRESH === "1"; const fiveMinFromNow = Date.now() + TOKEN_EXPIRY_THRESHOLD_MS; const isExpiring = !conn.oauthTokenExpiry || new Date(conn.oauthTokenExpiry).getTime() < fiveMinFromNow; if (forceRefresh || isExpiring) { const reason = forceRefresh ? "IDLE_FORCE_TOKEN_REFRESH=1" : "token expiring <5min"; // NIEMALS Token-Werte loggen — nur Fakt dass refresht wird console.log(`[idle/${conn.email}] proactive token refresh (${reason})`); const accessToken = await refreshAndSaveTokensDaemon(conn.id, MS_OAUTH_CLIENT_ID); return { type: "xoauth2", user: conn.email, accessToken }; } // Token noch gültig — direkt entschlüsseln if (!conn.oauthAccessToken) { throw new Error(`Connection ${conn.id} has no oauth_access_token`); } const accessToken = decrypt(conn.oauthAccessToken); return { type: "xoauth2", user: conn.email, accessToken }; } // Bestand: Gmail / iCloud / GMX / Custom-IMAP → App-Password if (!conn.passwordEncrypted) { throw new Error(`Connection ${conn.id} has no password_encrypted`); } const pass = decrypt(conn.passwordEncrypted); return { type: "password", user: conn.email, pass }; } // ─── Logging ───────────────────────────────────────────────────────────────── function log(email, msg) { // NIEMALS password/accessToken/credentials loggen. email ist safe. console.log(`[idle/${email}] ${msg}`); } function logError(email, msg, err) { // responseText = IMAP-Serverantwort (z.B. "NO [AUTHENTICATIONFAILED] Invalid credentials") // Credentials tauchen NICHT in err.responseText auf — ImapFlow legt sie nicht rein. const errMsg = err?.responseText || err?.message || String(err); console.error(`[idle/${email}] ${msg}: ${errMsg}`); } // ─── Auth-Fehler Detection ─────────────────────────────────────────────────── function isAuthError(err) { const text = (err?.responseText || err?.message || "").toUpperCase(); return ( text.includes("AUTHENTICATIONFAILED") || text.includes("AUTHENTICATE") || text.includes("INVALID CREDENTIALS") || text.includes("AUTHENTICATION FAILED") || // MS-spezifische Fehlercodes: AADSTS = Azure AD Token Service Error text.includes("AADSTS") ); } // ─── Session-Registry ──────────────────────────────────────────────────────── // Map const sessions = new Map(); let shuttingDown = false; // ─── Scan-Trigger ───────────────────────────────────────────────────────────── async function triggerScan(conn) { try { 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 }), }); if (!res.ok) { log(conn.email, `scan-trigger HTTP ${res.status}`); } else { const data = await res.json().catch(() => ({})); log( conn.email, `scan-triggered → scanned=${data.scanned ?? "?"} blocked=${data.blocked ?? "?"}`, ); } } catch (err) { logError(conn.email, "scan-trigger failed", err); } } // ─── IDLE-Session ───────────────────────────────────────────────────────────── /** * Startet eine einzelne IDLE-Session für eine MailConnection. * Reconnect-Loop läuft intern — diese Funktion returned nie (bis shutdown). * * Auth-Retry-Loop (OAuth-spezifisch): * Bei AUTHENTICATIONFAILED → Token refreshen → neu verbinden. * Max MAX_AUTH_RETRIES (3) mal. Dann: markConnectionAuthBroken + exit. * authRetries wird nach jedem erfolgreichen Connect resettet. * * Consent-Gate: * conn.consentAt === null → IDLE-Verbindung wird gehalten (keep-alive), * aber triggerScan() wird NICHT aufgerufen. exists-Events werden still ignoriert. * Sobald der User consent erteilt (DB-Refresh-Cycle nach max. 5min), * wird die Connection neu gestartet mit consentAt gesetzt. */ async function runSession(conn) { let attempt = 0; let authRetries = 0; while (!shuttingDown) { // Credentials holen (proaktiver Token-Refresh wenn nötig) let creds; try { creds = await getCredentialsForConnection(conn); } catch (err) { logError(conn.email, "credential resolution failed — session aborted", err); // Kein retry: kaputte Credentials oder Token-Refresh fehlgeschlagen // (z.B. Refresh-Token revoked). Als auth_broken markieren wenn OAuth. if (conn.authMethod === "oauth2_microsoft") { await markConnectionAuthBroken(conn.id).catch(() => {}); } else { await updateConnectionError(conn.id, err?.message || String(err)).catch(() => {}); } return; } const useImplicitTls = !conn.useStarttls; const imap = new ImapFlow({ host: conn.imapHost, port: conn.imapPort, secure: useImplicitTls, ...(conn.useStarttls ? { requireTLS: true } : {}), // ImapFlow 1.2.18 unterstützt XOAUTH2 nativ: // { user, accessToken } → AUTHENTICATE XOAUTH2 // { user, pass } → LOGIN oder PLAIN (je nach Server-Capability) auth: creds.type === "xoauth2" ? { user: creds.user, accessToken: creds.accessToken } : { user: creds.user, pass: creds.pass }, logger: false, tls: { rejectUnauthorized: conn.rejectUnauthorized ?? true }, // outlook.office365.com: disableCompression verhindert edge-cases bei // partial reads nach Reconnect. Gilt für oauth2_microsoft Connections. disableCompression: conn.imapHost.includes("office365"), }); // Referenz ablegen damit shutdown() darauf zugreifen kann const handle = sessions.get(conn.id); if (handle) handle.imap = imap; try { await imap.connect(); log(conn.email, `connected (${conn.imapHost}:${conn.imapPort}, auth=${creds.type})`); attempt = 0; // Reset nach erfolgreicher Verbindung authRetries = 0; // Auth-Retry-Counter ebenfalls reset // Initial-Heartbeat: sofort nach erfolgreichem Connect schreiben damit // das Frontend "aktiv" zeigt statt bis zum ersten NOOP-Cycle zu warten // (NOOP-Cycle = alle 2min → worst-case 2min+, gemessene delay 2-9min). // Gilt für alle auth-Methoden (app_password + oauth2_microsoft) und // auch für den Re-Connect nach AUTHENTICATIONFAILED-Recovery, da beide // Paths durch diesen Block laufen. // last_connect_error wird gleichzeitig geclearet: ein zuvor failed-State // (z.B. abgelaufener Token) ist nach erfolgreichem Connect behoben. await Promise.all([ updateIdleHeartbeat(conn.id).catch(() => {}), clearConnectionError(conn.id).catch(() => {}), ]); // Outlook/XOAUTH2 hat den Edge-Case dass getMailboxLock lautlos hängt // wenn der Server in einen ungültigen Zustand kommt — die Session // bleibt offen ohne Fortschritt bis der Renew-Timer (10min) ein // imap.close() schickt. Timeout-wrap macht das Failure-Mode explizit // → Auth-Retry-Loop greift sauber. await Promise.race([ imap.getMailboxLock("INBOX"), new Promise((_, reject) => setTimeout(() => reject(new Error("getMailboxLock timeout (30s)")), 30_000), ), ]); // Consent-Gate-Log: einmalig beim Connect — nur wenn consentAt fehlt if (!conn.consentAt) { log( conn.email, "consent_at=NULL — IDLE session held but scan/delete suspended. " + "Re-consent required via app.", ); } // IDLE-Loop: alle IDLE_RENEW_INTERVAL_MS erneuern while (!shuttingDown && sessions.has(conn.id)) { const idlePromise = new Promise((resolve, reject) => { imap.idle().then(resolve).catch(reject); }); // exists-event → sofort scannen (nur wenn consent erteilt) const onExists = () => { if (!conn.consentAt) { // Consent fehlt: exists-event ignorieren, Mail bleibt in Inbox. // UI zeigt Re-Consent-Modal (via /api/mail/pending-consent Endpoint). log(conn.email, "exists-event received — skipped (no consent_at)"); return; } log(conn.email, "exists-event received (new mail)"); triggerScan(conn); // fire-and-forget }; imap.on("exists", onExists); // IDLE nach 10min erneuern // Gilt für: GMX (~10-15min), Gmail (~29min), iCloud (~29min), outlook.office365.com (~29min) const renewTimer = setTimeout(() => { log(conn.email, "idle renewing (10min threshold)"); imap.close(); }, IDLE_RENEW_INTERVAL_MS); // NOOP-heartbeat alle 2min: silent-drop early-detection (GMX-pattern). const noopTimer = setInterval(async () => { try { await imap.noop(); await updateIdleHeartbeat(conn.id).catch(() => {}); } catch (err) { logError(conn.email, "noop failed — connection dead, force reconnect", err); imap.close(); } }, IDLE_NOOP_INTERVAL_MS); try { await idlePromise; } finally { clearInterval(noopTimer); clearTimeout(renewTimer); imap.removeListener("exists", onExists); } if (shuttingDown || !sessions.has(conn.id)) break; await sleep(500); } try { await imap.logout(); } catch { /* ignore */ } return; // Sauberer Exit (shutdown oder Connection entfernt) } catch (err) { logError(conn.email, "connection error", err); try { imap.close(); } catch { /* ignore */ } // ── AUTHENTICATIONFAILED-Recovery (OAuth-spezifisch) ────────────────── // Cold-Path: Token zwischen zwei IDLE-Renewals abgelaufen (>1h Session). // Oder: proaktiver Refresh ist fehlgeschlagen und wir landen hier. if (conn.authMethod === "oauth2_microsoft" && isAuthError(err)) { authRetries++; log( conn.email, `AUTHENTICATIONFAILED detected — refresh attempt ${authRetries}/${MAX_AUTH_RETRIES}`, ); if (authRetries > MAX_AUTH_RETRIES) { // Auth dauerhaft revoked (User hat App-Permission entzogen o.ä.) // Connection als auth_broken markieren — User muss re-connecten. log(conn.email, `auth_broken after ${MAX_AUTH_RETRIES} retries — session stopped`); await markConnectionAuthBroken(conn.id).catch(() => {}); sessions.delete(conn.id); return; } // Token refreshen — direkt hier im catch-Block. // Wenn der Refresh selbst fehlschlägt (revoked Refresh-Token), // wirft refreshAndSaveTokensDaemon — wir landen im äußeren catch // und der normale Reconnect-Backoff greift (attempt++). // Beim nächsten Attempt ruft getCredentialsForConnection() wieder refresh auf. try { const freshToken = await refreshAndSaveTokensDaemon(conn.id, MS_OAUTH_CLIENT_ID); // conn ist das ursprüngliche Objekt aus loadActiveConnections. // Wir patchen oauthAccessToken + oauthTokenExpiry inline damit // getCredentialsForConnection() beim nächsten Loop-Durchlauf // den frischen Token nutzt ohne erneuten DB-Read. conn.oauthAccessToken = encrypt(freshToken); conn.oauthTokenExpiry = new Date(Date.now() + 55 * 60 * 1000); // ~55min buffer log(conn.email, "token refreshed — reconnecting immediately"); // Kein normaler Backoff nach Auth-Refresh — sofort neu verbinden. // attempt bleibt unverändert (auth-error != network-error). continue; } catch (refreshErr) { logError(conn.email, "token refresh failed after AUTHENTICATIONFAILED", refreshErr); // Refresh selbst gescheitert → normaler Backoff (Netz, Serverproblem o.ä.) // authRetries bleibt erhöht — beim nächsten Auth-Fehler zählt es weiter. await updateConnectionError(conn.id, refreshErr?.message || String(refreshErr)).catch(() => {}); } } else { // Nicht-Auth-Fehler (Netz, TLS, etc.) — normal in DB schreiben const errText = err?.responseText || err?.message || String(err); await updateConnectionError(conn.id, errText).catch(() => {}); } } if (shuttingDown || !sessions.has(conn.id)) return; // Exponential backoff const delay = attempt < RECONNECT_DELAYS_MS.length ? RECONNECT_DELAYS_MS[attempt] : RECONNECT_LOOP_DELAY_MS; attempt++; log(conn.email, `reconnecting in ${delay / 1000}s (attempt ${attempt})`); await sleep(delay); } } // ─── Session-Management ─────────────────────────────────────────────────────── function startSession(conn) { if (sessions.has(conn.id)) return; // bereits aktiv log(conn.email, "session starting"); const handle = { conn, imap: null, promise: null }; sessions.set(conn.id, handle); handle.promise = runSession(conn).catch((err) => { logError(conn.email, "session crashed (unhandled)", err); sessions.delete(conn.id); }); } async function stopSession(connectionId, email) { const handle = sessions.get(connectionId); if (!handle) return; log(email ?? connectionId, "session stopping"); sessions.delete(connectionId); if (handle.imap) { try { handle.imap.close(); } catch { /* ignore */ } } await handle.promise.catch(() => {}); } // ─── DB-Refresh-Loop ────────────────────────────────────────────────────────── async function refreshConnections() { let rows; try { rows = await loadActiveConnections(); } catch (err) { console.error("[idle/db] loadActiveConnections failed:", err.message); return; } const activeIds = new Set(rows.map((r) => r.id)); // Neue Connections starten for (const row of rows) { if (!sessions.has(row.id)) { startSession(row); } } // Entfernte Connections stoppen for (const [id, handle] of sessions.entries()) { if (!activeIds.has(id)) { await stopSession(id, handle.conn?.email); } } // Consent-Status für laufende Sessions aktualisieren. // Wenn ein User consent erteilt hat seit dem letzten DB-Refresh: // Die laufende Session bekommt conn.consentAt gesetzt — next exists-event // löst dann sofort triggerScan() aus. Kein Restart nötig. for (const row of rows) { const handle = sessions.get(row.id); if (handle && handle.conn.consentAt !== row.consentAt) { if (!handle.conn.consentAt && row.consentAt) { log(row.email, "consent_at received — scan/delete now active"); } handle.conn.consentAt = row.consentAt; } } const consentPending = rows.filter((r) => !r.consentAt).length; console.log( `[idle/db] refreshed — ${activeIds.size} active connections, ${sessions.size} sessions` + (consentPending > 0 ? `, ${consentPending} pending consent` : ""), ); } // ─── Graceful Shutdown ──────────────────────────────────────────────────────── async function shutdown(signal) { console.log(`[idle] received ${signal} — shutting down gracefully`); shuttingDown = true; const stopPromises = []; for (const [id, handle] of sessions.entries()) { stopPromises.push(stopSession(id, handle.conn?.email)); } await Promise.allSettled(stopPromises); await pool.end().catch(() => {}); console.log("[idle] shutdown complete"); process.exit(0); } process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT")); // ─── Startup ────────────────────────────────────────────────────────────────── function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function assertEnv() { const missing = []; if (!process.env.DATABASE_URL) missing.push("DATABASE_URL"); if (!ADMIN_SECRET) missing.push("ADMIN_SECRET / NUXT_ADMIN_SECRET"); if (!MS_OAUTH_CLIENT_ID) missing.push("MS_OAUTH_CLIENT_ID"); if (missing.length > 0) { console.error( `[idle] FEHLER: fehlende Env-Vars: ${missing.join(", ")}. Daemon startet nicht.`, ); process.exit(1); } } async function main() { assertEnv(); console.log( `[idle] starting — backend=${BACKEND_URL} env=${process.env.NODE_ENV ?? "unknown"}`, ); await refreshConnections(); setInterval(() => { if (!shuttingDown) refreshConnections(); }, DB_REFRESH_INTERVAL_MS); } main().catch((err) => { console.error("[idle] fatal startup error:", err); process.exit(1); });