feat(mail): Sucht-Compound-Regel + Phase-1-Training-Foundation

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 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-29 08:14:57 +02:00
parent fd446874e9
commit c3de7055a5
9 changed files with 599 additions and 2 deletions

View File

@ -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",

View File

@ -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<string, unknown> = {
...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 },

View File

@ -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<void> {
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<void> {
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`);
}
}

View File

@ -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",
];
/**

View File

@ -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;

View File

@ -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 = /(?<!\[)\b[A-ZÄÖÜ]{4,}\b(?!\])/g;
// ─── Haupt-Funktion ───────────────────────────────────────────────────────────
/**
* Sanitiert einen Mail-Betreff für ML-Training:
* 1. Spracherkennung (franc) auf Raw-Subject vor Sanitization für bessere Accuracy.
* 2. E-Mail-Adressen [EMAIL]
* 3. URLs [URL]
* 4. Zahlen > 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";
}

View File

@ -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<string>();
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,

View File

@ -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);
});
});

44
pnpm-lock.yaml generated
View File

@ -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