Public-Domain-Guard (icloud.com/gmail.com etc. nie blockbar/veröffentlichbar): - neue utils/public-email-domains.ts (shared Freemail-Liste) - custom-domains/index.post + custom-domains/suggest + curated-domains/suggest lehnen Public-Domains mit 400 PUBLIC_DOMAIN ab (defense-in-depth) Mail-Detection (mo): "spins" zu GAMBLING_KEYWORDS + Subject-%-Pattern (Score 10) → fängt "Spins + 400% Bonus"-Spam von Freemail-Absendern. 61/61 Tests grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
758 lines
29 KiB
TypeScript
758 lines
29 KiB
TypeScript
/**
|
|
* Tests für mail-classifier.ts — Mail-Klassifikations-Pipeline.
|
|
*
|
|
* Testet alle Layer-Logiken als pure Funktionen (kein DB-Mock).
|
|
*
|
|
* Abgedeckt:
|
|
* - extractRelayedDomain() — diverse Relay-Patterns
|
|
* - normalizeBrand() — Normalisierungs-Logik
|
|
* - hasRandomTokens() — true/false cases
|
|
* - computeScore() — Score-Berechnung mit Weights
|
|
* - classifyMail() — End-to-End Pipeline:
|
|
* - Gamblezen-Beispiel → Layer 2 Hard-Block via Blocklist
|
|
* - Gamblezen-Beispiel ohne Blocklist → Score-Block via Domain-Keyword
|
|
* - BetandPlay-Beispiel → Layer 2 Hard-Block via Relay-Decoded
|
|
* - BetandPlay-Beispiel ohne Blocklist → Score-Path (kein Brand via Display-Name)
|
|
* - Whitelist-Case (wettervorhersage)
|
|
* - Domain-Block (Layer 2)
|
|
* - Relay-Decoded Block (Layer 2)
|
|
* - No-Signal → PASS
|
|
* - v1.0: Display-Name-only Gambling-Pattern → PASS (kein Score-Beitrag)
|
|
*/
|
|
import { describe, it, expect, vi } from "vitest";
|
|
|
|
// gambling-keywords.mjs ist ESM ohne TypeScript — mock before import
|
|
vi.mock("../../server/utils/gambling-keywords.mjs", () => ({
|
|
GAMBLING_KEYWORDS: [
|
|
"casino", "bet365", "bwin", "tipico", "unibet", "betway",
|
|
"pokerstars", "jackpot", "freispiel", "free spin", "bonus code",
|
|
"auszahlung", "glücksspiel", "slots", "roulette", "spins", "wette",
|
|
"stake", "rolletto", "vbet", "1xbet", "melbet", "mostbet",
|
|
"luckyvibe", "spinz", "casinoly", "rabona", "justcasino",
|
|
"getslots", "rocketplay", "freshcasino", "betano", "leovegas",
|
|
],
|
|
GAMBLING_WHITELIST: [
|
|
"wettervorhersage",
|
|
"wetter",
|
|
"wetterbericht",
|
|
"wettkampf",
|
|
"wettbewerb",
|
|
"wettsucht",
|
|
"spielsucht",
|
|
"suchtberatung",
|
|
"suchthilfe",
|
|
],
|
|
}));
|
|
|
|
import {
|
|
extractRelayedDomain,
|
|
normalizeBrand,
|
|
hasRandomTokens,
|
|
computeScore,
|
|
classifyMail,
|
|
matchesGamblingBrand,
|
|
} from "../../server/utils/mail-classifier";
|
|
|
|
// ─── extractRelayedDomain ────────────────────────────────────────────────────
|
|
|
|
describe("extractRelayedDomain()", () => {
|
|
it("extrahiert Domain aus SendGrid-bounce-Pattern (user=domain@sendgrid)", () => {
|
|
expect(extractRelayedDomain("bounces+user=gamblezen.com@sendgrid.net"))
|
|
.toBe("gamblezen.com");
|
|
});
|
|
|
|
it("extrahiert Domain aus Mailchimp-Track-Pattern (track.user=domain@mc)", () => {
|
|
expect(extractRelayedDomain("track.user=betandplay.com@mailchimp.com"))
|
|
.toBe("betandplay.com");
|
|
});
|
|
|
|
it("extrahiert Domain aus _at_-Pattern", () => {
|
|
expect(extractRelayedDomain("a1b2c3_user_at_betandplay.com@em.example.com"))
|
|
.toBe("betandplay.com");
|
|
});
|
|
|
|
it("gibt null zurück wenn kein Relay-Pattern erkannt", () => {
|
|
expect(extractRelayedDomain("info@betandplay.com")).toBeNull();
|
|
});
|
|
|
|
it("gibt null zurück für direkte Adressen ohne @", () => {
|
|
expect(extractRelayedDomain("noatsign")).toBeNull();
|
|
});
|
|
|
|
it("normalisiert extrahierte Domain auf lowercase", () => {
|
|
expect(extractRelayedDomain("bounce=GambleZen.COM@delivery.net"))
|
|
.toBe("gamblezen.com");
|
|
});
|
|
|
|
it("gibt null zurück für normale Adressen ohne Relay-Muster", () => {
|
|
expect(extractRelayedDomain("newsletter@example.org")).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── normalizeBrand ──────────────────────────────────────────────────────────
|
|
|
|
describe("normalizeBrand()", () => {
|
|
it("BetandPlay → betandplay", () => {
|
|
expect(normalizeBrand("BetandPlay")).toBe("betandplay");
|
|
});
|
|
|
|
it("bet-and-play → betandplay", () => {
|
|
expect(normalizeBrand("bet-and-play")).toBe("betandplay");
|
|
});
|
|
|
|
it("Gamble Zen → gamblezen", () => {
|
|
expect(normalizeBrand("Gamble Zen")).toBe("gamblezen");
|
|
});
|
|
|
|
it("Mr. Green → mrgreen", () => {
|
|
expect(normalizeBrand("Mr. Green")).toBe("mrgreen");
|
|
});
|
|
|
|
it("lucky_vibe → luckyvibe", () => {
|
|
expect(normalizeBrand("lucky_vibe")).toBe("luckyvibe");
|
|
});
|
|
|
|
it("unveränderte Kleinbuchstaben bleiben gleich", () => {
|
|
expect(normalizeBrand("casino")).toBe("casino");
|
|
});
|
|
});
|
|
|
|
// ─── matchesGamblingBrand ────────────────────────────────────────────────────
|
|
|
|
describe("matchesGamblingBrand()", () => {
|
|
it("'gamblezen' matcht", () => {
|
|
expect(matchesGamblingBrand("gamblezen")).toBe(true);
|
|
});
|
|
|
|
it("'betandplay' matcht", () => {
|
|
expect(matchesGamblingBrand("betandplay")).toBe(true);
|
|
});
|
|
|
|
it("'casino' matcht (exact)", () => {
|
|
expect(matchesGamblingBrand("casino")).toBe(true);
|
|
});
|
|
|
|
it("'mrgreen' matcht", () => {
|
|
expect(matchesGamblingBrand("mrgreen")).toBe(true);
|
|
});
|
|
|
|
it("'example' matcht nicht", () => {
|
|
expect(matchesGamblingBrand("example")).toBe(false);
|
|
});
|
|
|
|
it("zu kurze Strings (< 4 Zeichen) matchen nie", () => {
|
|
expect(matchesGamblingBrand("bet")).toBe(false);
|
|
});
|
|
|
|
it("'googlemail' matcht nicht", () => {
|
|
expect(matchesGamblingBrand("googlemail")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── hasRandomTokens ─────────────────────────────────────────────────────────
|
|
|
|
describe("hasRandomTokens()", () => {
|
|
it("local-part mit 2+ zufälligen Tokens → true", () => {
|
|
// Gamblezen-typisch: hq3a91_7xmpl2 (2 random-looking tokens)
|
|
expect(hasRandomTokens("hq3a91_7xmpl2")).toBe(true);
|
|
});
|
|
|
|
it("local-part mit User-ID + Token → true", () => {
|
|
expect(hasRandomTokens("user123abc_ref456xyz")).toBe(true);
|
|
});
|
|
|
|
it("'info' → false (Funktionswort)", () => {
|
|
expect(hasRandomTokens("info")).toBe(false);
|
|
});
|
|
|
|
it("'noreply' → false (Funktionswort)", () => {
|
|
expect(hasRandomTokens("noreply")).toBe(false);
|
|
});
|
|
|
|
it("'newsletter' → false (Funktionswort, kein Digit-Mix)", () => {
|
|
expect(hasRandomTokens("newsletter")).toBe(false);
|
|
});
|
|
|
|
it("normaler Local-Part ohne Zufalls-Tokens → false", () => {
|
|
expect(hasRandomTokens("john.doe")).toBe(false);
|
|
});
|
|
|
|
it("nur ein random Token (Grenzfall) → false", () => {
|
|
// Nur ein Token >= 6 mit Digit-Mix → unter Schwelle (braucht >= 2)
|
|
expect(hasRandomTokens("abc123")).toBe(false);
|
|
});
|
|
|
|
it("echter BetandPlay-typischer Local-Part → true", () => {
|
|
// z.B. "u7a2b1_offers_ref9x2z" — ein Funktionswort + 2 random tokens
|
|
expect(hasRandomTokens("u7a2b1_offers_ref9x2z")).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── computeScore ────────────────────────────────────────────────────────────
|
|
|
|
describe("computeScore()", () => {
|
|
it("Whitelist-Hit → score=0, whitelistHit=true", () => {
|
|
const result = computeScore(
|
|
"info@wetter.de",
|
|
"Wetter Service",
|
|
"Wettervorhersage für morgen",
|
|
false,
|
|
false,
|
|
);
|
|
expect(result.whitelistHit).toBe(true);
|
|
expect(result.score).toBe(0);
|
|
});
|
|
|
|
it("Casino im Betreff → SUBJECT_GAMBLING_KEYWORD += 50", () => {
|
|
const result = computeScore(
|
|
"info@example.com",
|
|
null,
|
|
"Dein Casino-Bonus wartet",
|
|
false,
|
|
false,
|
|
);
|
|
expect(result.keywordHitsSubject).toContain("casino");
|
|
expect(result.score).toBe(50);
|
|
});
|
|
|
|
it("Geld-Pattern (100€) im Betreff → SUBJECT_MONEY_PATTERN += 20", () => {
|
|
const result = computeScore(
|
|
"info@example.com",
|
|
null,
|
|
"100€ Willkommensbonus jetzt sichern",
|
|
false,
|
|
false,
|
|
);
|
|
expect(result.styleFlags).toContain("money-pattern");
|
|
expect(result.score).toBeGreaterThanOrEqual(20);
|
|
});
|
|
|
|
it("Brand-Match ohne Random → BRAND_MATCH_NO_RANDOM += 35", () => {
|
|
const result = computeScore(
|
|
"info@example.com",
|
|
null,
|
|
"Normaler Betreff",
|
|
true, // brandMatch=true
|
|
false, // randomTokens=false
|
|
);
|
|
expect(result.score).toBeGreaterThanOrEqual(35);
|
|
});
|
|
|
|
it("Random-Tokens ohne Brand → RANDOM_TOKENS_NO_BRAND += 10", () => {
|
|
const result = computeScore(
|
|
"info@example.com",
|
|
null,
|
|
"Newsletter vom Tag",
|
|
false, // brandMatch=false
|
|
true, // randomTokens=true
|
|
);
|
|
expect(result.score).toBeGreaterThanOrEqual(10);
|
|
});
|
|
|
|
it("Score wird auf max 100 gecapped", () => {
|
|
// Alle Signale gleichzeitig → Score würde > 100 sein
|
|
const result = computeScore(
|
|
"slots@casinobonus.bet",
|
|
"Casino Jackpot",
|
|
"JACKPOT Casino 500€ Freispiele Nur heute Letzte chance",
|
|
true,
|
|
true,
|
|
);
|
|
expect(result.score).toBeLessThanOrEqual(100);
|
|
});
|
|
});
|
|
|
|
// ─── classifyMail() — Pipeline End-to-End ────────────────────────────────────
|
|
|
|
describe("classifyMail() — End-to-End Pipeline", () => {
|
|
// Leere Domain-Set für die meisten Tests (kein Domain-Hard-Block)
|
|
const emptyDomainSet = new Set<string>();
|
|
|
|
// ─── Screenshot-Beispiel 1: Gamblezen via Relay ───────────────────────────
|
|
it("Gamblezen-Beispiel: bounces+user=gamblezen.com@em.sendgrid.net → Layer 2.5 Hard-Block", async () => {
|
|
// Gamblezen leitet über SendGrid-Bounces: Domain "em.sendgrid.net" ist nicht geblockt,
|
|
// aber relay-decoded → "gamblezen.com" + local-part hat random tokens.
|
|
// gamblezen.com ist ein bekannter Gambling-Brand.
|
|
const domainSetWithGamblezen = new Set(["gamblezen.com"]);
|
|
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "bounces+user=gamblezen.com@em.sendgrid.net",
|
|
senderName: "Gamble Zen",
|
|
subject: "Dein exklusives Angebot wartet",
|
|
},
|
|
blockedDomainSet: domainSetWithGamblezen,
|
|
});
|
|
|
|
// Relay-decoded domain matcht blocklist → Layer 2 (relay-decoded), NICHT Layer 2.5
|
|
expect(result.action).toBe("blocked");
|
|
expect(result.triggerSource).toBe("relay-decoded");
|
|
expect(result.relayDecodedDomain).toBe("gamblezen.com");
|
|
});
|
|
|
|
it("Gamblezen-Beispiel ohne Blocklist-Entry → kein Layer-2.5-Block (v1.0: kein Display-Name Brand-Match), Score-Path", async () => {
|
|
// v1.0: Display-Name "Gamble Zen" liefert keinen Brand-Match mehr.
|
|
// Domain em.sendgrid.net enthält kein Gambling-Keyword → kein Domain-Score.
|
|
// Subject "Dein exklusives Angebot wartet" enthält kein Keyword → Score=0 → PASS.
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "hq3a91_7xmpl2@em.sendgrid.net",
|
|
senderName: "Gamble Zen",
|
|
subject: "Dein exklusives Angebot wartet",
|
|
},
|
|
blockedDomainSet: emptyDomainSet,
|
|
});
|
|
|
|
// Kein Brand+Random mehr (Display-Name ist nicht mehr Brand-Source)
|
|
expect(result.triggerSource).not.toBe("brand+random");
|
|
expect(result.features.brandMatch).toBe(false);
|
|
// Random-Tokens sind noch erkannt (Local-Part hq3a91_7xmpl2)
|
|
expect(result.features.randomTokens).toBe(true);
|
|
// Score gering — kein Keyword im Subject/Domain → PASS
|
|
expect(result.action).toBe("passed");
|
|
});
|
|
|
|
// ─── Screenshot-Beispiel 2: BetandPlay via Relay ─────────────────────────
|
|
it("BetandPlay-Beispiel: track.user=betandplay.com@mailchimp.com → Layer 2.5 Hard-Block", async () => {
|
|
const domainSetWithBetandPlay = new Set(["betandplay.com"]);
|
|
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "track.user=betandplay.com@mailchimp.com",
|
|
senderName: "BetandPlay",
|
|
subject: "100€ Willkommensbonus — Nur heute!",
|
|
},
|
|
blockedDomainSet: domainSetWithBetandPlay,
|
|
});
|
|
|
|
expect(result.action).toBe("blocked");
|
|
expect(result.triggerSource).toBe("relay-decoded");
|
|
expect(result.relayDecodedDomain).toBe("betandplay.com");
|
|
});
|
|
|
|
it("BetandPlay-Beispiel ohne Blocklist-Entry → kein Layer-2.5-Block (v1.0: kein Display-Name Brand-Match), Score via Subject+Money", async () => {
|
|
// v1.0: Display-Name "BetandPlay" liefert keinen Brand-Match mehr.
|
|
// mailchimp.com enthält kein Gambling-Keyword.
|
|
// Subject "100€ Willkommensbonus" hat kein GAMBLING_KEYWORDS-Treffer (kein "casino", "bonus code" etc.)
|
|
// aber Geld-Pattern (100€) → +20. Random-Tokens → +10 (RANDOM_TOKENS_NO_BRAND).
|
|
// Score = 30 → < 50 → PASS.
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "u7a2b1_offers_ref9x2z@mailchimp.com",
|
|
senderName: "BetandPlay",
|
|
subject: "100€ Willkommensbonus",
|
|
},
|
|
blockedDomainSet: emptyDomainSet,
|
|
});
|
|
|
|
// Kein Brand+Random-Hard-Block (Display-Name ist v1.0 nicht Brand-Source)
|
|
expect(result.triggerSource).not.toBe("brand+random");
|
|
expect(result.features.brandMatch).toBe(false);
|
|
expect(result.features.randomTokens).toBe(true);
|
|
// Score: 20 (money) + 10 (random-no-brand) = 30 → PASS (< 50)
|
|
expect(result.score).toBe(30);
|
|
expect(result.action).toBe("passed");
|
|
});
|
|
|
|
// ─── Layer 1: Whitelist ───────────────────────────────────────────────────
|
|
it("Whitelist-Treffer: 'wettervorhersage' im Betreff → PASS", async () => {
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "service@wetter.de",
|
|
senderName: "Wetter.de",
|
|
subject: "Wettervorhersage für morgen",
|
|
},
|
|
blockedDomainSet: emptyDomainSet,
|
|
});
|
|
|
|
expect(result.action).toBe("passed");
|
|
expect(result.triggerSource).toBe("whitelist");
|
|
});
|
|
|
|
it("'wettkampf' in Betreff → PASS (kein Gambling trotz 'wette')", async () => {
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "info@sport.de",
|
|
senderName: null,
|
|
subject: "Wettkampf-Ergebnisse dieser Woche",
|
|
},
|
|
blockedDomainSet: emptyDomainSet,
|
|
});
|
|
|
|
expect(result.action).toBe("passed");
|
|
expect(result.triggerSource).toBe("whitelist");
|
|
});
|
|
|
|
// ─── Layer 2: Domain-Hard-Block ───────────────────────────────────────────
|
|
it("Domain in Blocklist → Layer 2 Hard-Block", async () => {
|
|
const domainSet = new Set(["casinoly.com"]);
|
|
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "promo@casinoly.com",
|
|
senderName: "Casinoly",
|
|
subject: "Dein Bonus wartet",
|
|
},
|
|
blockedDomainSet: domainSet,
|
|
});
|
|
|
|
expect(result.action).toBe("blocked");
|
|
expect(result.triggerSource).toBe("domain");
|
|
expect(result.features.domainBlocked).toBe(true);
|
|
});
|
|
|
|
// ─── Relay-Decoded Block ──────────────────────────────────────────────────
|
|
it("Relay-Decoded: =domain.com in local-part und Domain in Blocklist → relay-decoded Block", async () => {
|
|
const domainSet = new Set(["rabona.com"]);
|
|
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "bounce+track=rabona.com@em.sendgrid.net",
|
|
senderName: "Rabona Casino",
|
|
subject: "Exklusiv für dich",
|
|
},
|
|
blockedDomainSet: domainSet,
|
|
});
|
|
|
|
expect(result.action).toBe("blocked");
|
|
expect(result.triggerSource).toBe("relay-decoded");
|
|
expect(result.relayDecodedDomain).toBe("rabona.com");
|
|
});
|
|
|
|
// ─── Layer 3: Score-Hard-Block ────────────────────────────────────────────
|
|
it("Viele Signale → Score >= 80 → Hard-Block (ohne Display-Name-Beitrag)", async () => {
|
|
// Domain-Keyword ("casino" in spinz-casino.example) → +40
|
|
// Subject-Keyword ("jackpot") → +50
|
|
// Geld-Pattern (500€) → +20
|
|
// Urgency ("Nur heute") → +15
|
|
// ALL_CAPS ("JACKPOT") → +5
|
|
// Gesamt: 130 → gecapped auf 100. Display-Name spielt keine Rolle.
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "info@spinz-casino.example",
|
|
senderName: "Casino Jackpot Club",
|
|
subject: "JACKPOT 500€ Freispiele — Nur heute!",
|
|
},
|
|
blockedDomainSet: emptyDomainSet,
|
|
});
|
|
|
|
expect(result.action).toBe("blocked");
|
|
expect(result.triggerSource).toMatch(/^score:/);
|
|
expect(result.score).toBeGreaterThanOrEqual(80);
|
|
});
|
|
|
|
// ─── No-Signal → PASS ────────────────────────────────────────────────────
|
|
it("unauffällige Mail → PASS mit triggerSource 'no-signal'", async () => {
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "newsletter@amazon.de",
|
|
senderName: "Amazon",
|
|
subject: "Deine Bestellung wurde versandt",
|
|
},
|
|
blockedDomainSet: emptyDomainSet,
|
|
});
|
|
|
|
expect(result.action).toBe("passed");
|
|
expect(result.triggerSource).toBe("no-signal");
|
|
expect(result.score).toBeLessThan(25);
|
|
});
|
|
|
|
// ─── Brand-Match ohne Random → kein Hard-Block, Score-Erhöhung ───────────
|
|
it("Brand-Match ohne Random-Tokens → kein Layer-2.5-Block, aber Score-Erhöhung", async () => {
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "info@betandplay.com", // direktes info@, kein random
|
|
senderName: "BetandPlay",
|
|
subject: "Willkommen",
|
|
},
|
|
blockedDomainSet: emptyDomainSet,
|
|
});
|
|
|
|
// Kein Hard-Block Layer 2.5 (kein Random), aber Score erhöht durch Brand-Match
|
|
expect(result.triggerSource).not.toBe("brand+random");
|
|
expect(result.features.brandMatch).toBe(true);
|
|
expect(result.features.randomTokens).toBe(false);
|
|
// Score >= 35 (BRAND_MATCH_NO_RANDOM) — endet je nach anderen Signalen
|
|
expect(result.features.score).toBeGreaterThanOrEqual(35);
|
|
});
|
|
|
|
// ─── Korrekte Feature-Struktur im Result ─────────────────────────────────
|
|
it("Result-Features enthalten alle erwarteten Keys", async () => {
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "promo@example.com",
|
|
senderName: null,
|
|
subject: "Test",
|
|
},
|
|
blockedDomainSet: emptyDomainSet,
|
|
});
|
|
|
|
expect(result.features).toHaveProperty("score");
|
|
expect(result.features).toHaveProperty("domainBlocked");
|
|
expect(result.features).toHaveProperty("relayDecoded");
|
|
expect(result.features).toHaveProperty("brandMatch");
|
|
expect(result.features).toHaveProperty("randomTokens");
|
|
expect(result.features).toHaveProperty("keywordHitsSubject");
|
|
expect(result.features).toHaveProperty("keywordHitsDomain");
|
|
expect(result.features).toHaveProperty("keywordHitsName");
|
|
expect(result.features).toHaveProperty("styleFlags");
|
|
expect(result.features).toHaveProperty("whitelistHit");
|
|
});
|
|
|
|
// ─── Fix 2: SUBJECT_GAMBLING_KEYWORD angehoben auf 50 ────────────────────
|
|
it("Fix 2: 'Casino Bonus' im Betreff, generischer Sender → Score=50 → BLOCK (war vorher PASS)", async () => {
|
|
// Vorher: SUBJECT_GAMBLING_KEYWORD=35 → Score 35 < SCORE_BLOCK_MIDRANGE=50 → PASS
|
|
// Jetzt: SUBJECT_GAMBLING_KEYWORD=50 → Score 50 >= 50 → BLOCK
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "info@example.com",
|
|
senderName: null,
|
|
subject: "Casino Bonus",
|
|
},
|
|
blockedDomainSet: emptyDomainSet,
|
|
});
|
|
expect(result.action).toBe("blocked");
|
|
expect(result.triggerSource).toMatch(/^score:/);
|
|
expect(result.score).toBe(50);
|
|
expect(result.features.keywordHitsSubject).toContain("casino");
|
|
});
|
|
|
|
it("Fix 2: 'Hotel Las Vegas' im Betreff → kein Casino-Keyword → PASS", async () => {
|
|
// 'Las Vegas' enthält nicht 'casino' als Standalone-Wort — kein Keyword-Hit
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "buchung@hotel-example.com",
|
|
senderName: "Hotel Example",
|
|
subject: "Ihre Buchung Hotel Las Vegas",
|
|
},
|
|
blockedDomainSet: emptyDomainSet,
|
|
});
|
|
expect(result.action).toBe("passed");
|
|
expect(result.features.keywordHitsSubject).toHaveLength(0);
|
|
});
|
|
|
|
// ─── Fix: "spins" + Prozent-Pattern (Steffanie-Heier-Fall) ──────────────────
|
|
|
|
it("Steffanie-Heier-Fall: 'Fettes Angebot: Spins + 400% Bonus' → BLOCK (score=60)", async () => {
|
|
// hotmail.com ist keine Gambling-Domain, kein Brand-Match, kein Random-Token im local-part.
|
|
// Vor Fix: Score 0 → PASS.
|
|
// Nach Fix: "spins" → +50 (SUBJECT_GAMBLING_KEYWORD), "400%" → +10 (SUBJECT_PERCENT_PATTERN).
|
|
// Score = 60 >= SCORE_BLOCK_MIDRANGE (50) → BLOCK.
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "xpslyjzbt6630@hotmail.com",
|
|
senderName: "Steffanie Heier",
|
|
subject: "Fettes Angebot: Spins + 400% Bonus",
|
|
},
|
|
blockedDomainSet: new Set<string>(),
|
|
});
|
|
expect(result.action).toBe("blocked");
|
|
expect(result.triggerSource).toMatch(/^score:/);
|
|
expect(result.score).toBe(60);
|
|
expect(result.features.keywordHitsSubject).toContain("spins");
|
|
expect(result.features.styleFlags).toContain("percent-pattern");
|
|
});
|
|
|
|
it("FP-Guard: '10% Rabatt auf deine Bestellung' ohne Gambling-Keyword → PASS", async () => {
|
|
// Prozent-Pattern allein: +10 Punkte < SCORE_PASS_BELOW (25) → triggerSource 'no-signal' → PASS.
|
|
// Sicherstellt dass legitime Rabatt-Mails nicht durch das %-Pattern geblockt werden.
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "newsletter@shop-example.de",
|
|
senderName: "Mein Shop",
|
|
subject: "10% Rabatt auf deine Bestellung",
|
|
},
|
|
blockedDomainSet: new Set<string>(),
|
|
});
|
|
expect(result.action).toBe("passed");
|
|
expect(result.triggerSource).toBe("no-signal");
|
|
expect(result.score).toBe(10);
|
|
expect(result.features.styleFlags).toContain("percent-pattern");
|
|
expect(result.features.keywordHitsSubject).toHaveLength(0);
|
|
});
|
|
|
|
it("FP-Guard: 'spins' in Recovery-Kontext ('Spielsucht') → PASS via Whitelist", async () => {
|
|
// "spielsucht" ist in GAMBLING_WHITELIST → Layer 1 greift, bevor 'spins' gewertet wird.
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "info@spielsucht-hilfe.de",
|
|
senderName: "Spielsucht-Hilfe",
|
|
subject: "Weg von Spins und Jackpots — Hilfe bei Spielsucht",
|
|
},
|
|
blockedDomainSet: new Set<string>(),
|
|
});
|
|
expect(result.action).toBe("passed");
|
|
expect(result.triggerSource).toBe("whitelist");
|
|
});
|
|
|
|
// ─── v1.0: Display-Name-only Signale → kein Score-Beitrag ────────────────
|
|
|
|
it("v1.0: Subject leer + Display-Name 'Casino Bonus' + generische Domain → Score=0 → PASS", async () => {
|
|
// Display-Name hat Gambling-Keyword, aber v1.0 wertet das nicht aus.
|
|
// Kein Subject-Keyword, keine Gambling-Domain → Score=0 → PASS.
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "info@example.com",
|
|
senderName: "Casino Bonus",
|
|
subject: "",
|
|
},
|
|
blockedDomainSet: emptyDomainSet,
|
|
});
|
|
expect(result.action).toBe("passed");
|
|
expect(result.score).toBe(0);
|
|
expect(result.features.keywordHitsName).toHaveLength(0);
|
|
expect(result.triggerSource).toBe("no-signal");
|
|
});
|
|
|
|
it("v1.0: Subject 'Hotel Las Vegas' + Display-Name 'Casino Royale' + generische Domain → Score=0 → PASS", async () => {
|
|
// Weder Subject noch Domain enthält einen GAMBLING_KEYWORDS-Treffer.
|
|
// Display-Name "Casino Royale" hat zwar 'casino', zählt aber v1.0 nicht.
|
|
const result = await classifyMail({
|
|
mail: {
|
|
senderEmail: "info@hotel-example.com",
|
|
senderName: "Casino Royale",
|
|
subject: "Hotel Las Vegas",
|
|
},
|
|
blockedDomainSet: emptyDomainSet,
|
|
});
|
|
expect(result.action).toBe("passed");
|
|
expect(result.score).toBe(0);
|
|
expect(result.features.keywordHitsName).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ─── 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,
|
|
// da ein vollständiger IMAP-Mock außerhalb des Scope dieser Test-Suite liegt.
|
|
describe("Fix 1: System-Folder specialUse-Filter-Regex", () => {
|
|
// Repliziert die SKIP_SPECIAL_USE-Konstante aus scan-internal.post.ts
|
|
const SKIP_SPECIAL_USE = /^\\(All|Drafts|Sent|Trash|Flagged)$/;
|
|
|
|
type MockMailbox = { path: string; specialUse?: string; flags?: Set<string> };
|
|
|
|
function filterScannable(mailboxes: MockMailbox[]): MockMailbox[] {
|
|
return mailboxes.filter((mb) => {
|
|
if (mb.flags?.has("\\Noselect")) return false;
|
|
if (mb.specialUse && SKIP_SPECIAL_USE.test(mb.specialUse)) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
it("Gmail All Mail (specialUse='\\\\All') wird ausgeschlossen", () => {
|
|
const mailboxes: MockMailbox[] = [
|
|
{ path: "INBOX", specialUse: "\\Inbox" },
|
|
{ path: "[Gmail]/All Mail", specialUse: "\\All" },
|
|
{ path: "[Gmail]/Spam", specialUse: "\\Junk" },
|
|
];
|
|
const result = filterScannable(mailboxes);
|
|
expect(result.map((m) => m.path)).toEqual(["INBOX", "[Gmail]/Spam"]);
|
|
expect(result.map((m) => m.path)).not.toContain("[Gmail]/All Mail");
|
|
});
|
|
|
|
it("Drafts, Sent, Trash, Flagged werden ausgeschlossen", () => {
|
|
const mailboxes: MockMailbox[] = [
|
|
{ path: "INBOX" },
|
|
{ path: "Drafts", specialUse: "\\Drafts" },
|
|
{ path: "Sent", specialUse: "\\Sent" },
|
|
{ path: "Trash", specialUse: "\\Trash" },
|
|
{ path: "Starred", specialUse: "\\Flagged" },
|
|
{ path: "Spam", specialUse: "\\Junk" },
|
|
];
|
|
const result = filterScannable(mailboxes);
|
|
const paths = result.map((m) => m.path);
|
|
expect(paths).toContain("INBOX");
|
|
expect(paths).toContain("Spam");
|
|
expect(paths).not.toContain("Drafts");
|
|
expect(paths).not.toContain("Sent");
|
|
expect(paths).not.toContain("Trash");
|
|
expect(paths).not.toContain("Starred");
|
|
});
|
|
|
|
it("Folder ohne specialUse (iCloud/GMX) werden NICHT ausgeschlossen", () => {
|
|
// iCloud/GMX liefern kein specialUse-Field — der Filter lässt sie durch
|
|
const mailboxes: MockMailbox[] = [
|
|
{ path: "INBOX" },
|
|
{ path: "Junk" }, // kein specialUse → bleibt drin (wollen wir)
|
|
{ path: "Sent Items" }, // kein specialUse → bleibt drin (suboptimal aber sicher)
|
|
];
|
|
const result = filterScannable(mailboxes);
|
|
// Alle 3 bleiben — kein false positive ohne specialUse-Info
|
|
expect(result).toHaveLength(3);
|
|
});
|
|
|
|
it("Noselect-Folder wird immer ausgeschlossen (unabhängig von specialUse)", () => {
|
|
const mailboxes: MockMailbox[] = [
|
|
{ path: "INBOX" },
|
|
{ path: "[Gmail]", flags: new Set(["\\Noselect"]) },
|
|
];
|
|
const result = filterScannable(mailboxes);
|
|
expect(result.map((m) => m.path)).toEqual(["INBOX"]);
|
|
});
|
|
});
|