diff --git a/backend/imap-idle/index.mjs b/backend/imap-idle/index.mjs index b5ce820..6effaa9 100644 --- a/backend/imap-idle/index.mjs +++ b/backend/imap-idle/index.mjs @@ -601,6 +601,19 @@ async function runSession(conn) { disableCompression: conn.imapHost.includes("office365"), }); + // ImapFlow kann socket-level Errors (ECONNRESET, TLS-disconnect, ETIMEDOUT) + // als EventEmitter-'error'-Event feuern — ZUSÄTZLICH zu oder STATT eines + // rejected Promise. Ohne diesen Handler eskaliert Node zu uncaughtException + // → Prozess-Exit → alle 44+ Sessions fallen gleichzeitig aus. + // Dieser Handler absorbiert das Event account-lokal; der bestehende catch-Block + // weiter unten greift für den Promise-Rejection-Pfad. + imap.on("error", (err) => { + logError(conn.email, "imap socket error (absorbed, reconnect-loop handles it)", err); + // imap.close() hier ist safe: idempotent, löst idlePromise-rejection aus + // → IDLE-Loop verlässt await idlePromise → runSession-catch greift. + try { imap.close(); } catch { /* ignore */ } + }); + // Referenz ablegen damit shutdown() darauf zugreifen kann const handle = sessions.get(conn.id); if (handle) handle.imap = imap; @@ -876,6 +889,63 @@ async function shutdown(signal) { process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT")); +// ─── Letztes Netz: bekannte IMAP-Socket-Errors als uncaughtException ────────── +// Obwohl per-Account imap.on('error') und try/catch die primären Abfanggräben +// sind, kann ein Timing-Fenster (z.B. Error-Event feuert zwischen close() und +// neuem imap-Objekt) doch auf Prozessebene ankommen. +// Dieser Guard schluckt NUR bekannte IMAP/Socket-Fehler (ECONNRESET, ETIMEDOUT, +// ECONNREFUSED, EPIPE, EHOSTUNREACH, TLS-Fehler). Alles andere (Bug im Daemon- +// Code selbst) wird NICHT geschluckt — normaler uncaughtException-Exit bleibt. +const IMAP_SOCKET_ERROR_CODES = new Set([ + "ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "EPIPE", + "EHOSTUNREACH", "ENOTFOUND", "ENETUNREACH", +]); + +process.on("uncaughtException", (err, origin) => { + const code = err?.code; + const msg = err?.message ?? ""; + + const isKnownImapError = + (code && IMAP_SOCKET_ERROR_CODES.has(code)) || + msg.includes("Client network socket disconnected") || + msg.includes("before secure TLS connection") || + msg.includes("ECONNRESET") || + msg.includes("socket hang up"); + + if (isKnownImapError) { + console.error( + `[idle] uncaughtException absorbed (known IMAP/socket error) — origin=${origin} code=${code ?? "?"}: ${msg}`, + ); + // Daemon läuft weiter — Reconnect-Loop der betroffenen Session übernimmt + return; + } + + // Unbekannter Fehler: normal crashen (kein stummes Schlucken von Bugs) + console.error(`[idle] uncaughtException (non-IMAP, crashing) — origin=${origin}:`, err); + process.exit(1); +}); + +process.on("unhandledRejection", (reason, promise) => { + const msg = (reason instanceof Error ? reason.message : String(reason)) ?? ""; + const code = (reason instanceof Error ? /** @type {any} */ (reason).code : undefined); + + const isKnownImapError = + (code && IMAP_SOCKET_ERROR_CODES.has(code)) || + msg.includes("Client network socket disconnected") || + msg.includes("before secure TLS connection") || + msg.includes("ECONNRESET") || + msg.includes("socket hang up"); + + if (isKnownImapError) { + console.error( + `[idle] unhandledRejection absorbed (known IMAP/socket error): ${msg}`, + ); + return; + } + + console.error("[idle] unhandledRejection (non-IMAP):", reason); +}); + // ─── Startup ────────────────────────────────────────────────────────────────── function sleep(ms) { diff --git a/backend/nitro.config.ts b/backend/nitro.config.ts index b505fc5..ff75fb9 100644 --- a/backend/nitro.config.ts +++ b/backend/nitro.config.ts @@ -10,11 +10,15 @@ export default defineNitroConfig({ // Default-publicAssets greift nicht zuverlässig wenn srcDir auf "server" zeigt. publicAssets: [{ baseURL: "/", dir: "../public", maxAge: 60 * 60 }], - // Supabase + imapflow als external deps — nicht bundlen. + // Supabase + imapflow + expo-server-sdk als external deps — nicht bundlen. // imapflow nutzt CJS-inherits-Pattern, bricht beim Bundlen zu ESM // ("superCtor.prototype must be of type object"). @parse/node-apn wird // in services/voip-push.ts via dynamic import geladen (vermeidet das gleiche // Problem ohne Externalize-Eintrag). + // expo-server-sdk v6+ ist type:"module" und importiert undici (CJS) intern via + // ESM-Syntax. Nitro/Rollup kann diesen ESM→CJS-Interop nicht korrekt bundlen: + // → `Class extends value [object Module] is not a constructor` in push.mjs. + // Fix: external halten, läuft zur Laufzeit aus node_modules korrekt als ESM. externals: { // imapflow MUSS external bleiben — nutzt CJS-inherits-Pattern, bricht beim // Bundlen zu ESM ("superCtor.prototype must be of type object", util.inherits). @@ -22,8 +26,14 @@ export default defineNitroConfig({ // Specifier "imapflow", nicht auf aufgelöste node_modules-Pfade. Bei Module-Graph- // Shifts (z.B. neue Prisma-Felder) wurde imapflow doch inlined → scan-internal 500 // (Incident 2026-06-05). Expliziter external-Eintrag mit Pfad-Regex erzwingt es robust. - external: [/(^|[\\/]node_modules[\\/])imapflow([\\/]|$)/], - inline: [/^(?!@supabase\/supabase-js)(?!imapflow)/], + // + // expo-server-sdk: dasselbe Robustness-Prinzip — Pfad-Regex statt reiner Lookahead, + // weil Rollup bei transitivem Import-Graph den nackten Specifier nicht immer sieht. + external: [ + /(^|[\\/]node_modules[\\/])imapflow([\\/]|$)/, + /(^|[\\/]node_modules[\\/])expo-server-sdk([\\/]|$)/, + ], + inline: [/^(?!@supabase\/supabase-js)(?!imapflow)(?!expo-server-sdk)/], }, imports: { diff --git a/backend/server/api/mail/scan-internal.post.ts b/backend/server/api/mail/scan-internal.post.ts index 801e2f2..873178f 100644 --- a/backend/server/api/mail/scan-internal.post.ts +++ b/backend/server/api/mail/scan-internal.post.ts @@ -94,6 +94,17 @@ export default defineEventHandler(async (event) => { tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true }, }); + // ImapFlow kann socket-level Errors (ECONNRESET, TLS-disconnect) als EventEmitter- + // 'error'-Event feuern — ZUSÄTZLICH zu oder STATT eines rejected Promise. + // Ohne diesen Handler eskaliert Node das zu einem uncaughtException, das den + // gesamten Prozess killt. Dieser Handler absorbiert es account-lokal. + imap.on("error", (err: Error) => { + console.error( + `[scan-internal] imap socket error for ${connection.email} (absorbed, non-fatal):`, + err?.message ?? err, + ); + }); + let scanned = 0; let newlyBlocked = 0; diff --git a/backend/server/api/mail/scan.post.ts b/backend/server/api/mail/scan.post.ts index 6b9a8b8..e05f079 100644 --- a/backend/server/api/mail/scan.post.ts +++ b/backend/server/api/mail/scan.post.ts @@ -82,6 +82,16 @@ export default defineEventHandler(async (event) => { tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true }, }); + // Socket-level Errors (ECONNRESET, TLS-disconnect) können als EventEmitter- + // 'error'-Event kommen statt als rejected Promise → uncaughtException ohne + // diesen Handler. + imap.on("error", (err: Error) => { + console.error( + `[scan] imap socket error for ${connection.email} (absorbed, non-fatal):`, + err?.message ?? err, + ); + }); + let scanned = 0; let newlyBlocked = 0; diff --git a/backend/server/services/push.ts b/backend/server/services/push.ts index c61ea38..d8b984f 100644 --- a/backend/server/services/push.ts +++ b/backend/server/services/push.ts @@ -11,12 +11,33 @@ * Token-Cleanup: Bei DeviceNotRegistered Receipts werden Tokens automatisch * disabled (PushToken.enabled = false) — Re-Enable nur durch Re-Registrierung * vom Client. + * + * IMPORT-HINWEIS: expo-server-sdk v6+ ist type:"module" und importiert undici (CJS) + * intern via ESM-Syntax. Nitro/Rollup kann diesen ESM→CJS-Interop beim statischen + * Bundlen nicht korrekt auflösen → `Class extends value [object Module] is not a + * constructor` zur Laufzeit. Workaround: dynamic import (analog @parse/node-apn in + * voip-push.ts), sodass das Modul erst zur Laufzeit aus node_modules geladen wird. */ -import { Expo, type ExpoPushMessage } from "expo-server-sdk"; import { usePrisma } from "../utils/prisma"; import { sendVoIPPush } from "./voip-push"; -const expo = new Expo(); +// expo-server-sdk lazy-geladen via dynamic import — verhindert Rollup-ESM/CJS-Interop-Bug. +type ExpoModule = typeof import("expo-server-sdk"); +type ExpoInstance = InstanceType; +type ExpoPushMessage = ExpoModule["ExpoPushMessage"]; + +let _expoMod: ExpoModule | null = null; +let _expo: ExpoInstance | null = null; + +async function ensureExpo(): Promise<{ expo: ExpoInstance; Expo: ExpoModule["Expo"] }> { + if (_expo && _expoMod) return { expo: _expo, Expo: _expoMod.Expo }; + const mod = (await import("expo-server-sdk")) as unknown as ExpoModule & { + default?: ExpoModule; + }; + _expoMod = mod.default ?? mod; + _expo = new _expoMod.Expo(); + return { expo: _expo, Expo: _expoMod.Expo }; +} export interface ChatPushPayload { /** Empfänger-User-ID (kann mehrere Tokens haben) */ @@ -37,6 +58,7 @@ export interface ChatPushPayload { export async function sendChatPush(payload: ChatPushPayload): Promise { try { const db = usePrisma(); + const { expo, Expo } = await ensureExpo(); // 1) Profile-Opt-out prüfen const profile = await db.profile.findUnique({ @@ -161,6 +183,7 @@ export async function sendDeviceAddedPush( ): Promise { try { const db = usePrisma(); + const { expo, Expo } = await ensureExpo(); const profile = await db.profile.findUnique({ where: { id: payload.userId }, @@ -250,6 +273,7 @@ export interface CallRingPushPayload { export async function sendCallRingPush(payload: CallRingPushPayload): Promise { try { const db = usePrisma(); + const { expo, Expo } = await ensureExpo(); const profile = await db.profile.findUnique({ where: { id: payload.receiverId },