import { ImapFlow } from "imapflow"; import { getMailConnections, deleteOldMailBlocked, getAlreadyBlockedUidSet, insertMailBlocked, upsertMailBlockedStat, updateMailConnectionScanStats, insertMailClassificationSample, } from "../../db/mail"; import { getBlocklistedDomainsSet } 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 0–4 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 msClientId: string = (config.msOauthClientId as string) || process.env.MS_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, msClientId); } 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 }, }); 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] = await Promise.all([ getBlocklistedDomainsSet(senderDomains, user.id, includeGlobal), getAlreadyBlockedUidSet(allUids, user.id), ]); const toInsert: Parameters[0] = []; const uidsToDelete: string[] = []; const sampleInserts: Parameters[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, }); // 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, 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 }; });