rebreak-monorepo/backend/server/utils/mail-training-utils.ts
chahinebrini c3de7055a5 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>
2026-05-29 08:14:57 +02:00

149 lines
5.9 KiB
TypeScript

/**
* 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";
}