From 807847381f68f6dcedeb8a439e314ff0f67a6621 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Thu, 28 May 2026 16:07:05 +0200 Subject: [PATCH] fix(mail): Junk-Folder IDLE-Gap + Layer 2.6 global Display-Name-Patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/imap-idle/index.mjs | 10 ++++ backend/server/api/mail/scan-internal.post.ts | 4 +- backend/server/db/domains.ts | 57 ++++++++++++++++++- backend/server/utils/mail-classifier.ts | 21 ++++--- 4 files changed, 78 insertions(+), 14 deletions(-) diff --git a/backend/imap-idle/index.mjs b/backend/imap-idle/index.mjs index a4d1461..1d93731 100644 --- a/backend/imap-idle/index.mjs +++ b/backend/imap-idle/index.mjs @@ -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(); diff --git a/backend/server/api/mail/scan-internal.post.ts b/backend/server/api/mail/scan-internal.post.ts index c8fed9e..2b48c6f 100644 --- a/backend/server/api/mail/scan-internal.post.ts +++ b/backend/server/api/mail/scan-internal.post.ts @@ -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[0] = []; diff --git a/backend/server/db/domains.ts b/backend/server/db/domains.ts index 9a2c235..a4cebca 100644 --- a/backend/server/db/domains.ts +++ b/backend/server/db/domains.ts @@ -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 { const db = usePrisma(); @@ -111,6 +113,59 @@ export async function getCustomMailDisplayNames(userId: string): Promise 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 { + 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(); + 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) diff --git a/backend/server/utils/mail-classifier.ts b/backend/server/utils/mail-classifier.ts index 156e58e..7d39177 100644 --- a/backend/server/utils/mail-classifier.ts +++ b/backend/server/utils/mail-classifier.ts @@ -316,11 +316,11 @@ export interface ClassifyMailParams { /** Menge der geblockten Domains (aus getBlocklistedDomainsSet) */ blockedDomainSet: Set; /** - * 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 0 && senderName) {