chahinebrini 1493752634 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>
2026-06-10 14:59:37 +02:00

256 lines
8.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ImapFlow } from "imapflow";
import {
getMailConnections,
deleteOldMailBlocked,
getAlreadyBlockedUidSet,
insertMailBlocked,
upsertMailBlockedStat,
updateMailConnectionScanStats,
insertMailClassificationSample,
} from "../../db/mail";
import { getBlocklistedDomainsSet, getCustomMailDisplayNames } from "../../db/domains";
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
import { resolveProviderMeta } from "../../utils/imap-providers";
import { resolveImapAuth } from "../../utils/mail-auth";
import { classifyMail } from "../../utils/mail-classifier";
/**
* POST /api/mail/scan
* Scannt ALLE Ordner (INBOX, Spam, Papierkorb, All Mail …) nach Gambling-Mails.
* Free-User: nur eigene Domains + Keywords. Pro/Legend: globale Blocklist + eigene.
*
* Klassifikations-Pipeline: Layer 04 via mail-classifier.ts.
* Layer 5 (Sample-Capture): nach jeder Klassifikation.
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const connections = await getMailConnections(user.id);
if (connections.length === 0) {
throw createError({
statusCode: 404,
message: "Kein Mail-Konto verbunden",
});
}
// Consent-Gate (DSGVO Art. 9): Connections ohne explizite Einwilligung überspringen
const skippedNoConsent = connections.filter((c) => !c.consentAt).length;
const eligibleConnections = connections.filter((c) => c.consentAt);
if (skippedNoConsent > 0) {
console.log(
`[scan] skipping ${skippedNoConsent} connections — no consent_at (pending re-consent)`,
);
}
// Plan-aware: Free users get only custom domains, Pro/Legend get global blocklist
const profile = await getProfile(user.id);
const limits = getPlanLimits(profile?.plan ?? "free");
const inGrace =
profile?.globalBlocklistGraceUntil != null &&
new Date(profile.globalBlocklistGraceUntil) > new Date();
const includeGlobal = limits.globalBlocklist === "full" || inGrace;
await deleteOldMailBlocked(user.id);
const config = useRuntimeConfig(event);
const oauthClientIds = {
msClientId: (config.msOauthClientId as string) || process.env.MS_OAUTH_CLIENT_ID || "",
googleClientId: (config.googleOauthClientId as string) || process.env.GOOGLE_OAUTH_CLIENT_ID || "",
};
let totalScanned = 0;
let totalBlocked = 0;
for (const connection of eligibleConnections) {
let imapAuth: { user: string; accessToken: string } | { user: string; pass: string };
try {
imapAuth = await resolveImapAuth(connection, oauthClientIds);
} catch {
continue;
}
const useImplicitTls = !connection.useStarttls;
const imap = new ImapFlow({
host: connection.imapHost,
port: connection.imapPort,
secure: useImplicitTls,
...(connection.useStarttls ? { requireTLS: true } : {}),
auth: imapAuth,
logger: false,
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;
try {
await imap.connect();
const mailboxes = await imap.list();
const scannable = mailboxes.filter(
(mb: any) => !mb.flags?.has("\\Noselect"),
);
for (const mb of scannable) {
let lock: any;
try {
lock = await imap.getMailboxLock(mb.path);
} catch {
continue;
}
try {
const SCAN_LIMIT = 200;
const status = await imap.status(mb.path, { messages: true });
const msgCount = (status as any).messages ?? 0;
if (msgCount === 0) continue;
const fetchRange =
msgCount > SCAN_LIMIT ? `${msgCount - SCAN_LIMIT + 1}:*` : "1:*";
const allMessages = await imap.fetchAll(fetchRange, {
envelope: true,
});
scanned += allMessages.length;
totalScanned += allMessages.length;
const allUids = allMessages.map(
(m: any) => `${mb.path}:${String(m.uid ?? m.seq)}`,
);
const senderDomains = allMessages
.map((m: any) =>
((m.envelope?.from?.[0]?.address ?? "").toLowerCase().split("@")[1] ?? ""),
)
.filter(Boolean);
const [blockedDomainSet, alreadyBlockedSet, customDisplayNames] = await Promise.all([
getBlocklistedDomainsSet(senderDomains, user.id, includeGlobal),
getAlreadyBlockedUidSet(allUids, user.id),
getCustomMailDisplayNames(user.id),
]);
const toInsert: Parameters<typeof insertMailBlocked>[0] = [];
const uidsToDelete: string[] = [];
const sampleInserts: Parameters<typeof insertMailClassificationSample>[0][] = [];
for (const msg of allMessages) {
const from = msg.envelope?.from?.[0];
const senderEmail = (from?.address ?? "").toLowerCase();
const senderName = from?.name ?? null;
const subject = (msg.envelope?.subject ?? "").trim();
const msgDate = msg.envelope?.date ?? new Date();
const uid = `${mb.path}:${String(msg.uid ?? msg.seq)}`;
// Layer 0: Already blocked → skip, kein Sample
if (alreadyBlockedSet.has(uid)) continue;
const result = await classifyMail({
mail: { senderEmail, senderName, subject },
blockedDomainSet,
customDisplayNames,
});
// Layer 5: Sample-Capture (immer, außer Layer 0)
const senderDomain = senderEmail.split("@")[1] ?? null;
sampleInserts.push({
userId: user.id,
connectionId: connection.id,
senderName: senderName?.slice(0, 255) ?? null,
senderDomain: senderDomain?.slice(0, 255) ?? null,
relayDecodedDomain: result.relayDecodedDomain?.slice(0, 255) ?? null,
subject: subject.slice(0, 998) || null,
features: result.features as unknown as Record<string, unknown>,
finalAction: result.action,
triggerSource: result.triggerSource,
});
if (result.action !== "blocked") continue;
uidsToDelete.push(String(msg.uid));
toInsert.push({
userId: user.id,
connectionId: connection.id,
gmailMessageId: uid,
senderEmail: senderEmail || "unbekannt",
senderName,
subject: subject.slice(0, 200) || "(kein Betreff)",
receivedAt: msgDate,
action: "deleted",
triggerSource: result.triggerSource,
});
newlyBlocked++;
}
if (uidsToDelete.length > 0) {
try {
await imap.messageDelete(uidsToDelete.join(","), { uid: true });
} catch {
try {
for (const uid of uidsToDelete) {
await imap
.messageFlagsAdd(uid, ["\\Deleted"], { uid: true })
.catch(() => {});
}
await imap.expunge();
} catch {
/* ignore */
}
}
}
await insertMailBlocked(toInsert);
// Samples fire-and-forget
if (sampleInserts.length > 0) {
Promise.all(sampleInserts.map((s) => insertMailClassificationSample(s))).catch((err) => {
console.warn("[scan] sample insert failed (non-fatal):", err);
});
}
if (toInsert.length > 0) {
const providerMeta = resolveProviderMeta(connection.imapHost);
await upsertMailBlockedStat({
userId: user.id,
mailConnectionId: connection.id,
provider: providerMeta.provider,
providerLabel: providerMeta.providerLabel,
count: toInsert.length,
});
}
} finally {
lock.release();
}
}
await imap.logout();
} catch {
try {
await imap.logout();
} catch {}
}
totalBlocked += newlyBlocked;
await updateMailConnectionScanStats(
connection.id,
scanned,
newlyBlocked,
connection.emailsBlocked,
connection.emailsScanned,
connection.scanInterval,
);
}
return { ok: true, scanned: totalScanned, blocked: totalBlocked, skippedNoConsent };
});