/** * 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.5 Hard-Block * - BetandPlay-Beispiel → Layer 2.5 Hard-Block (Apple Hide-My-Email-Pattern) * - Whitelist-Case (wettervorhersage) * - Domain-Block (Layer 2) * - Relay-Decoded Block (Layer 2) * - No-Signal → PASS */ 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", "wette", "stake", "rolletto", "vbet", "1xbet", "melbet", "mostbet", "luckyvibe", "spinz", "casinoly", "rabona", "justcasino", "getslots", "rocketplay", "freshcasino", "betano", "leovegas", ], GAMBLING_WHITELIST: [ "wettervorhersage", "wetter", "wetterbericht", "wettkampf", "wettbewerb", ], })); 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 += 35", () => { const result = computeScore( "info@example.com", null, "Dein Casino-Bonus wartet", false, false, ); expect(result.keywordHitsSubject).toContain("casino"); expect(result.score).toBeGreaterThanOrEqual(35); }); 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(); // ─── 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 → Layer 2.5 Hard-Block via Brand+Random", async () => { // Wenn gamblezen.com NICHT in der Blocklist ist: Brand+Random greift trotzdem const result = await classifyMail({ mail: { senderEmail: "hq3a91_7xmpl2@em.sendgrid.net", senderName: "Gamble Zen", // Brand-Match via Display-Name subject: "Dein exklusives Angebot wartet", }, blockedDomainSet: emptyDomainSet, }); expect(result.action).toBe("blocked"); expect(result.triggerSource).toBe("brand+random"); expect(result.features.brandMatch).toBe(true); expect(result.features.randomTokens).toBe(true); }); // ─── 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 → Layer 2.5 Hard-Block via Brand+Random", async () => { const result = await classifyMail({ mail: { senderEmail: "u7a2b1_offers_ref9x2z@mailchimp.com", senderName: "BetandPlay", // Brand-Match via Display-Name subject: "100€ Willkommensbonus", }, blockedDomainSet: emptyDomainSet, }); expect(result.action).toBe("blocked"); expect(result.triggerSource).toBe("brand+random"); expect(result.features.brandMatch).toBe(true); expect(result.features.randomTokens).toBe(true); }); // ─── 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", async () => { // Casino im Sender-Name + Jackpot im Betreff + Urgency + Geld-Pattern 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"); }); });