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:
parent
fd446874e9
commit
c3de7055a5
@ -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",
|
||||
|
||||
@ -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 },
|
||||
|
||||
100
backend/server/plugins/mail-retention-cron.ts
Normal file
100
backend/server/plugins/mail-retention-cron.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
148
backend/server/utils/mail-training-utils.ts
Normal file
148
backend/server/utils/mail-training-utils.ts
Normal 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";
|
||||
}
|
||||
@ -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,
|
||||
|
||||
193
backend/tests/mail/mail-training-utils.test.ts
Normal file
193
backend/tests/mail/mail-training-utils.test.ts
Normal 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
44
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user