fix(mail): Junk-Folder IDLE-Gap + Layer 2.6 global Display-Name-Patterns

Task 1 — Junk-Folder Fix:
- noopTimer (alle 2min) ruft jetzt triggerScan(conn) fire-and-forget auf
- Outlook/Hotmail-Mails die direkt in "Junk Email" landen werden damit
  innerhalb von max. 2min erfasst (IDLE hört nur INBOX, kein exists-Event)
- Consent-Guard analog exists-Event: nur wenn conn.consentAt gesetzt

Task 2 — Layer 2.6 global Display-Name-Patterns:
- getMailDisplayNamePatterns(userId) neu in db/domains.ts:
  lädt aus global_mail_display_names (admin-curated, pending Migration)
  + user_custom_domains type=mail_display_name (backward-compat)
  mit try/catch-Fallback bis Schema-Migration deployed ist
- getCustomMailDisplayNames() als @deprecated markiert (bleibt für Übergangszeitraum)
- scan-internal.post.ts: Import auf getMailDisplayNamePatterns umgestellt
- mail-classifier.ts: Layer 2.6 Kommentar von "dead code" auf "live v1.1" aktualisiert

Schema-Migration (global_mail_display_names) ist Aufgabe von rebreak-backend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-28 16:07:05 +02:00
parent 4a9601aadb
commit 807847381f
4 changed files with 78 additions and 14 deletions

View File

@ -638,10 +638,20 @@ async function runSession(conn) {
}, IDLE_RENEW_INTERVAL_MS); }, IDLE_RENEW_INTERVAL_MS);
// NOOP-heartbeat alle 2min: silent-drop early-detection (GMX-pattern). // NOOP-heartbeat alle 2min: silent-drop early-detection (GMX-pattern).
// Zusätzlich: fire-and-forget scan-internal bei jedem Tick (Junk-Folder-Fix).
// Outlook/Hotmail liefert kein INBOX-exists-Event für Mails die direkt in
// "Junk Email" landen — IDLE hört nur INBOX. Der 2min-Tick stellt sicher dass
// auch Junk-Ordner-Mails innerhalb von max. 2min erfasst werden.
// Consent-Gate sitzt in scan-internal → kein doppeltes Check hier nötig.
const noopTimer = setInterval(async () => { const noopTimer = setInterval(async () => {
try { try {
await imap.noop(); await imap.noop();
await updateIdleHeartbeat(conn.id).catch(() => {}); await updateIdleHeartbeat(conn.id).catch(() => {});
// Junk-Folder-Sweep: scan-internal scannt alle Ordner inkl. Junk.
// Nur auslösen wenn Consent erteilt (analog exists-Event-Guard).
if (conn.consentAt) {
triggerScan(conn).catch(() => {});
}
} catch (err) { } catch (err) {
logError(conn.email, "noop failed — connection dead, force reconnect", err); logError(conn.email, "noop failed — connection dead, force reconnect", err);
imap.close(); imap.close();

View File

@ -8,7 +8,7 @@ import {
updateMailConnectionScanStats, updateMailConnectionScanStats,
insertMailClassificationSample, insertMailClassificationSample,
} from "../../db/mail"; } from "../../db/mail";
import { getBlocklistedDomainsSet, getCustomMailDisplayNames } from "../../db/domains"; import { getBlocklistedDomainsSet, getMailDisplayNamePatterns } from "../../db/domains";
import { getProfile } from "../../db/profile"; import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features"; import { getPlanLimits } from "../../utils/plan-features";
import { resolveProviderMeta } from "../../utils/imap-providers"; import { resolveProviderMeta } from "../../utils/imap-providers";
@ -149,7 +149,7 @@ export default defineEventHandler(async (event) => {
const [blockedDomainSet, alreadyBlockedSet, customDisplayNames] = await Promise.all([ const [blockedDomainSet, alreadyBlockedSet, customDisplayNames] = await Promise.all([
getBlocklistedDomainsSet(senderDomains, userId, includeGlobal), getBlocklistedDomainsSet(senderDomains, userId, includeGlobal),
getAlreadyBlockedUidSet(allUids, userId), getAlreadyBlockedUidSet(allUids, userId),
getCustomMailDisplayNames(userId), getMailDisplayNamePatterns(userId),
]); ]);
const toInsert: Parameters<typeof insertMailBlocked>[0] = []; const toInsert: Parameters<typeof insertMailBlocked>[0] = [];

View File

@ -97,10 +97,12 @@ export async function addUserCustomDomain(
} }
/** /**
* Gibt alle Display-Name-Patterns eines Users zurück. * Gibt alle Display-Name-Patterns eines Users zurück (user-scope, Legacy).
* Wird vor jedem Mail-Scan geladen und an classifyMail() übergeben (Layer 2.6). * Wird vor jedem Mail-Scan geladen und an classifyMail() übergeben (Layer 2.6).
* *
* DSGVO: keine PII User-eigene Heuristik-Patterns (z.B. "EXTRASPIN"). * DSGVO: keine PII User-eigene Heuristik-Patterns (z.B. "EXTRASPIN").
*
* @deprecated Verwende getMailDisplayNamePatterns() liefert global + user in einem Aufruf.
*/ */
export async function getCustomMailDisplayNames(userId: string): Promise<string[]> { export async function getCustomMailDisplayNames(userId: string): Promise<string[]> {
const db = usePrisma(); const db = usePrisma();
@ -111,6 +113,59 @@ export async function getCustomMailDisplayNames(userId: string): Promise<string[
return rows.map((r) => r.domain); return rows.map((r) => r.domain);
} }
/**
* Gibt alle aktiven Display-Name-Patterns zurück (global + user-scope, union).
*
* Globale Patterns kommen aus `global_mail_display_names` (admin-curated,
* analog BlocklistDomain). User-Patterns aus `user_custom_domains` mit
* type='mail_display_name' werden dazugemergt (backward-compat, falls manuell befüllt).
*
* Caching: caller-seitig (scan-internal lädt einmal pro Mailbox-Iteration).
*
* SCHEMA-ABHÄNGIGKEIT: Setzt `global_mail_display_names`-Tabelle voraus
* (Migration via rebreak-backend). Bis die Migration live ist, fällt die
* Funktion auf user-scope-only zurück (try/catch um Prisma-unknown-model-Error).
*
* DSGVO: keine Mail-Inhalte reine Heuristik-Patterns (Art. 5 DSGVO).
*/
export async function getMailDisplayNamePatterns(userId: string): Promise<string[]> {
const db = usePrisma();
// User-scope (backward-compat): immer laden, Schema existiert bereits.
const userRows = await db.userCustomDomain.findMany({
where: { userId, type: "mail_display_name" },
select: { domain: true },
});
const userPatterns = userRows.map((r) => r.domain);
// Global-scope: aus global_mail_display_names.
// Try/catch: wenn Migration noch nicht deployed → graceful fallback auf user-only.
let globalPatterns: string[] = [];
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const globalRows = await (db as any).globalMailDisplayName.findMany({
where: { isActive: true },
select: { pattern: true },
});
globalPatterns = globalRows.map((r: { pattern: string }) => r.pattern);
} catch {
// Tabelle existiert noch nicht (Migration pending) — user-only reicht als Übergangslösung.
}
// Deduplizieren (case-insensitive): wenn ein User-Pattern identisch zu einem
// globalen ist → brauchen wir es nur einmal im Array.
const seen = new Set<string>();
const result: string[] = [];
for (const p of [...globalPatterns, ...userPatterns]) {
const key = p.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
result.push(p);
}
}
return result;
}
export async function deleteUserCustomDomain(id: string, userId: string) { export async function deleteUserCustomDomain(id: string, userId: string) {
const db = usePrisma(); const db = usePrisma();
// Cannot delete submitted/approved domains (protect integrity) // Cannot delete submitted/approved domains (protect integrity)

View File

@ -316,11 +316,11 @@ export interface ClassifyMailParams {
/** Menge der geblockten Domains (aus getBlocklistedDomainsSet) */ /** Menge der geblockten Domains (aus getBlocklistedDomainsSet) */
blockedDomainSet: Set<string>; blockedDomainSet: Set<string>;
/** /**
* User-spezifische Display-Name-Patterns (aus getCustomMailDisplayNames). * Display-Name-Patterns (global-curated + optional user-scope) aus getMailDisplayNamePatterns().
* Layer 2.6: case-insensitive Substring-Match gegen senderName. * Layer 2.6: case-insensitive Substring-Match gegen senderName.
* Leer-Array wenn User keine Display-Name-Patterns gesetzt hat. * Leer-Array solange keine Patterns geladen wurden.
* *
* DSGVO: keine PII reine Heuristik-Muster (z.B. ["EXTRASPIN"]). * DSGVO: keine PII reine Heuristik-Muster (z.B. ["Tipico", "Bet365"]).
*/ */
customDisplayNames?: string[]; customDisplayNames?: string[];
} }
@ -440,16 +440,15 @@ export async function classifyMail(params: ClassifyMailParams): Promise<Classifi
}; };
} }
// ── Layer 2.6: User-Custom-Display-Name-Hard-Block ────────────────────────── // ── Layer 2.6: Display-Name-Hard-Block (global-curated + user-scope) ────────
// Display-name patterns disabled in v1.0 — re-enable when display-name input UX ships (v1.1). // Patterns kommen aus getMailDisplayNamePatterns() — admin-curated globale
// getCustomMailDisplayNames() returns [] until mail_display_name rows exist, // Gambling-Brand-Liste (z.B. "Tipico", "Bet365") plus optionale user-scope Patterns.
// so this block is dead code in practice. Keep logic intact for trivial re-activation.
// //
// User-eigene Patterns (z.B. "EXTRASPIN") matchen case-insensitiv als Substring // v1.1 (2026-05-28): von dead-code zu live — global_mail_display_names-Tabelle
// gegen den Sender-Display-Name. Kein Score — direkter Hard-Block wenn Match. // als Datenquelle. Keine User-UI nötig; Admin pflegt die Liste manuell.
// //
// Substring-Match (nicht exact) damit "EXTRASPIN Casino" und "ExtraSpin Bonus" // Substring-Match (nicht exact) damit "Tipico Casino" und "TIPICO Bonus"
// beide von Pattern "EXTRASPIN" erfasst werden. // beide von Pattern "Tipico" erfasst werden.
// //
// Gambling-Brands rotieren aktiv Capitalization → case-insensitive ist Pflicht. // Gambling-Brands rotieren aktiv Capitalization → case-insensitive ist Pflicht.
if (customDisplayNames && customDisplayNames.length > 0 && senderName) { if (customDisplayNames && customDisplayNames.length > 0 && senderName) {