From c3de7055a521ce97aa4767075abeef4533426877 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Fri, 29 May 2026 08:14:57 +0200 Subject: [PATCH] feat(mail): Sucht-Compound-Regel + Phase-1-Training-Foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task B — linguistische FP-Fix: - mail-classifier.ts: Subject-Keyword-Loop überspringt Keyword-Score wenn Subject das Keyword als Sucht-Compound enthält (z.B. "glücksspiel" in "Glücksspielsucht" → kein +50 Score). Globale linguistische Invariante Deutsch — Gambling-Marketer schreiben nie "Glücksspielsucht-Bonus". - gambling-keywords.mjs: GAMBLING_WHITELIST erweitert um Stamm-Varianten (wettsucht, spielsucht, suchtberatung, suchthilfe) als Fallback für Compounds wo keyword ≠ exakter Stamm. - 4 neue Tests: Forum Glücksspielsucht → PASS, Hilfe bei Spielsucht → PASS, Wettsucht-Selbsthilfe → PASS, Glücksspiel-Bonus 100€ → BLOCK. Task C — Phase-1-Data-Foundation: - mail-training-utils.ts: sanitizeSubjectForTraining() (PII-Stripping via Regex: EMAIL/URL/NUM/Greeting/ALL-CAPS) + detectSubjectLanguage() via franc (iso639-3). 26 Unit-Tests. - franc@6.2.0 installiert (~50KB ESM). - mail.ts insertMailClassificationSample(): ruft sanitizeSubjectForTraining() auf, schreibt detectedLang + subjectSanitized in features-JSON (Interim bis Schema-Migration). - mail-retention-cron.ts: Subject-Nullification nach 30 Tagen (täglich) + Sample-Purge nach 12 Monaten (monatlich). DSGVO Art. 5 Abs. 1e. 105 Tests grün (58 classifier + 26 training-utils + 11 display-name + 10 gmail). Co-Authored-By: Claude Sonnet 4.6 --- backend/package.json | 1 + backend/server/db/mail.ts | 28 ++- backend/server/plugins/mail-retention-cron.ts | 100 +++++++++ backend/server/utils/gambling-keywords.mjs | 9 + backend/server/utils/mail-classifier.ts | 9 + backend/server/utils/mail-training-utils.ts | 148 ++++++++++++++ backend/tests/mail/mail-classifier.test.ts | 69 +++++++ .../tests/mail/mail-training-utils.test.ts | 193 ++++++++++++++++++ pnpm-lock.yaml | 44 ++++ 9 files changed, 599 insertions(+), 2 deletions(-) create mode 100644 backend/server/plugins/mail-retention-cron.ts create mode 100644 backend/server/utils/mail-training-utils.ts create mode 100644 backend/tests/mail/mail-training-utils.test.ts diff --git a/backend/package.json b/backend/package.json index a469646..17fa7c7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,6 +17,7 @@ "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "@supabase/supabase-js": "^2.39.7", + "franc": "^6.2.0", "groq-sdk": "^0.7.0", "imapflow": "^1.2.18", "jose": "^6.0.0", diff --git a/backend/server/db/mail.ts b/backend/server/db/mail.ts index 01f62ff..cf85fd6 100644 --- a/backend/server/db/mail.ts +++ b/backend/server/db/mail.ts @@ -2,6 +2,7 @@ import { usePrisma } from "../utils/prisma"; import { encrypt, decrypt } from "../utils/crypto"; import { refreshMicrosoftTokens } from "../utils/ms-oauth"; import { refreshGoogleTokens } from "../utils/google-oauth"; +import { sanitizeSubjectForTraining } from "../utils/mail-training-utils"; export async function getMailConnections(userId: string) { const db = usePrisma(); @@ -221,6 +222,13 @@ export async function deleteUserMailClassificationSamples(userId: string) { * kurzlebige Detection-Signale, kein narrativer Inhalt. * Vollständige Löschung bei Account-Delete via deleteUserMailClassificationSamples() * (Art. 17) — NICHT via Prisma-Cascade, da userId keine FK-Relation hat. + * + * Phase 1 Data-Foundation: + * - sanitizeSubjectForTraining() läuft immer → subjectSanitized + detectedLang + * - detectedLang wird in features.detectedLang geschrieben (kein Schema-Change) + * - subjectSanitized: TODO — wartet auf Schema-Migration (rebreak-backend). + * Nach Migration: data.subjectSanitized = sanitized.subjectSanitized hinzufügen. + * Bis dahin: in features.subjectSanitized persistiert (lesbar, nicht optimal). */ export async function insertMailClassificationSample(entry: { userId: string; @@ -239,9 +247,25 @@ export async function insertMailClassificationSample(entry: { groqReason?: string | null; }) { const db = usePrisma(); - // JSON.parse(JSON.stringify(features)) liefert ein "plain JSON value" das Prisma akzeptiert. + + // Phase 1: Subject sanitisieren + Sprache erkennen. + // Läuft bei JEDER Sample-Insertion, unabhängig von trainingConsent + // (Sanitization ist DSGVO-Voraussetzung — erst nach Migration + Consent wird + // subjectSanitized für Training genutzt). + const sanitized = sanitizeSubjectForTraining(entry.subject); + + // features anreichern: detectedLang + subjectSanitized (Interim bis Schema-Migration) + const enrichedFeatures: Record = { + ...entry.features, + detectedLang: sanitized.detectedLang, + // TODO nach Schema-Migration: subjectSanitized als eigenes DB-Feld persistieren. + // Bis dahin im features-JSON — für Analysen bereits nutzbar. + subjectSanitized: sanitized.subjectSanitized, + }; + + // JSON.parse(JSON.stringify(...)) liefert ein "plain JSON value" das Prisma akzeptiert. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const featuresJson = JSON.parse(JSON.stringify(entry.features)); + const featuresJson = JSON.parse(JSON.stringify(enrichedFeatures)); await db.mailClassificationSample.create({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data: { ...entry, features: featuresJson }, diff --git a/backend/server/plugins/mail-retention-cron.ts b/backend/server/plugins/mail-retention-cron.ts new file mode 100644 index 0000000..6a0ad31 --- /dev/null +++ b/backend/server/plugins/mail-retention-cron.ts @@ -0,0 +1,100 @@ +/** + * Mail-Retention-Cron — Phase 1 Data-Foundation. + * + * Zweck: DSGVO-konforme Datensparsamkeit (Art. 5 Abs. 1e — Speicherbegrenzung). + * + * Zwei Retention-Jobs: + * + * 1. Subject-Nullification (täglich): + * Raw-Subject in MailClassificationSample auf null setzen wenn älter als 30 Tage. + * Das sanitisierte Subject (features.subjectSanitized) bleibt erhalten — + * nur der raw-Text wird entfernt. Begründung: raw-Subject kann noch PII + * enthalten die sanitizeSubjectForTraining() übersehen hat (z.B. Names die + * nicht ALL-CAPS sind). Nach 30 Tagen ist der Klassifikations-Nutzen gering. + * + * 2. Sample-Purge (monatlich): + * MailClassificationSamples älter als 12 Monate werden hart gelöscht. + * Begründung: Trainingsdaten werden im Phase-2-Export batch-extrahiert + * (Colab). Nach 12 Monaten sind ältere Samples für Modell-Updates weniger + * relevant als neue Samples. User-Lösch-Recht (Art. 17) ist davon unabhängig + * und wird via deleteUserMailClassificationSamples() behandelt. + * + * Läuft nur in Production (import.meta.dev guard). + * Beide Jobs laufen beim Server-Start einmalig (initial sweep) + dann periodisch. + */ + +import { consola } from "consola"; +import { usePrisma } from "../utils/prisma"; + +// Subject-Nullification: täglich +const SUBJECT_NULLIFY_INTERVAL_MS = 24 * 60 * 60 * 1000; +// Subject wird nach 30 Tagen auf null gesetzt +const SUBJECT_NULLIFY_AGE_DAYS = 30; + +// Sample-Purge: alle 30 Tage (monatlich) +const SAMPLE_PURGE_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000; +// Samples älter als 12 Monate werden gelöscht +const SAMPLE_PURGE_AGE_DAYS = 365; + +export default defineNitroPlugin((nitro) => { + if (import.meta.dev) return; + + consola.info("[mail-retention-cron] Starting — subject-nullify daily, sample-purge monthly"); + + // Einmaliger initialer Sweep bei Server-Start (deckt verpasste Runs ab) + void runSubjectNullification().catch(() => {}); + void runSamplePurge().catch(() => {}); + + const nullifyInterval = setInterval(() => { + void runSubjectNullification().catch(() => {}); + }, SUBJECT_NULLIFY_INTERVAL_MS); + + const purgeInterval = setInterval(() => { + void runSamplePurge().catch(() => {}); + }, SAMPLE_PURGE_INTERVAL_MS); + + nitro.hooks.hook("close", () => { + clearInterval(nullifyInterval); + clearInterval(purgeInterval); + }); +}); + +/** + * Setzt raw-Subject-Feld auf null für Samples älter als 30 Tage. + * features.subjectSanitized bleibt erhalten (wurde bei Insert geschrieben). + * + * Verwendet $executeRaw weil Prisma updateMany kein + * WHERE subject IS NOT NULL direkt unterstützt (kein affected-rows-count). + */ +async function runSubjectNullification(): Promise { + const db = usePrisma(); + const cutoff = new Date(Date.now() - SUBJECT_NULLIFY_AGE_DAYS * 86_400_000); + + const result = await db.$executeRaw` + UPDATE "rebreak"."mail_classification_samples" + SET "subject" = NULL + WHERE "subject" IS NOT NULL + AND "created_at" < ${cutoff}::timestamptz + `; + + if (result > 0) { + consola.info(`[mail-retention-cron] subject-nullify: ${result} samples anonymized`); + } +} + +/** + * Löscht MailClassificationSamples älter als 12 Monate. + * Gibt die Anzahl gelöschter Rows zurück (für Logging). + */ +async function runSamplePurge(): Promise { + const db = usePrisma(); + const cutoff = new Date(Date.now() - SAMPLE_PURGE_AGE_DAYS * 86_400_000); + + const result = await db.mailClassificationSample.deleteMany({ + where: { createdAt: { lt: cutoff } }, + }); + + if (result.count > 0) { + consola.info(`[mail-retention-cron] sample-purge: ${result.count} old samples deleted`); + } +} diff --git a/backend/server/utils/gambling-keywords.mjs b/backend/server/utils/gambling-keywords.mjs index 02a6c1a..4f60060 100644 --- a/backend/server/utils/gambling-keywords.mjs +++ b/backend/server/utils/gambling-keywords.mjs @@ -42,6 +42,15 @@ export const GAMBLING_WHITELIST = [ "wetterbericht", "wettkampf", // kein Glücksspiel "wettbewerb", // dito + // Recovery-/Anti-Gambling-Compounds mit "sucht"-Suffix. + // Ergänzung zu Sucht-Compound-Regel in mail-classifier.ts: + // Regel deckt Fälle wo keyword als exaktes Präfix vorkommt (glücksspiel→glücksspielsucht). + // "wettsucht" kann nicht via concat-Regel abgedeckt werden (kw="wette" ≠ "wett"), + // daher Whitelist als Fallback für Stamm-Varianten. + "wettsucht", + "spielsucht", + "suchtberatung", + "suchthilfe", ]; /** diff --git a/backend/server/utils/mail-classifier.ts b/backend/server/utils/mail-classifier.ts index 7d39177..c8ac311 100644 --- a/backend/server/utils/mail-classifier.ts +++ b/backend/server/utils/mail-classifier.ts @@ -252,6 +252,15 @@ export function computeScore( // ── Subject-Keywords ── for (const kw of GAMBLING_KEYWORDS as string[]) { if (subjectLower.includes(kw)) { + // Linguistische Invariante (Deutsch): Compound-Nomen mit "-sucht"-Suffix + // (Glücksspielsucht, Spielsucht, Wettsucht) signalisieren IMMER Recovery-/ + // Anti-Gambling-Kontext. Gambling-Marketer schreiben nie "Glücksspielsucht-Bonus" + // — regulatorisch tabu + würde User-Vertrauen zerstören. + // Implementierung: keyword "glücksspiel" matcht in "Glücksspielsucht" → + // subject enthält "${kw}sucht" → kein Score-Beitrag. + if (subjectLower.includes(`${kw}sucht`)) { + continue; // Recovery-Kontext — kein Gambling-Signal + } keywordHitsSubject.push(kw); score += SCORE_WEIGHTS.SUBJECT_GAMBLING_KEYWORD; break; diff --git a/backend/server/utils/mail-training-utils.ts b/backend/server/utils/mail-training-utils.ts new file mode 100644 index 0000000..a757560 --- /dev/null +++ b/backend/server/utils/mail-training-utils.ts @@ -0,0 +1,148 @@ +/** + * Mail-Training-Utils — Phase 1 Data-Foundation. + * + * Zweck: Anonymisierung + Spracherkennung für zukünftiges ML-Training (Phase 3). + * Diese Utilities bereiten Klassifikations-Samples so auf, dass sie DSGVO-konform + * für Modell-Training verwendbar sind (Art. 5 Abs. 1e — Speicherbegrenzung). + * + * Scope dieser Datei: + * - sanitizeSubjectForTraining() — PII-Stripping aus Betreff-Zeilen + * - detectSubjectLanguage() — Spracherkennung via franc (iso639-3) + * + * Was hier NICHT ist: + * - ML-Inference / Modell-Calls (Phase 3) + * - NER (zu aufwendig für MVP) + * - Consent-Prüfung (liegt beim Aufrufer, nicht in dieser Util) + * + * DSGVO-Anmerkungen: + * - sanitizeSubjectForTraining() entfernt PII bevor Samples persistiert werden. + * - Raw-Subject bleibt 30 Tage in DB, danach via Retention-Cron auf null gesetzt. + * - detectedLang (iso639-3-Code) ist kein personenbezogenes Datum. + */ + +// franc ist ein ESM-only package (v6+). Import funktioniert in Node ESM context. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore — franc hat keine @types, Exports sind sauber via JSDoc +import { franc } from "franc"; + +// ─── Typen ──────────────────────────────────────────────────────────────────── + +/** + * Ergebnis der Sanitization. + * subjectSanitized: PII-freie Version des Betreffs für ML-Training. + * detectedLang: iso639-3-Code (z.B. "deu", "eng", "fra") oder "und" wenn unbekannt. + */ +export interface SubjectSanitizationResult { + subjectSanitized: string; + detectedLang: string; +} + +// ─── Regex-Patterns für PII-Detection ──────────────────────────────────────── + +/** + * Erkennt E-Mail-Adressen im Betreff. + * Beispiel: "Info für test@example.com" → "Info für [EMAIL]" + */ +const RE_EMAIL = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g; + +/** + * Erkennt URLs (http/https/www-prefixed). + * Beispiel: "Besuche https://casino.bet/promo" → "Besuche [URL]" + */ +const RE_URL = /(?:https?:\/\/|www\.)[^\s<>"'{}|\\^`\[\]]+/gi; + +/** + * Erkennt Zahlenfolgen mit mehr als 4 Ziffern (z.B. Kundennummern, Tracking-IDs). + * 4-stellige Zahlen bleiben (Jahres-/Score-Angaben wie "2024", "1000€" bleiben lesbar). + * Beispiel: "Bestellung 123456789" → "Bestellung [NUM]" + */ +const RE_LONG_NUMBER = /\b\d{5,}\b/g; + +/** + * Erkennt typische Grußformeln am Anfang des Betreffs. + * Nach dem Grußwort + Komma folgt oft ein Name — alles bis zum nächsten + * Satzzeichen/Trennzeichen wird als Personenreferenz behandelt und entfernt. + * + * Beispiele: + * "Hallo Max, dein Bonus wartet" → "dein Bonus wartet" + * "Hi Sarah! Neues Angebot" → "Neues Angebot" + * "Dear John, your account" → "your account" + * "Cher Michel, votre offre" → "votre offre" + * "Sehr geehrte Frau Müller, ..." → "..." + */ +const RE_GREETING_PREFIX = + /^(?:hallo|hi|hey|dear|cher|chère|sehr geehrte[rn]?|bonjour|buenos días|caro|cara)\s+[^,!.;:]+[,!.;:]\s*/i; + +/** + * Erkennt ALL-CAPS-Wörter die typischerweise Personennamen oder + * tracking-spezifische Tokens sind (z.B. "JOHN", "ABC123XY" nach Grußposition). + * Nur Wörter >= 4 Zeichen, um Abkürzungen wie "SMS", "VIP" zu schonen. + * + * Negative Lookahead: Tokens in eckigen Klammern ([EMAIL], [URL], [NUM]) + * werden NICHT angefasst — verhindert [[NAME]] double-replacement. + * + * Hinweis: Wird nach Greeting-Strip angewendet, um verbleibende Name-Tokens + * zu ersetzen. Bewusst konservativ — keine Volltext-NER. + */ +const RE_ALL_CAPS_LONG = /(? 4 Stellen → [NUM] + * 5. Grußwort-Prefix mit Name → strip + * 6. ALL-CAPS-Wörter >= 4 Zeichen → [NAME] (konservative Heuristik) + * 7. Whitespace normalisieren + * + * Die Reihenfolge ist bedeutsam: URLs vor Emails prüfen (URLs können @-Zeichen + * enthalten), Greeting vor ALL-CAPS (Greeting-Strip reduziert false positives). + * + * Gibt einen leeren String zurück wenn subject null/leer ist. + */ +export function sanitizeSubjectForTraining( + subject: string | null | undefined, +): SubjectSanitizationResult { + if (!subject || subject.trim().length === 0) { + return { subjectSanitized: "", detectedLang: "und" }; + } + + // Schritt 1: Spracherkennung auf dem Raw-Subject (franc braucht natürlichen Text) + // franc gibt "und" zurück wenn zu kurz oder unbekannt (< ~20 Zeichen oft unsicher). + const detectedLang = (franc(subject) as string) ?? "und"; + + // Schritt 2: Sanitization + let sanitized = subject; + + // URLs vor E-Mails (URLs können @ enthalten) + sanitized = sanitized.replace(RE_URL, "[URL]"); + + // E-Mail-Adressen + sanitized = sanitized.replace(RE_EMAIL, "[EMAIL]"); + + // Lange Zahlenfolgen (> 4 Stellen) + sanitized = sanitized.replace(RE_LONG_NUMBER, "[NUM]"); + + // Grußwort-Prefix mit Name entfernen + sanitized = sanitized.replace(RE_GREETING_PREFIX, ""); + + // ALL-CAPS-Wörter >= 4 Zeichen → [NAME] + sanitized = sanitized.replace(RE_ALL_CAPS_LONG, "[NAME]"); + + // Whitespace normalisieren (mehrfache Leerzeichen, leading/trailing) + sanitized = sanitized.replace(/\s+/g, " ").trim(); + + return { subjectSanitized: sanitized, detectedLang }; +} + +/** + * Wrapper der nur die Spracherkennung ausführt (ohne Sanitization). + * Nützlich wenn Sanitization bereits anderweitig passiert ist. + */ +export function detectSubjectLanguage(subject: string | null | undefined): string { + if (!subject || subject.trim().length === 0) return "und"; + return (franc(subject) as string) ?? "und"; +} diff --git a/backend/tests/mail/mail-classifier.test.ts b/backend/tests/mail/mail-classifier.test.ts index d79f348..950a32a 100644 --- a/backend/tests/mail/mail-classifier.test.ts +++ b/backend/tests/mail/mail-classifier.test.ts @@ -37,6 +37,10 @@ vi.mock("../../server/utils/gambling-keywords.mjs", () => ({ "wetterbericht", "wettkampf", "wettbewerb", + "wettsucht", + "spielsucht", + "suchtberatung", + "suchthilfe", ], })); @@ -563,6 +567,71 @@ describe("classifyMail() — End-to-End Pipeline", () => { }); }); +// ─── Sucht-Compound-Regel (linguistische Invariante Deutsch) ───────────────── + +describe("Sucht-Compound-Regel — Recovery-Kontext wird nicht geblockt", () => { + const emptyDomainSet = new Set(); + + it("'Forum Glücksspielsucht' → PASS (Compound-Regel greift)", async () => { + // "glücksspiel" matcht in Subject, aber subject enthält "glücksspielsucht" + // → ${kw}sucht = "glücksspielsucht" ist in subjectLower → skip → Score=0 → PASS + const result = await classifyMail({ + mail: { + senderEmail: "info@forum-gluecksspielsucht.de", + senderName: "Forum Glücksspielsucht", + subject: "Willkommen im Forum Glücksspielsucht", + }, + blockedDomainSet: emptyDomainSet, + }); + expect(result.action).toBe("passed"); + expect(result.features.keywordHitsSubject).toHaveLength(0); + }); + + it("'Hilfe bei Spielsucht' → PASS (Whitelist)", async () => { + // "spielsucht" ist in GAMBLING_WHITELIST → PASS via Layer 1. + const result = await classifyMail({ + mail: { + senderEmail: "beratung@spielsucht-hilfe.de", + senderName: "Spielsucht-Hilfe e.V.", + subject: "Hilfe bei Spielsucht — kostenlose Beratung", + }, + blockedDomainSet: emptyDomainSet, + }); + expect(result.action).toBe("passed"); + expect(result.features.keywordHitsSubject).toHaveLength(0); + }); + + it("'Wettsucht-Selbsthilfe' → PASS (Whitelist-Fallback für Stamm-Variante)", async () => { + // "wette" (kw) matcht in "Wettsucht", aber "wettesucht" ist nicht in subject — + // Stamm-Variante: Compound-Regel greift nicht, Whitelist "wettsucht" fängt es ab. + const result = await classifyMail({ + mail: { + senderEmail: "info@wettsucht-hilfe.de", + senderName: "Wettsucht-Selbsthilfe", + subject: "Wettsucht-Selbsthilfe — Du bist nicht allein", + }, + blockedDomainSet: emptyDomainSet, + }); + expect(result.action).toBe("passed"); + }); + + it("'Glücksspiel-Bonus 100€' → BLOCK (kein Sucht-Compound im Subject)", async () => { + // "glücksspiel" matcht, subject enthält NICHT "glücksspielsucht" → Regel greift nicht. + // Score: +50 (keyword) + 20 (money-pattern) = 70 → BLOCK. + const result = await classifyMail({ + mail: { + senderEmail: "promo@casino-example.com", + senderName: "Casino Bonus", + subject: "Glücksspiel-Bonus 100€ — Jetzt einlösen", + }, + blockedDomainSet: emptyDomainSet, + }); + expect(result.action).toBe("blocked"); + expect(result.features.keywordHitsSubject).toContain("glücksspiel"); + expect(result.score).toBeGreaterThanOrEqual(50); + }); +}); + // ─── Fix 1: Folder-Filter (System-Folder-Ausschluss) ────────────────────────── // Hinweis: scan-internal ist ein Nitro-Handler (nicht reine Funktion) — die // specialUse-Filter-Logik wird hier als Unit über die regex-Konstante getestet, diff --git a/backend/tests/mail/mail-training-utils.test.ts b/backend/tests/mail/mail-training-utils.test.ts new file mode 100644 index 0000000..1022ede --- /dev/null +++ b/backend/tests/mail/mail-training-utils.test.ts @@ -0,0 +1,193 @@ +/** + * Tests für mail-training-utils.ts — Phase 1 Data-Foundation. + * + * Abgedeckt: + * - sanitizeSubjectForTraining() — alle Regex-Patterns + * - detectSubjectLanguage() — franc-Integration + * - Edge-Cases: null, leer, nur Whitespace + */ +import { describe, it, expect } from "vitest"; + +import { + sanitizeSubjectForTraining, + detectSubjectLanguage, +} from "../../server/utils/mail-training-utils"; + +// ─── sanitizeSubjectForTraining() ───────────────────────────────────────────── + +describe("sanitizeSubjectForTraining()", () => { + // ─── Edge-Cases ──────────────────────────────────────────────────────────── + + it("null → leerer String + detectedLang='und'", () => { + const result = sanitizeSubjectForTraining(null); + expect(result.subjectSanitized).toBe(""); + expect(result.detectedLang).toBe("und"); + }); + + it("leerer String → leerer String + detectedLang='und'", () => { + const result = sanitizeSubjectForTraining(""); + expect(result.subjectSanitized).toBe(""); + expect(result.detectedLang).toBe("und"); + }); + + it("nur Whitespace → leerer String", () => { + const result = sanitizeSubjectForTraining(" "); + expect(result.subjectSanitized).toBe(""); + }); + + // ─── E-Mail-Erkennung ────────────────────────────────────────────────────── + + it("E-Mail-Adresse im Betreff → [EMAIL]", () => { + const result = sanitizeSubjectForTraining("Info für test@example.com"); + expect(result.subjectSanitized).toBe("Info für [EMAIL]"); + }); + + it("E-Mail mitten im Satz → [EMAIL]", () => { + const result = sanitizeSubjectForTraining("Willkommen user123@domain.de, dein Konto ist aktiv"); + expect(result.subjectSanitized).toBe("Willkommen [EMAIL], dein Konto ist aktiv"); + }); + + // ─── URL-Erkennung ───────────────────────────────────────────────────────── + + it("https-URL → [URL]", () => { + const result = sanitizeSubjectForTraining("Besuche https://casino.bet/promo"); + expect(result.subjectSanitized).toBe("Besuche [URL]"); + }); + + it("http-URL → [URL]", () => { + const result = sanitizeSubjectForTraining("Klicke auf http://example.com/offer"); + expect(result.subjectSanitized).toBe("Klicke auf [URL]"); + }); + + it("www-URL → [URL]", () => { + const result = sanitizeSubjectForTraining("www.bonus.de/angebot jetzt sichern"); + expect(result.subjectSanitized).toBe("[URL] jetzt sichern"); + }); + + // ─── Zahlen-Erkennung ────────────────────────────────────────────────────── + + it("5-stellige Zahl → [NUM]", () => { + const result = sanitizeSubjectForTraining("Bestellung 12345 wurde versandt"); + expect(result.subjectSanitized).toBe("Bestellung [NUM] wurde versandt"); + }); + + it("9-stellige Kundennummer → [NUM]", () => { + const result = sanitizeSubjectForTraining("Deine Kundennummer: 987654321"); + expect(result.subjectSanitized).toBe("Deine Kundennummer: [NUM]"); + }); + + it("4-stellige Zahl bleibt erhalten (Jahr, Score)", () => { + const result = sanitizeSubjectForTraining("Angebot 2024 — bis zu 1000€"); + expect(result.subjectSanitized).toBe("Angebot 2024 — bis zu 1000€"); + }); + + // ─── Greeting-Prefix ─────────────────────────────────────────────────────── + + it("'Hallo Max, ...' → Greeting-Prefix stripped", () => { + const result = sanitizeSubjectForTraining("Hallo Max, dein Bonus wartet"); + expect(result.subjectSanitized).toBe("dein Bonus wartet"); + }); + + it("'Hi Sarah! ...' → Greeting-Prefix stripped", () => { + const result = sanitizeSubjectForTraining("Hi Sarah! Neues Angebot für dich"); + expect(result.subjectSanitized).toBe("Neues Angebot für dich"); + }); + + it("'Dear John, ...' → Greeting-Prefix stripped", () => { + const result = sanitizeSubjectForTraining("Dear John, your account is ready"); + expect(result.subjectSanitized).toBe("your account is ready"); + }); + + it("'Cher Michel, ...' → Greeting-Prefix stripped", () => { + const result = sanitizeSubjectForTraining("Cher Michel, votre offre expire"); + expect(result.subjectSanitized).toBe("votre offre expire"); + }); + + it("kein Greeting → bleibt unverändert (kein false strip)", () => { + const result = sanitizeSubjectForTraining("Casino Bonus wartet auf dich"); + expect(result.subjectSanitized).toBe("Casino Bonus wartet auf dich"); + }); + + // ─── ALL-CAPS-Wörter ─────────────────────────────────────────────────────── + + it("ALL-CAPS-Wort >= 4 Zeichen → [NAME]", () => { + const result = sanitizeSubjectForTraining("Willkommen HANS, dein Konto ist bereit"); + expect(result.subjectSanitized).toBe("Willkommen [NAME], dein Konto ist bereit"); + }); + + it("ALL-CAPS-Wort < 4 Zeichen bleibt (z.B. 'SMS', 'VIP')", () => { + const result = sanitizeSubjectForTraining("Dein VIP-Bonus wartet"); + // "VIP" ist 3 Zeichen → bleibt erhalten + expect(result.subjectSanitized).toBe("Dein VIP-Bonus wartet"); + }); + + // ─── Whitespace-Normalisierung ───────────────────────────────────────────── + + it("mehrfache Leerzeichen nach Sanitization → normalisiert", () => { + // Wenn Greeting entfernt wird können führende Leerzeichen entstehen + const result = sanitizeSubjectForTraining("Hallo Test, dein Angebot"); + expect(result.subjectSanitized).not.toMatch(/\s{2,}/); + }); + + // ─── Kombinierte Patterns ────────────────────────────────────────────────── + + it("Kombination: URL + Zahl + E-Mail alle ersetzt", () => { + const result = sanitizeSubjectForTraining( + "Tracking 123456789: Paket von user@shop.de — Link: https://track.shop.de/abc" + ); + expect(result.subjectSanitized).toContain("[NUM]"); + expect(result.subjectSanitized).toContain("[EMAIL]"); + expect(result.subjectSanitized).toContain("[URL]"); + expect(result.subjectSanitized).not.toMatch(/\d{5,}/); + expect(result.subjectSanitized).not.toMatch(/[a-zA-Z0-9._%+\-]+@/); + }); + + it("realistisches Gambling-Mail bleibt erkennbar nach Sanitization", () => { + const result = sanitizeSubjectForTraining( + "Dein Glücksspiel-Bonus 100€ wartet — Tracking: 987654321" + ); + // Gambling-Signal "Glücksspiel-Bonus" bleibt erhalten + expect(result.subjectSanitized).toContain("Glücksspiel-Bonus"); + // Tracking-ID wird ersetzt + expect(result.subjectSanitized).toContain("[NUM]"); + expect(result.subjectSanitized).not.toMatch(/\d{5,}/); + }); +}); + +// ─── detectSubjectLanguage() ────────────────────────────────────────────────── + +describe("detectSubjectLanguage()", () => { + it("null → 'und'", () => { + expect(detectSubjectLanguage(null)).toBe("und"); + }); + + it("leerer String → 'und'", () => { + expect(detectSubjectLanguage("")).toBe("und"); + }); + + it("Deutsch erkannt (deu)", () => { + // Längerer Text für bessere Spracherkennung + const lang = detectSubjectLanguage( + "Dein exklusives Angebot wartet auf dich — jetzt einlösen und profitieren" + ); + expect(lang).toBe("deu"); + }); + + it("Englisch-artiger Text → nicht 'und' (franc erkennt eine Sprache)", () => { + // franc kann je nach Text-Sample eng oder sco (Scots) zurückgeben für englische Texte — + // beide sind akzeptabel. Wichtig: kein "und" (= unbekannt). + const lang = detectSubjectLanguage( + "Your exclusive offer is waiting — claim your bonus now and start winning today" + ); + expect(lang).not.toBe("und"); + expect(typeof lang).toBe("string"); + }); + + it("Zu kurzer Text → 'und' (franc-Limitation bei < ~20 Zeichen)", () => { + // Sehr kurze Texte liefern "und" oder falsche Erkennungen — erwartetes Verhalten. + // Wir prüfen nur dass ein String zurückkommt (kein throw). + const lang = detectSubjectLanguage("Hi"); + expect(typeof lang).toBe("string"); + expect(lang.length).toBeGreaterThan(0); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 772dec3..6d38559 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: expo-av: specifier: ~16.0.8 version: 16.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) + expo-blur: + specifier: ~15.0.8 + version: 15.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) expo-build-properties: specifier: ~1.0.10 version: 1.0.10(expo@54.0.34) @@ -322,6 +325,9 @@ importers: '@supabase/supabase-js': specifier: ^2.39.7 version: 2.105.3 + franc: + specifier: ^6.2.0 + version: 6.2.0 groq-sdk: specifier: ^0.7.0 version: 0.7.0 @@ -4791,6 +4797,9 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -5494,6 +5503,13 @@ packages: react-native-web: optional: true + expo-blur@15.0.8: + resolution: {integrity: sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-build-properties@1.0.10: resolution: {integrity: sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q==} peerDependencies: @@ -5879,6 +5895,9 @@ packages: framesync@6.1.2: resolution: {integrity: sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==} + franc@6.2.0: + resolution: {integrity: sha512-rcAewP7PSHvjq7Kgd7dhj82zE071kX5B4W1M4ewYMf/P+i6YsDQmj62Xz3VQm9zyUzUXwhIde/wHLGCMrM+yGg==} + freeport-async@2.0.0: resolution: {integrity: sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==} engines: {node: '>=8'} @@ -6978,6 +6997,9 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + n-gram@2.0.2: + resolution: {integrity: sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ==} + named-placeholders@1.1.6: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} @@ -8749,6 +8771,9 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + trigram-utils@2.0.1: + resolution: {integrity: sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -14737,6 +14762,8 @@ snapshots: cluster-key-slot@1.1.2: {} + collapse-white-space@2.1.0: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -15495,6 +15522,12 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0) + expo-blur@15.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0) + expo-build-properties@1.0.10(expo@54.0.34): dependencies: ajv: 8.20.0 @@ -15976,6 +16009,10 @@ snapshots: dependencies: tslib: 2.4.0 + franc@6.2.0: + dependencies: + trigram-utils: 2.0.1 + freeport-async@2.0.0: {} fresh@0.5.2: {} @@ -17198,6 +17235,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + n-gram@2.0.2: {} + named-placeholders@1.1.6: dependencies: lru.min: 1.1.4 @@ -19507,6 +19546,11 @@ snapshots: tr46@0.0.3: {} + trigram-utils@2.0.1: + dependencies: + collapse-white-space: 2.1.0 + n-gram: 2.0.2 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3