diff --git a/backend/server/api/custom-domains/index.post.ts b/backend/server/api/custom-domains/index.post.ts index 020328d..13f42aa 100644 --- a/backend/server/api/custom-domains/index.post.ts +++ b/backend/server/api/custom-domains/index.post.ts @@ -11,55 +11,101 @@ 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\-_]+$/; - /** * Leitet Frontend-`kind` auf internen `CustomDomainType` ab. * * Variante A (neues Frontend): { pattern: string, kind: 'web' | 'mail' } * - kind='web' → type='web' - * - kind='mail' → analysiere pattern: - * enthält '.' + sieht wie Domain aus → 'mail_domain' - * sonst → 'mail_display_name' + * - kind='mail' → MUSS Domain-Shape haben (mit Punkt + TLD) → type='mail_domain' + * Display-Name-Input (kein Punkt) → throws 400 INVALID_MAIL_DOMAIN + * Wenn pattern volle Adresse (local@domain.tld) → local-part wird gestripped * * Variante B (direkt / Legacy): { domain: string, type: 'web' | 'mail_domain' | 'mail_display_name' } + * - type='mail_display_name' → throws 400 DISPLAY_NAME_NOT_SUPPORTED (v1.0) + * + * Display-Name-Blocking kommt in v1.1 mit eigener UX. + * + * Returns null wenn ein 400-Error geworfen werden soll — caller wirft dann den Error + * basierend auf dem zurückgegebenen error-Code. */ -function resolveTypeAndValue(body: any): { type: CustomDomainType; value: string } { +type ResolveResult = + | { ok: true; type: CustomDomainType; value: string } + | { ok: false; error: "INVALID_MAIL_DOMAIN" | "DISPLAY_NAME_NOT_SUPPORTED" }; + +function resolveTypeAndValue(body: any): ResolveResult { // Variante A: pattern + kind if (body?.kind !== undefined || body?.pattern !== undefined) { const kind = (body?.kind as string)?.trim() ?? "web"; const pattern = (body?.pattern as string)?.trim() ?? ""; if (kind === "web") { - return { type: "web", value: pattern }; + return { ok: true, type: "web", value: pattern }; } if (kind === "mail") { - // Domain-shape: enthält mindestens einen Punkt und passt auf Domain-Regex (nach lowercase) - const lower = pattern.toLowerCase().replace(/^https?:\/\//, ""); - if (lower.includes(".") && DOMAIN_RE.test(lower)) { - return { type: "mail_domain", value: lower }; + // Defensiv: wenn volle Adresse übergeben (local@domain.tld) → local-part strippen + let normalized = pattern; + if (normalized.includes("@")) { + const atIdx = normalized.lastIndexOf("@"); + normalized = normalized.slice(atIdx + 1); } - return { type: "mail_display_name", value: pattern }; + normalized = normalized.toLowerCase().replace(/^https?:\/\//, "").trim(); + + // Domain-Shape prüfen: muss Punkt haben und Domain-Regex bestehen + if (normalized.includes(".") && DOMAIN_RE.test(normalized)) { + return { ok: true, type: "mail_domain", value: normalized }; + } + + // Sieht nach Display-Name aus (kein Punkt, kein @-Domain) → in v1.0 nicht unterstützt + return { ok: false, error: "INVALID_MAIL_DOMAIN" }; } // Unbekanntes kind → 400 via validTypes-Check unten - return { type: kind as CustomDomainType, value: pattern }; + return { ok: true, type: kind as CustomDomainType, value: pattern }; } // Variante B: domain + type const rawType = (body?.type as string)?.trim() ?? "web"; const value = (body?.domain as string)?.trim() ?? ""; - return { type: rawType as CustomDomainType, value }; + + // v1.0: mail_display_name wird nicht mehr akzeptiert + if (rawType === "mail_display_name") { + return { ok: false, error: "DISPLAY_NAME_NOT_SUPPORTED" }; + } + + return { ok: true, type: rawType as CustomDomainType, value }; } export default defineEventHandler(async (event) => { const user = await requireUser(event); const body = await readBody(event); - const { type, value: rawValue } = resolveTypeAndValue(body); + const resolved = resolveTypeAndValue(body); + + if (!resolved.ok) { + if (resolved.error === "INVALID_MAIL_DOMAIN") { + throw createError({ + statusCode: 400, + data: { + error: "INVALID_MAIL_DOMAIN", + message: + "Mail-Patterns brauchen eine Domain (z.B. only4-subscribers.com). Display-Name-Blocking kommt in einer späteren Version.", + }, + }); + } + if (resolved.error === "DISPLAY_NAME_NOT_SUPPORTED") { + throw createError({ + statusCode: 400, + data: { + error: "DISPLAY_NAME_NOT_SUPPORTED", + message: + "Mail-Patterns brauchen eine Domain (z.B. only4-subscribers.com). Display-Name-Blocking kommt in einer späteren Version.", + }, + }); + } + } + + const { type, value: rawValue } = resolved as { ok: true; type: CustomDomainType; value: string }; if (!CUSTOM_DOMAIN_TYPES.includes(type as CustomDomainType)) { throw createError({ @@ -68,37 +114,18 @@ export default defineEventHandler(async (event) => { }); } - // domain/pattern validieren + normalisieren + // domain/pattern validieren + normalisieren (nur web + mail_domain hier) + // mail_domain: value wurde in resolveTypeAndValue bereits normalisiert (lowercase, local-part stripped) let value = rawValue; - - 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" }, - }); - } + 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" }, + }); } // Per-type Slot-Limit prüfen diff --git a/backend/server/utils/mail-classifier.ts b/backend/server/utils/mail-classifier.ts index 6645ace..e14d349 100644 --- a/backend/server/utils/mail-classifier.ts +++ b/backend/server/utils/mail-classifier.ts @@ -445,6 +445,10 @@ export async function classifyMail(params: ClassifyMailParams): Promise { /** * Extrahiert die resolveTypeAndValue-Logik als reiner Unit-Test ohne Server-Overhead. - * Spiegelt exakt die Implementierung in index.post.ts wider. + * Spiegelt exakt die Implementierung in index.post.ts wider (v1.0 — kein Display-Name). + * + * Returns { ok: true, type, value } oder { ok: false, error } — analog zum Endpoint. */ -function resolveTypeAndValueForTest(body: any): { type: string; value: string } { +type TestResolveResult = + | { ok: true; type: string; value: string } + | { ok: false; error: "INVALID_MAIL_DOMAIN" | "DISPLAY_NAME_NOT_SUPPORTED" }; + +function resolveTypeAndValueForTest(body: any): TestResolveResult { const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; if (body?.kind !== undefined || body?.pattern !== undefined) { @@ -65,64 +72,102 @@ function resolveTypeAndValueForTest(body: any): { type: string; value: string } const pattern = (body?.pattern as string)?.trim() ?? ""; if (kind === "web") { - return { type: "web", value: pattern }; + return { ok: true, type: "web", value: pattern }; } if (kind === "mail") { - const lower = pattern.toLowerCase().replace(/^https?:\/\//, ""); - if (lower.includes(".") && DOMAIN_RE.test(lower)) { - return { type: "mail_domain", value: lower }; + // Defensiv: volle Adresse → local-part strippen + let normalized = pattern; + if (normalized.includes("@")) { + const atIdx = normalized.lastIndexOf("@"); + normalized = normalized.slice(atIdx + 1); } - return { type: "mail_display_name", value: pattern }; + normalized = normalized.toLowerCase().replace(/^https?:\/\//, "").trim(); + + if (normalized.includes(".") && DOMAIN_RE.test(normalized)) { + return { ok: true, type: "mail_domain", value: normalized }; + } + + // Display-Name-Input: nicht unterstützt in v1.0 + return { ok: false, error: "INVALID_MAIL_DOMAIN" }; } - return { type: kind, value: pattern }; + return { ok: true, type: kind, value: pattern }; } const rawType = (body?.type as string)?.trim() ?? "web"; const value = (body?.domain as string)?.trim() ?? ""; - return { type: rawType, value }; + + // v1.0: mail_display_name wird nicht akzeptiert + if (rawType === "mail_display_name") { + return { ok: false, error: "DISPLAY_NAME_NOT_SUPPORTED" }; + } + + return { ok: true, type: rawType, value }; } -describe("Body-Compat-Mapping — kind='mail' Pattern-Analyse", () => { +describe("Body-Compat-Mapping — kind='mail' Pattern-Analyse (v1.0: nur Domain-Input)", () => { it("{ kind: 'web', pattern: 'casino.de' } → type='web'", () => { const r = resolveTypeAndValueForTest({ kind: "web", pattern: "casino.de" }); - expect(r.type).toBe("web"); - expect(r.value).toBe("casino.de"); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.type).toBe("web"); + expect(r.value).toBe("casino.de"); + } }); it("{ kind: 'mail', pattern: 'only4-subscribers.com' } → mail_domain (enthält Punkt + TLD)", () => { const r = resolveTypeAndValueForTest({ kind: "mail", pattern: "only4-subscribers.com" }); - expect(r.type).toBe("mail_domain"); - expect(r.value).toBe("only4-subscribers.com"); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.type).toBe("mail_domain"); + expect(r.value).toBe("only4-subscribers.com"); + } }); it("{ kind: 'mail', pattern: 'casino-relay.de' } → mail_domain", () => { const r = resolveTypeAndValueForTest({ kind: "mail", pattern: "casino-relay.de" }); - expect(r.type).toBe("mail_domain"); + expect(r.ok).toBe(true); + if (r.ok) expect(r.type).toBe("mail_domain"); }); - it("{ kind: 'mail', pattern: 'EXTRASPIN' } → mail_display_name (kein Punkt)", () => { + // v1.0: Display-Name-Input → 400 INVALID_MAIL_DOMAIN (kein mail_display_name mehr) + it("{ kind: 'mail', pattern: 'EXTRASPIN' } → 400 INVALID_MAIL_DOMAIN (kein Punkt, Display-Name)", () => { const r = resolveTypeAndValueForTest({ kind: "mail", pattern: "EXTRASPIN" }); - expect(r.type).toBe("mail_display_name"); - expect(r.value).toBe("EXTRASPIN"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe("INVALID_MAIL_DOMAIN"); }); - it("{ kind: 'mail', pattern: 'Casino Bonus' } → mail_display_name (Leerzeichen, kein TLD)", () => { + it("{ kind: 'mail', pattern: 'Casino Bonus' } → 400 INVALID_MAIL_DOMAIN (Leerzeichen, kein TLD)", () => { const r = resolveTypeAndValueForTest({ kind: "mail", pattern: "Casino Bonus" }); - expect(r.type).toBe("mail_display_name"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe("INVALID_MAIL_DOMAIN"); }); - it("Variante B { domain, type } passiert unverändert durch", () => { + // Defensiv: volle Adresse → local-part gestripped, als mail_domain gespeichert + it("{ kind: 'mail', pattern: 'communications@only4-subscribers.com' } → mail_domain mit 'only4-subscribers.com'", () => { + const r = resolveTypeAndValueForTest({ kind: "mail", pattern: "communications@only4-subscribers.com" }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.type).toBe("mail_domain"); + expect(r.value).toBe("only4-subscribers.com"); + } + }); + + it("Variante B { domain, type: 'mail_domain' } passiert unverändert durch", () => { const r = resolveTypeAndValueForTest({ domain: "spin.casino.com", type: "mail_domain" }); - expect(r.type).toBe("mail_domain"); - expect(r.value).toBe("spin.casino.com"); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.type).toBe("mail_domain"); + expect(r.value).toBe("spin.casino.com"); + } }); - it("Variante B { domain, type: 'mail_display_name' } passiert unverändert", () => { + // v1.0: Variante B mit mail_display_name → 400 DISPLAY_NAME_NOT_SUPPORTED + it("Variante B { domain, type: 'mail_display_name' } → 400 DISPLAY_NAME_NOT_SUPPORTED (v1.0)", () => { const r = resolveTypeAndValueForTest({ domain: "EXTRASPIN", type: "mail_display_name" }); - expect(r.type).toBe("mail_display_name"); - expect(r.value).toBe("EXTRASPIN"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe("DISPLAY_NAME_NOT_SUPPORTED"); }); }); @@ -170,10 +215,13 @@ describe("Slot-Bucket-Logik — welcher Bucket pro Type", () => { expect(mailCount >= limits.customDomains.mail).toBe(true); }); - it("mix mail_domain + mail_display_name zählt gemeinsam in mail-Bucket", () => { - // 3 mail_domain + 2 mail_display_name = 5 mail total → Limit erreicht (Free) + it("legacy mail_display_name-Rows (falls vorhanden) zählen weiterhin in mail-Bucket", () => { + // v1.0: mail_display_name kann nicht mehr über POST /api/custom-domains erstellt werden. + // Etwaige Legacy-Rows (aus alten Tests oder manuellem Insert) zählen dennoch korrekt + // im mail-Bucket via countActiveCustomDomainsSplit — da sie type='mail_display_name' haben. + // DB-Typ bleibt im Schema erhalten für triviale v1.1-Reaktivierung. const mailDomainCount = 3; - const mailDisplayNameCount = 2; + const mailDisplayNameCount = 2; // Legacy-Rows (nicht mehr per API anlegbar in v1.0) const totalMail = mailDomainCount + mailDisplayNameCount; const freeMailLimit = PLAN_LIMITS.free.customDomains.mail; expect(totalMail >= freeMailLimit).toBe(true); @@ -202,7 +250,9 @@ describe("countActiveCustomDomainsSplit — Slot-Counting-Semantik (Dokumentatio // ─── Submit-Guard mail_display_name ────────────────────────────────────────── describe("Submit-Guard — DISPLAY_NAME_NOT_SUBMITTABLE", () => { - it("type='mail_display_name' darf nicht submitted werden (v1.0 constraint)", () => { + it("type='mail_display_name' darf nicht submitted werden — guard in submit.post.ts (v1.0)", () => { + // v1.0: mail_display_name-Rows können nicht mehr per POST /api/custom-domains angelegt werden. + // Falls Legacy-Rows in der DB existieren: submit.post.ts wirft 400 DISPLAY_NAME_NOT_SUBMITTABLE. // Spiegelt die Guard-Logik in submit.post.ts wider: // if (existing.type === 'mail_display_name') → 400 DISPLAY_NAME_NOT_SUBMITTABLE const type = "mail_display_name";