chahinebrini 275637f0b0 feat(mail): separate mail_blocked_stats table — preserves charts beyond 24h cleanup
deleteOldMailBlocked löscht weiter rohe Einträge nach 24h (Datenminimierung
für Mail-Inhalte, DSGVO Art. 5 Abs. 1 lit. c). Aber für Charts und
Pattern-Analysen werden vor dem Cleanup permanent aggregierte Daten in
einer separaten Tabelle geführt.

Architektur:

- Neue Tabelle mail_blocked_stats — UNIQUE (user_id, date, connection_id),
  enthält ausschließlich counts + UTC-Datum + IMAP-Host. Kein Subject,
  kein Sender, kein Mail-Inhalt. Datenminimierung jetzt auch im Audit-
  Pfad sichtbar.
- Live-Aggregation: scan.post.ts + scan-internal.post.ts upserten direkt
  nach jedem mail_blocked-INSERT in mail_blocked_stats (count += 1).
- 30-Tage-Backfill als SQL im Migration-File: bestehende mail_blocked-
  Rows der letzten 30 Tage werden einmalig aggregiert, damit Charts
  nicht 30 Tage lang leer aussehen.
- Stats-Endpoints (blocked-by-day, blocked-by-connection) lesen jetzt
  aus mail_blocked_stats. Response-Shape unverändert → Frontend bleibt
  unberührt.

ON DELETE CASCADE auf mail_connection_id (Hans-Müller-konservativ):
User-initiierter Disconnect = Art. 17-Signal → assoziierte Stats werden
mitgelöscht. SetNull wäre DSGVO-grenzwertig (orphan stats ohne klare
Lösch-Kontext-Zuordnung).

pnpm build:backend clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:13:24 +02:00

215 lines
6.9 KiB
TypeScript

import { ImapFlow } from "imapflow";
import {
getMailConnections,
deleteOldMailBlocked,
getAlreadyBlockedUidSet,
insertMailBlocked,
upsertMailBlockedStat,
updateMailConnectionScanStats,
} 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";
// 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");
// Grace-Period berücksichtigen
const inGrace =
profile?.globalBlocklistGraceUntil != null &&
new Date(profile.globalBlocklistGraceUntil) > new Date();
const includeGlobal = limits.globalBlocklist === "full" || inGrace;
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);
// Aggregat-Stats aktualisieren (vor 24h-Cleanup resistent)
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 { scanned: totalScanned, blocked: totalBlocked };
});