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);
// 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 () => {
try {
await imap.noop();
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) {
logError(conn.email, "noop failed — connection dead, force reconnect", err);
imap.close();

View File

@ -8,7 +8,7 @@ import {
updateMailConnectionScanStats,
insertMailClassificationSample,
} from "../../db/mail";
import { getBlocklistedDomainsSet, getCustomMailDisplayNames } from "../../db/domains";
import { getBlocklistedDomainsSet, getMailDisplayNamePatterns } from "../../db/domains";
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
import { resolveProviderMeta } from "../../utils/imap-providers";
@ -149,7 +149,7 @@ export default defineEventHandler(async (event) => {
const [blockedDomainSet, alreadyBlockedSet, customDisplayNames] = await Promise.all([
getBlocklistedDomainsSet(senderDomains, userId, includeGlobal),
getAlreadyBlockedUidSet(allUids, userId),
getCustomMailDisplayNames(userId),
getMailDisplayNamePatterns(userId),
]);
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).
*
* 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[]> {
const db = usePrisma();
@ -111,6 +113,59 @@ export async function getCustomMailDisplayNames(userId: string): Promise<string[
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) {
const db = usePrisma();
// Cannot delete submitted/approved domains (protect integrity)

View File

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