fix(backend): IMAP ECONNRESET crash-loop + dm-push ESM interop

- mail/scan{,-internal}.post.ts + imap-idle: attach imap.on('error')
  + targeted uncaughtException/unhandledRejection guards so a
  connection-level IMAP error (ECONNRESET / TLS disconnect) can no
  longer propagate to a process-level uncaughtException and kill the
  Nitro API (root cause of the staging 502 crash-loop)
- services/push.ts: lazy dynamic-import expo-server-sdk (singleton,
  like voip-push.ts) to fix "Class extends value [object Module]"
  (ESM/CJS undici interop) that broke DM push notifications;
  + nitro.config externals safety net

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-10 14:59:37 +02:00
parent 1f20056ef3
commit 1493752634
5 changed files with 130 additions and 5 deletions

View File

@ -601,6 +601,19 @@ async function runSession(conn) {
disableCompression: conn.imapHost.includes("office365"), 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 // Referenz ablegen damit shutdown() darauf zugreifen kann
const handle = sessions.get(conn.id); const handle = sessions.get(conn.id);
if (handle) handle.imap = imap; if (handle) handle.imap = imap;
@ -876,6 +889,63 @@ async function shutdown(signal) {
process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT")); 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 ────────────────────────────────────────────────────────────────── // ─── Startup ──────────────────────────────────────────────────────────────────
function sleep(ms) { function sleep(ms) {

View File

@ -10,11 +10,15 @@ export default defineNitroConfig({
// Default-publicAssets greift nicht zuverlässig wenn srcDir auf "server" zeigt. // Default-publicAssets greift nicht zuverlässig wenn srcDir auf "server" zeigt.
publicAssets: [{ baseURL: "/", dir: "../public", maxAge: 60 * 60 }], 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 // imapflow nutzt CJS-inherits-Pattern, bricht beim Bundlen zu ESM
// ("superCtor.prototype must be of type object"). @parse/node-apn wird // ("superCtor.prototype must be of type object"). @parse/node-apn wird
// in services/voip-push.ts via dynamic import geladen (vermeidet das gleiche // in services/voip-push.ts via dynamic import geladen (vermeidet das gleiche
// Problem ohne Externalize-Eintrag). // 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: { externals: {
// imapflow MUSS external bleiben — nutzt CJS-inherits-Pattern, bricht beim // imapflow MUSS external bleiben — nutzt CJS-inherits-Pattern, bricht beim
// Bundlen zu ESM ("superCtor.prototype must be of type object", util.inherits). // 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- // 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 // 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. // (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: { imports: {

View File

@ -94,6 +94,17 @@ export default defineEventHandler(async (event) => {
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true }, 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 scanned = 0;
let newlyBlocked = 0; let newlyBlocked = 0;

View File

@ -82,6 +82,16 @@ export default defineEventHandler(async (event) => {
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true }, 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 scanned = 0;
let newlyBlocked = 0; let newlyBlocked = 0;

View File

@ -11,12 +11,33 @@
* Token-Cleanup: Bei DeviceNotRegistered Receipts werden Tokens automatisch * Token-Cleanup: Bei DeviceNotRegistered Receipts werden Tokens automatisch
* disabled (PushToken.enabled = false) Re-Enable nur durch Re-Registrierung * disabled (PushToken.enabled = false) Re-Enable nur durch Re-Registrierung
* vom Client. * vom Client.
*
* IMPORT-HINWEIS: expo-server-sdk v6+ ist type:"module" und importiert undici (CJS)
* intern via ESM-Syntax. Nitro/Rollup kann diesen ESMCJS-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 { usePrisma } from "../utils/prisma";
import { sendVoIPPush } from "./voip-push"; 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<ExpoModule["Expo"]>;
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 { export interface ChatPushPayload {
/** Empfänger-User-ID (kann mehrere Tokens haben) */ /** Empfänger-User-ID (kann mehrere Tokens haben) */
@ -37,6 +58,7 @@ export interface ChatPushPayload {
export async function sendChatPush(payload: ChatPushPayload): Promise<void> { export async function sendChatPush(payload: ChatPushPayload): Promise<void> {
try { try {
const db = usePrisma(); const db = usePrisma();
const { expo, Expo } = await ensureExpo();
// 1) Profile-Opt-out prüfen // 1) Profile-Opt-out prüfen
const profile = await db.profile.findUnique({ const profile = await db.profile.findUnique({
@ -161,6 +183,7 @@ export async function sendDeviceAddedPush(
): Promise<void> { ): Promise<void> {
try { try {
const db = usePrisma(); const db = usePrisma();
const { expo, Expo } = await ensureExpo();
const profile = await db.profile.findUnique({ const profile = await db.profile.findUnique({
where: { id: payload.userId }, where: { id: payload.userId },
@ -250,6 +273,7 @@ export interface CallRingPushPayload {
export async function sendCallRingPush(payload: CallRingPushPayload): Promise<void> { export async function sendCallRingPush(payload: CallRingPushPayload): Promise<void> {
try { try {
const db = usePrisma(); const db = usePrisma();
const { expo, Expo } = await ensureExpo();
const profile = await db.profile.findUnique({ const profile = await db.profile.findUnique({
where: { id: payload.receiverId }, where: { id: payload.receiverId },