197 lines
6.2 KiB
TypeScript

import { ImapFlow } from "imapflow";
import {
getMailConnections,
deleteOldMailBlocked,
getAlreadyBlockedUidSet,
insertMailBlocked,
updateMailConnectionScanStats,
} from "../../db/mail";
import { getBlocklistedDomainsSet } from "../../db/domains";
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
// Single-Source-of-Truth (Mo's Finding #4)
// @ts-expect-error — .mjs ohne types, GAMBLING_KEYWORDS ist string[]
import { GAMBLING_KEYWORDS } from "../../utils/gambling-keywords.mjs";
/**
* 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.
*/
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",
});
}
// 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 includeGlobal = limits.globalBlocklist;
await deleteOldMailBlocked(user.id);
let totalScanned = 0;
let totalBlocked = 0;
for (const connection of connections) {
let password: string;
try {
password = decrypt(connection.passwordEncrypted);
} catch {
continue;
}
// useStarttls=true → STARTTLS (secure=false + requireTLS=true)
// rejectUnauthorized=false → self-signed Certs zulassen (nur Custom-IMAP)
const useImplicitTls = !connection.useStarttls;
const imap = new ImapFlow({
host: connection.imapHost,
port: connection.imapPort,
secure: useImplicitTls,
...(connection.useStarttls ? { requireTLS: true } : {}),
auth: { user: connection.email, pass: password },
logger: false,
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true },
});
let scanned = 0;
let newlyBlocked = 0;
try {
await imap.connect();
// Scan ALL mailbox folders (not just hardcoded list)
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 [blockedDomainSet, alreadyBlockedSet] = await Promise.all([
getBlocklistedDomainsSet(
allMessages
.map(
(m: any) =>
(m.envelope?.from?.[0]?.address ?? "")
.toLowerCase()
.split("@")[1] ?? "",
)
.filter(Boolean),
user.id,
includeGlobal,
),
getAlreadyBlockedUidSet(allUids, user.id),
]);
const toInsert: Parameters<typeof insertMailBlocked>[0] = [];
const uidsToDelete: string[] = [];
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)}`;
const haystack = `${senderEmail} ${subject}`.toLowerCase();
const isGamblingKeyword = GAMBLING_KEYWORDS.some((kw) =>
haystack.includes(kw),
);
const senderDomain = senderEmail.split("@")[1] ?? "";
const isBlocklisted = senderDomain
? blockedDomainSet.has(senderDomain)
: false;
if (!isGamblingKeyword && !isBlocklisted) continue;
if (alreadyBlockedSet.has(uid)) 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",
});
newlyBlocked++;
}
// Permanently delete gambling mails from this folder
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);
} 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 { scanned: totalScanned, blocked: totalBlocked };
});