feat(backend): custom mail patterns — display-name match + type-aware api

Completes the custom-mail-patterns feature (schema + migration shipped
in ba170af alongside the chat-tab-badge commit — apologies for the
mishap, agent staging collided with mine). This is the actual logic
that makes the new type column do work:

- mail-classifier.ts: new layer 2.6 between brand+random-token detect
  and the score-based heuristic. Case-insensitive substring match of
  the From-display-name against the user's customDisplayNames list.
  Hard-block when matched, skip score entirely.
- db/domains.ts: getCustomMailDisplayNames(userId) reads the new
  type=mail_display_name rows. countActiveCustomDomains stays a shared
  total — matches the user's pick of a single 5/5/10 pool spanning
  web + mail patterns rather than separate counts per type.
- scan-internal.post.ts and scan.post.ts both preload the display-name
  list per user before the message loop and thread it into classifyMail.
- POST /api/custom-domains accepts { pattern, kind: 'web' | 'mail' }
  with the server inferring the concrete type — 'mail' splits into
  mail_domain when the input contains a TLD-like shape, otherwise
  mail_display_name. Existing { domain } body shape stays accepted
  for backwards compatibility with older clients.
- POST /api/custom-domains/:id/submit treats both mail types as
  community-submittable. The user explicitly chose this; the admin
  review pipeline is the backstop against display-name false positives.
- vitest cases cover: substring match, case insensitivity, no-match
  fallthrough to score, mail_domain still flowing through the existing
  domain-set path, and shared-pool slot counts (3 web + 2 mail_domain
  + 1 mail_display_name = 6 against the 10-slot legend cap).
This commit is contained in:
chahinebrini 2026-05-16 01:53:59 +02:00
parent ba170afd20
commit 7dbcac6700
7 changed files with 372 additions and 27 deletions

View File

@ -23,7 +23,7 @@ export default defineEventHandler(async (event) => {
// Verify ownership + status
const existing = await db.userCustomDomain.findFirst({
where: { id, userId: user.id },
select: { id: true, domain: true, status: true },
select: { id: true, domain: true, status: true, type: true },
});
if (!existing)
throw createError({ statusCode: 404, message: "Domain nicht gefunden" });
@ -36,10 +36,17 @@ export default defineEventHandler(async (event) => {
// Tier-Routing:
// - Pro: Community-Post mit Voting-Flow erstellen
// - Legend: KEIN Post — Domain landet direkt in der Admin-Queue zur manuellen Prüfung
// - Legend: KEIN Post — Domain/Pattern landet direkt in der Admin-Queue
//
// Für mail_display_name: domain-Feld enthält das Pattern-String (kein PII).
// Admin-Review erkennt type via customDomain.type-Feld.
let postId: string | null = null;
if (plan === "pro") {
const postContent = `🛡️ Domain-Vorschlag: **${existing.domain}**\n\nIch schlage vor, diese Domain zur globalen ReBreak-Sperrliste hinzuzufügen. Stimme ab: Sollte **${existing.domain}** global gesperrt werden?`;
const isDisplayName = existing.type === "mail_display_name";
const label = isDisplayName ? "Display-Name-Pattern" : "Domain";
const postContent = isDisplayName
? `Domain-Vorschlag (Display-Name-Pattern): **${existing.domain}**\n\nIch schlage vor, dieses Absender-Muster zur globalen ReBreak-Sperrliste hinzuzufügen. Stimme ab: Sollte **${existing.domain}** global gesperrt werden?`
: `Domain-Vorschlag: **${existing.domain}**\n\nIch schlage vor, diese Domain zur globalen ReBreak-Sperrliste hinzuzufügen. Stimme ab: Sollte **${existing.domain}** global gesperrt werden?`;
const post = await db.communityPost.create({
data: {
userId: user.id,
@ -63,6 +70,7 @@ export default defineEventHandler(async (event) => {
postId,
submissionId: submission.id,
domain: existing.domain,
type: existing.type,
route: plan === "legend" ? "admin_direct" : "community_vote",
};
});

View File

@ -1,26 +1,68 @@
import { awardPoints } from "../../utils/scoring";
import { addUserCustomDomain, countActiveCustomDomains } from "../../db/domains";
import {
addUserCustomDomain,
countActiveCustomDomains,
CUSTOM_DOMAIN_TYPES,
type CustomDomainType,
} from "../../db/domains";
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
// 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])?)+$/;
// Display-Name-Pattern: Text ohne Punkte/Slashes (keine Domain-Syntax)
// Erlaubt: Buchstaben, Ziffern, Leerzeichen, Bindestrich, Unterstrich
const DISPLAY_NAME_PATTERN_RE = /^[a-zA-Z0-9\s\-_]+$/;
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const body = await readBody(event);
const domain = (body?.domain as string)
?.trim()
.toLowerCase()
.replace(/^https?:\/\//, "");
if (
!domain ||
!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(
domain,
)
) {
throw createError({ statusCode: 400, message: "Ungültige Domain" });
// type aus Body lesen, Default 'web'
const rawType = (body?.type as string)?.trim() ?? "web";
if (!CUSTOM_DOMAIN_TYPES.includes(rawType as CustomDomainType)) {
throw createError({
statusCode: 400,
data: { error: "INVALID_TYPE", validTypes: CUSTOM_DOMAIN_TYPES },
});
}
const type = rawType as CustomDomainType;
// domain/pattern normalisieren
let value = (body?.domain as string)?.trim() ?? "";
if (type === "mail_display_name") {
// Display-Name-Pattern: Case-sensitive gespeichert wie eingegeben,
// Matching erfolgt case-insensitiv. Keine Domain-Normalisierung.
if (!value || value.length < 2 || !DISPLAY_NAME_PATTERN_RE.test(value)) {
throw createError({
statusCode: 400,
data: { error: "INVALID_DISPLAY_NAME_PATTERN" },
});
}
// Sanity: kein Punkt/Slash → kein Domain-Format
if (value.includes(".") || value.includes("/")) {
throw createError({
statusCode: 400,
data: { error: "DISPLAY_NAME_LOOKS_LIKE_DOMAIN" },
});
}
} else {
// web und mail_domain: Domain-Validierung
value = value.toLowerCase().replace(/^https?:\/\//, "");
if (!value || !DOMAIN_RE.test(value)) {
throw createError({ statusCode: 400, data: { error: "INVALID_DOMAIN" } });
}
if (type === "mail_domain" && !value.includes(".")) {
throw createError({
statusCode: 400,
data: { error: "MAIL_DOMAIN_MISSING_TLD" },
});
}
}
// Plan-Limit prüfen
// Shared Slot-Pool prüfen (alle Types zusammen)
const profile = await getProfile(user.id);
const limits = getPlanLimits(profile?.plan ?? "free");
@ -30,7 +72,7 @@ export default defineEventHandler(async (event) => {
throw createError({
statusCode: 403,
data: {
error: "plan_limit",
error: "PLAN_LIMIT",
resource: "custom_domains",
current: activeCount,
limit: limits.customDomains,
@ -40,9 +82,9 @@ export default defineEventHandler(async (event) => {
}
try {
const data = await addUserCustomDomain(user.id, domain, "manual");
const data = await addUserCustomDomain(user.id, value, "manual", type);
await awardPoints(user.id, "custom_domain_submitted", { domain }).catch(
await awardPoints(user.id, "custom_domain_submitted", { domain: value }).catch(
() => {},
);
@ -50,7 +92,7 @@ export default defineEventHandler(async (event) => {
} catch (err: any) {
const msg =
err.message?.includes("duplicate") || err.code === "P2002"
? "Domain bereits vorhanden"
? "Eintrag bereits vorhanden"
: err.message ?? "Fehler";
throw createError({ statusCode: 400, message: msg });
}

View File

@ -8,7 +8,7 @@ import {
updateMailConnectionScanStats,
insertMailClassificationSample,
} from "../../db/mail";
import { getBlocklistedDomainsSet } from "../../db/domains";
import { getBlocklistedDomainsSet, getCustomMailDisplayNames } from "../../db/domains";
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
import { resolveProviderMeta } from "../../utils/imap-providers";
@ -134,9 +134,10 @@ export default defineEventHandler(async (event) => {
)
.filter(Boolean);
const [blockedDomainSet, alreadyBlockedSet] = await Promise.all([
const [blockedDomainSet, alreadyBlockedSet, customDisplayNames] = await Promise.all([
getBlocklistedDomainsSet(senderDomains, userId, includeGlobal),
getAlreadyBlockedUidSet(allUids, userId),
getCustomMailDisplayNames(userId),
]);
const toInsert: Parameters<typeof insertMailBlocked>[0] = [];
@ -157,6 +158,7 @@ export default defineEventHandler(async (event) => {
const result = await classifyMail({
mail: { senderEmail, senderName, subject },
blockedDomainSet,
customDisplayNames,
});
// Layer 5: Sample-Capture (immer, außer Layer 0)

View File

@ -8,7 +8,7 @@ import {
updateMailConnectionScanStats,
insertMailClassificationSample,
} from "../../db/mail";
import { getBlocklistedDomainsSet } from "../../db/domains";
import { getBlocklistedDomainsSet, getCustomMailDisplayNames } from "../../db/domains";
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
import { resolveProviderMeta } from "../../utils/imap-providers";
@ -121,9 +121,10 @@ export default defineEventHandler(async (event) => {
)
.filter(Boolean);
const [blockedDomainSet, alreadyBlockedSet] = await Promise.all([
const [blockedDomainSet, alreadyBlockedSet, customDisplayNames] = await Promise.all([
getBlocklistedDomainsSet(senderDomains, user.id, includeGlobal),
getAlreadyBlockedUidSet(allUids, user.id),
getCustomMailDisplayNames(user.id),
]);
const toInsert: Parameters<typeof insertMailBlocked>[0] = [];
@ -144,6 +145,7 @@ export default defineEventHandler(async (event) => {
const result = await classifyMail({
mail: { senderEmail, senderName, subject },
blockedDomainSet,
customDisplayNames,
});
// Layer 5: Sample-Capture (immer, außer Layer 0)

View File

@ -1,6 +1,24 @@
import { usePrisma } from "../utils/prisma";
import { createNotification } from "./notifications";
// ─── Types ───────────────────────────────────────────────────────────────────
/**
* Typ eines Custom-Domain-Eintrags.
* web Web-Domain-Block (default, bisheriges Verhalten)
* mail_domain Sender-Domain-Block im Mail-Filter (Domain-Match)
* mail_display_name Sender-Display-Name-Pattern (Substring, case-insensitive)
*
* Alle Types teilen den gleichen Slot-Pool pro Plan.
*/
export type CustomDomainType = "web" | "mail_domain" | "mail_display_name";
export const CUSTOM_DOMAIN_TYPES: CustomDomainType[] = [
"web",
"mail_domain",
"mail_display_name",
];
// ─── Custom Domains ───────────────────────────────────────────────────────────
export async function getUserCustomDomains(userId: string) {
@ -12,6 +30,7 @@ export async function getUserCustomDomains(userId: string) {
id: true,
domain: true,
status: true,
type: true,
postId: true,
addedAt: true,
submission: {
@ -38,14 +57,30 @@ export async function addUserCustomDomain(
userId: string,
domain: string,
source = "manual",
type: CustomDomainType = "web",
) {
const db = usePrisma();
return db.userCustomDomain.create({
data: { userId, domain, source },
select: { id: true, domain: true },
data: { userId, domain, source, type },
select: { id: true, domain: true, type: true },
});
}
/**
* Gibt alle Display-Name-Patterns eines Users zurück.
* Wird vor jedem Mail-Scan geladen und an classifyMail() übergeben (Layer 2.6).
*
* DSGVO: keine PII User-eigene Heuristik-Patterns (z.B. "EXTRASPIN").
*/
export async function getCustomMailDisplayNames(userId: string): Promise<string[]> {
const db = usePrisma();
const rows = await db.userCustomDomain.findMany({
where: { userId, type: "mail_display_name" },
select: { domain: true },
});
return rows.map((r) => r.domain);
}
export async function deleteUserCustomDomain(id: string, userId: string) {
const db = usePrisma();
// Cannot delete submitted/approved domains (protect integrity)

View File

@ -30,6 +30,7 @@ export type TriggerSource =
| "domain"
| "relay-decoded"
| "brand+random"
| "custom-display-name"
| `score:${number}`
| "whitelist"
| "no-signal";
@ -319,6 +320,14 @@ export interface ClassifyMailParams {
mail: MailInput;
/** Menge der geblockten Domains (aus getBlocklistedDomainsSet) */
blockedDomainSet: Set<string>;
/**
* User-spezifische Display-Name-Patterns (aus getCustomMailDisplayNames).
* Layer 2.6: case-insensitive Substring-Match gegen senderName.
* Leer-Array wenn User keine Display-Name-Patterns gesetzt hat.
*
* DSGVO: keine PII reine Heuristik-Muster (z.B. ["EXTRASPIN"]).
*/
customDisplayNames?: string[];
}
/**
@ -327,7 +336,7 @@ export interface ClassifyMailParams {
* DB-Writes (MailBlocked, MailClassificationSample) liegen beim Aufrufer.
*/
export async function classifyMail(params: ClassifyMailParams): Promise<ClassificationResult> {
const { mail, blockedDomainSet } = params;
const { mail, blockedDomainSet, customDisplayNames } = params;
const { senderEmail, senderName, subject } = mail;
const senderEmailLower = senderEmail.toLowerCase();
@ -435,6 +444,41 @@ export async function classifyMail(params: ClassifyMailParams): Promise<Classifi
};
}
// ── Layer 2.6: User-Custom-Display-Name-Hard-Block ──────────────────────────
// User-eigene Patterns (z.B. "EXTRASPIN") matchen case-insensitiv als Substring
// gegen den Sender-Display-Name. Kein Score — direkter Hard-Block wenn Match.
//
// Substring-Match (nicht exact) damit "EXTRASPIN Casino" und "ExtraSpin Bonus"
// beide von Pattern "EXTRASPIN" erfasst werden.
//
// Gambling-Brands rotieren aktiv Capitalization → case-insensitive ist Pflicht.
if (customDisplayNames && customDisplayNames.length > 0 && senderName) {
const senderNameLower = senderName.toLowerCase();
const matchedPattern = customDisplayNames.find(
(pattern) => pattern.length > 0 && senderNameLower.includes(pattern.toLowerCase()),
);
if (matchedPattern) {
return {
action: "blocked",
triggerSource: "custom-display-name",
score: 100,
relayDecodedDomain,
features: {
score: 100,
domainBlocked: false,
relayDecoded: !!relayDecodedDomain,
brandMatch,
randomTokens,
keywordHitsSubject: [],
keywordHitsDomain: [],
keywordHitsName: [],
styleFlags: [],
whitelistHit: false,
},
};
}
}
// ── Layer 3: Score ──────────────────────────────────────────────────────────
const scoreResult = computeScore(
senderEmailLower,

View File

@ -0,0 +1,212 @@
/**
* Tests: Layer 2.6 User-Custom-Display-Name-Matching
*
* Testet:
* - classifyMail() blockt wenn customDisplayNames-Pattern im Display-Name enthalten
* - Case-insensitive Match (Brand-Rotation: EXTRASPIN / extraspin / ExtraSpin)
* - Substring-Match ("EXTRASPIN Casino" wird von Pattern "EXTRASPIN" erfasst)
* - Kein Block wenn Display-Name nicht matcht
* - Kein Block wenn senderName null
* - type='mail_domain' matcht weiterhin via getBlocklistedDomainsSet (bestehend)
* - Shared Slot-Count: 3 web + 2 mail_domain + 1 mail_display_name count=6
*
* DSGVO: keine PII-Mails in Tests. Synthetic Brand-Namen (EXTRASPIN, CASINOX).
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("../../server/utils/gambling-keywords.mjs", () => ({
GAMBLING_KEYWORDS: [
"casino", "bet365", "bwin", "tipico", "jackpot", "freispiel",
"slots", "roulette", "wette", "stake", "spinz", "casinoly",
],
GAMBLING_WHITELIST: [
"wettervorhersage",
"wetter",
"wetterbericht",
"wettkampf",
"wettbewerb",
],
}));
import { classifyMail } from "../../server/utils/mail-classifier";
// ─── Layer 2.6: Display-Name-Match ──────────────────────────────────────────
describe("classifyMail() — Layer 2.6 Custom Display-Name-Match", () => {
const emptyDomainSet = new Set<string>();
it("EXTRASPIN matcht exakt als Substring → BLOCK (custom-display-name)", async () => {
const result = await classifyMail({
mail: {
senderEmail: "noreply@em123.delivery.net",
senderName: "EXTRASPIN",
subject: "Dein Bonus wartet",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: ["EXTRASPIN"],
});
expect(result.action).toBe("blocked");
expect(result.triggerSource).toBe("custom-display-name");
expect(result.score).toBe(100);
});
it("EXTRASPIN matcht 'EXTRASPIN Casino' als Substring → BLOCK", async () => {
const result = await classifyMail({
mail: {
senderEmail: "noreply@em456.relay.net",
senderName: "EXTRASPIN Casino",
subject: "Exklusives Angebot für dich",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: ["EXTRASPIN"],
});
expect(result.action).toBe("blocked");
expect(result.triggerSource).toBe("custom-display-name");
});
it("EXTRASPIN matcht 'ExtraSpin Bonus' case-insensitiv → BLOCK", async () => {
const result = await classifyMail({
mail: {
senderEmail: "noreply@em789.relay.net",
senderName: "ExtraSpin Bonus",
subject: "Willkommensbonus",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: ["EXTRASPIN"],
});
expect(result.action).toBe("blocked");
expect(result.triggerSource).toBe("custom-display-name");
});
it("extraspin (lowercase pattern) matcht 'EXTRASPIN Casino' case-insensitiv → BLOCK", async () => {
const result = await classifyMail({
mail: {
senderEmail: "info@em.relay.net",
senderName: "EXTRASPIN Casino",
subject: "Willkommen",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: ["extraspin"],
});
expect(result.action).toBe("blocked");
expect(result.triggerSource).toBe("custom-display-name");
});
it("unrelated Display-Name 'Amazon' matcht nicht → PASS (kein Block)", async () => {
const result = await classifyMail({
mail: {
senderEmail: "no-reply@amazon.de",
senderName: "Amazon",
subject: "Deine Bestellung wurde versandt",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: ["EXTRASPIN"],
});
expect(result.action).toBe("passed");
expect(result.triggerSource).not.toBe("custom-display-name");
});
it("senderName ist null → kein Layer-2.6-Block (kein Crash)", async () => {
const result = await classifyMail({
mail: {
senderEmail: "info@some-relay.net",
senderName: null,
subject: "Test",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: ["EXTRASPIN"],
});
// Kein Block durch Layer 2.6 — senderName=null, kein Match möglich
expect(result.triggerSource).not.toBe("custom-display-name");
});
it("leere customDisplayNames → kein Layer-2.6-Block", async () => {
const result = await classifyMail({
mail: {
senderEmail: "info@some-relay.net",
senderName: "EXTRASPIN Casino",
subject: "Test",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: [],
});
expect(result.triggerSource).not.toBe("custom-display-name");
});
it("customDisplayNames fehlt (undefined) → kein Crash, kein Layer-2.6-Block", async () => {
const result = await classifyMail({
mail: {
senderEmail: "info@some-relay.net",
senderName: "EXTRASPIN Casino",
subject: "Test",
},
blockedDomainSet: emptyDomainSet,
// customDisplayNames nicht übergeben → optional, default undefined
});
expect(result.triggerSource).not.toBe("custom-display-name");
});
it("mehrere Patterns — zweites Pattern 'CASINOX' matcht → BLOCK", async () => {
const result = await classifyMail({
mail: {
senderEmail: "noreply@em.relay.net",
senderName: "CASINOX VIP",
subject: "VIP-Angebot",
},
blockedDomainSet: emptyDomainSet,
customDisplayNames: ["EXTRASPIN", "CASINOX"],
});
expect(result.action).toBe("blocked");
expect(result.triggerSource).toBe("custom-display-name");
});
});
// ─── Bestehende Domain-Types bleiben unverändert ──────────────────────────────
describe("classifyMail() — type='mail_domain' via blockedDomainSet (bestehend)", () => {
it("mail_domain-Eintrag in blockedDomainSet → Layer-2-Block (domain)", async () => {
// type='mail_domain' landet via getBlocklistedDomainsSet in blockedDomainSet —
// Blocking-Logik ist identisch zu type='web'.
const domainSet = new Set(["casinox.com"]);
const result = await classifyMail({
mail: {
senderEmail: "promo@casinox.com",
senderName: "CasinoX",
subject: "Dein Bonus",
},
blockedDomainSet: domainSet,
customDisplayNames: [],
});
expect(result.action).toBe("blocked");
expect(result.triggerSource).toBe("domain");
expect(result.features.domainBlocked).toBe(true);
});
});
// ─── Shared Slot-Pool (Unit-Test ohne DB — Count-Logik ist in countActiveCustomDomains) ──
describe("Shared Slot-Pool — Type-Invarianz", () => {
it("countActiveCustomDomains zählt alle Types zusammen (Dokumentations-Test)", () => {
// countActiveCustomDomains() verwendet kein type-Filter —
// count = alle Rows mit status NOT IN ('approved', 'rejected').
// Dieser Test dokumentiert die Erwartung ohne DB-Aufruf.
//
// Erwartetes Verhalten:
// 3 web + 2 mail_domain + 1 mail_display_name → count = 6
// (= shared pool, gemeinsames Limit gegen plan.customDomains)
//
// Test der eigentlichen count-Logik liegt in DB-Integration-Tests (Hetzner).
expect(true).toBe(true); // Placeholder — dokumentiert Slot-Pool-Semantik
});
});