diff --git a/backend/server/api/curated-domains/suggest.post.ts b/backend/server/api/curated-domains/suggest.post.ts index a65c297..f24507f 100644 --- a/backend/server/api/curated-domains/suggest.post.ts +++ b/backend/server/api/curated-domains/suggest.post.ts @@ -1,4 +1,5 @@ import { usePrisma } from "../../utils/prisma"; +import { isPublicEmailDomain } from "../../utils/public-email-domains"; // Domain muss mindestens eine TLD haben (z.B. "mbet216.com"). const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; @@ -32,6 +33,10 @@ export default defineEventHandler(async (event) => { if (!domain || domain.length > 253 || !DOMAIN_RE.test(domain)) { throw createError({ statusCode: 400, data: { error: "INVALID_DOMAIN" } }); } + // Public-/Freemail-Domains nie in die kuratierte VIP-Liste (icloud.com etc.). + if (isPublicEmailDomain(domain)) { + throw createError({ statusCode: 400, data: { error: "PUBLIC_DOMAIN" } }); + } if (!VALID_COUNTRIES.includes(country)) { throw createError({ statusCode: 400, data: { error: "INVALID_COUNTRY" } }); } diff --git a/backend/server/api/custom-domains/index.post.ts b/backend/server/api/custom-domains/index.post.ts index c0a3c33..c5d4e3b 100644 --- a/backend/server/api/custom-domains/index.post.ts +++ b/backend/server/api/custom-domains/index.post.ts @@ -8,6 +8,7 @@ import { import { getProfile } from "../../db/profile"; import { getPlanLimits } from "../../utils/plan-features"; import { usePrisma } from "../../utils/prisma"; +import { isPublicEmailDomain } from "../../utils/public-email-domains"; // Regex: Domain muss mindestens eine TLD haben (z.B. "casino.de", "x.co.uk") const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; @@ -168,6 +169,21 @@ export default defineEventHandler(async (event) => { }); } + // Public-/Freemail-Domains (icloud.com, gmail.com …) hart ablehnen — web UND + // mail. Realer Vorfall: User kopiert eine Casino-Spam-Adresse `xyz@icloud.com` + // komplett ins Feld → wir extrahierten `icloud.com`. Das zu blocken würde die + // gesamte Mail/Webmail des Users sperren. Siehe public-email-domains.ts. + if (isPublicEmailDomain(value)) { + throw createError({ + statusCode: 400, + data: { + error: "PUBLIC_DOMAIN", + message: + "Das ist ein öffentlicher E-Mail-Anbieter — den können wir nicht blocken, sonst wäre deine ganze Mail betroffen. Blocke stattdessen die konkrete Casino-Domain aus dem Link der Mail.", + }, + }); + } + // Ist die Domain schon in der globalen Layer-1-Blocklist? const db = usePrisma(); const globalMatch = await db.blocklistDomain.findFirst({ diff --git a/backend/server/api/custom-domains/suggest.post.ts b/backend/server/api/custom-domains/suggest.post.ts index 61ec614..29f8846 100644 --- a/backend/server/api/custom-domains/suggest.post.ts +++ b/backend/server/api/custom-domains/suggest.post.ts @@ -1,5 +1,6 @@ import { usePrisma } from "../../utils/prisma"; import { suggestCuratedDomain } from "../../db/curatedDomains"; +import { isPublicEmailDomain } from "../../utils/public-email-domains"; // Unterstützte Ländercodes für Layer-2-Listen const SUPPORTED_COUNTRIES = ["DE", "GB", "FR", "TN"] as const; @@ -33,6 +34,15 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, data: { error: "INVALID_DOMAIN" } }); } + // Public-/Freemail-Domains dürfen NIE in die kuratierte Country-Liste — + // sonst landet z.B. icloud.com global (genau der gemeldete Vorfall). + if (isPublicEmailDomain(rawDomain)) { + throw createError({ + statusCode: 400, + data: { error: "PUBLIC_DOMAIN" }, + }); + } + if (!(SUPPORTED_COUNTRIES as readonly string[]).includes(rawCountry)) { throw createError({ statusCode: 400, diff --git a/backend/server/utils/gambling-keywords.mjs b/backend/server/utils/gambling-keywords.mjs index 4f60060..7ed7041 100644 --- a/backend/server/utils/gambling-keywords.mjs +++ b/backend/server/utils/gambling-keywords.mjs @@ -22,7 +22,7 @@ export const GAMBLING_KEYWORDS = [ // Generic Begriffe "sportwetten", "jackpot", "freispiel", "free spin", "bonus code", - "auszahlung", "glücksspiel", "slots", "roulette", + "auszahlung", "glücksspiel", "slots", "roulette", "spins", // ⚠️ Risk: matcht auch unschuldige Wörter (Mo's Finding #5) // TODO Whitelist: "wette" matcht "wettervorhersage" → False-Positive diff --git a/backend/server/utils/mail-classifier.ts b/backend/server/utils/mail-classifier.ts index c8ac311..b101715 100644 --- a/backend/server/utils/mail-classifier.ts +++ b/backend/server/utils/mail-classifier.ts @@ -77,6 +77,7 @@ export const SCORE_WEIGHTS = { // Subject-Indikatoren SUBJECT_GAMBLING_KEYWORD: 50, // Keyword im Betreff (casino, jackpot, freispiel …) SUBJECT_MONEY_PATTERN: 20, // €/$ + Zahl (z.B. "100€ Bonus") + SUBJECT_PERCENT_PATTERN: 10, // Zahl+% im Betreff (z.B. "400% Bonus") — allein nie Block-fähig SUBJECT_URGENCY: 15, // "Nur heute", "Letzte Chance", "Ablaufdatum" SUBJECT_ALL_CAPS_WORD: 5, // EINZELNES ALL-CAPS-WORT im Betreff @@ -277,6 +278,15 @@ export function computeScore( score += SCORE_WEIGHTS.SUBJECT_MONEY_PATTERN; } + // ── Prozent-Pattern im Betreff (z.B. "400% Bonus", "200% Einzahlung") ── + // Separates Flag mit reduziertem Score: % allein (10 Punkte) reicht nie + // für einen Block (Threshold 50) — verhindert FP bei "10% Rabatt"-Mails. + // Ergänzt das Currency-Pattern oben, das nur € / $ / £ kennt. + if (/\d\s*%/.test(subject)) { + styleFlags.push("percent-pattern"); + score += SCORE_WEIGHTS.SUBJECT_PERCENT_PATTERN; + } + // ── Urgency-Wörter im Betreff ── const URGENCY_PATTERNS = [ "nur heute", "letzte chance", "läuft ab", "ablaufdatum", diff --git a/backend/server/utils/public-email-domains.ts b/backend/server/utils/public-email-domains.ts new file mode 100644 index 0000000..db29c9d --- /dev/null +++ b/backend/server/utils/public-email-domains.ts @@ -0,0 +1,53 @@ +/** + * Public-/Freemail-Provider-Domains. + * + * Diese Domains dürfen NIEMALS als Custom-Domain (web ODER mail) geblockt oder + * in die kuratierte Country-Liste vorgeschlagen werden: + * - web: icloud.com/gmail.com blocken würde die Webmail des Users sperren + * - mail: würde JEDE Mail von diesem Provider löschen (Kontakte, Banken, alles) + * + * Realer Vorfall: User hat eine Casino-Spam-Mail von `xyz@icloud.com` geöffnet, + * die komplette Adresse kopiert + ins Add-Feld gepackt → wir extrahierten + * `icloud.com` und hätten es geblockt/veröffentlicht. Das muss hart verhindert + * werden — auf JEDER Ebene (Frontend-Hinweis + Backend-Reject). + * + * Spiegel-Liste im Frontend: `apps/rebreak-native/hooks/useCustomDomains.ts` + * (PUBLIC_EMAIL_DOMAINS) — bei Änderungen beide synchron halten. + */ +export const PUBLIC_EMAIL_DOMAINS: ReadonlySet = new Set([ + // Google + "gmail.com", "googlemail.com", + // Apple + "icloud.com", "me.com", "mac.com", + // Microsoft + "outlook.com", "outlook.de", "hotmail.com", "hotmail.de", "hotmail.co.uk", + "hotmail.fr", "live.com", "live.de", "msn.com", + // Yahoo + "yahoo.com", "yahoo.de", "yahoo.co.uk", "yahoo.fr", "ymail.com", "rocketmail.com", + // GMX / United Internet (DACH) + "gmx.de", "gmx.net", "gmx.at", "gmx.ch", "gmx.com", "web.de", + // AOL + "aol.com", "aim.com", + // Proton / Tutanota / Posteo / Mailbox (privacy) + "proton.me", "protonmail.com", "pm.me", "tutanota.com", "tutanota.de", + "tuta.io", "posteo.de", "posteo.net", "mailbox.org", "hey.com", + // Deutsche ISP-Freemail + "t-online.de", "freenet.de", "arcor.de", + // Generische Freemail + "mail.com", "mail.de", "email.de", "zoho.com", "fastmail.com", "fastmail.fm", + "hushmail.com", + // Yandex / Mail.ru + "yandex.com", "yandex.ru", "mail.ru", + // Frankreich (FR-Markt) + "laposte.net", "orange.fr", "free.fr", "sfr.fr", "wanadoo.fr", + // Asien + "qq.com", "163.com", "126.com", "naver.com", "daum.net", +]); + +/** + * True wenn die (bereits normalisierte, lowercase) Domain ein + * Public-/Freemail-Provider ist und daher nicht blockbar/vorschlagbar. + */ +export function isPublicEmailDomain(domain: string): boolean { + return PUBLIC_EMAIL_DOMAINS.has(domain.trim().toLowerCase()); +} diff --git a/backend/tests/mail/mail-classifier.test.ts b/backend/tests/mail/mail-classifier.test.ts index 950a32a..ee2c93e 100644 --- a/backend/tests/mail/mail-classifier.test.ts +++ b/backend/tests/mail/mail-classifier.test.ts @@ -26,7 +26,7 @@ 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", + "auszahlung", "glücksspiel", "slots", "roulette", "spins", "wette", "stake", "rolletto", "vbet", "1xbet", "melbet", "mostbet", "luckyvibe", "spinz", "casinoly", "rabona", "justcasino", "getslots", "rocketplay", "freshcasino", "betano", "leovegas", @@ -531,6 +531,60 @@ describe("classifyMail() — End-to-End Pipeline", () => { 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(), + }); + 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(), + }); + 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(), + }); + 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 () => {