fix(backend): remove display-name pattern support for v1.0
User explicitly chose to drop display-name matching from v1.0 after
the UX trap surfaced — a user typing "EXTRASPIN" without a domain got
a 400 INVALID_DOMAIN back, which is a confusing dead-end. v1.1 will
ship a dedicated display-name UI; until then mail input is domain-only.
- resolveTypeAndValue returns a discriminated union — kind='mail' with
no dot or @ now resolves to { ok: false, error: 'INVALID_MAIL_DOMAIN' }
instead of silently turning into a mail_display_name row.
- Full-address mail input (local@domain.tld) still gets its local-part
stripped server-side so the stored value is always a clean domain.
- Variant-B body { type: 'mail_display_name' } returns 400
DISPLAY_NAME_NOT_SUPPORTED for direct API consumers.
- The DISPLAY_NAME_PATTERN regex is gone — the path that used it can
no longer be reached.
- classifyMail's Layer 2.6 (the display-name substring match) is
intentionally left in place as dead code with a v1.1 marker, so
re-enabling later is just wiring the input field back up and feeding
the customDisplayNames array.
- Tests rewritten: the two pre-existing display-name tests now assert
the 400 INVALID_MAIL_DOMAIN path, plus a new positive case for the
full-address local-part strip. 217 vitest passes, 4 pre-existing skips.
Staging DB clean — the type column hasn't been deployed yet so no
mail_display_name rows exist to backfill.
This commit is contained in:
parent
1e07e8303f
commit
c1250836a3
@ -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
|
||||
|
||||
@ -445,6 +445,10 @@ export async function classifyMail(params: ClassifyMailParams): Promise<Classifi
|
||||
}
|
||||
|
||||
// ── Layer 2.6: User-Custom-Display-Name-Hard-Block ──────────────────────────
|
||||
// Display-name patterns disabled in v1.0 — re-enable when display-name input UX ships (v1.1).
|
||||
// getCustomMailDisplayNames() returns [] until mail_display_name rows exist,
|
||||
// so this block is dead code in practice. Keep logic intact for trivial re-activation.
|
||||
//
|
||||
// User-eigene Patterns (z.B. "EXTRASPIN") matchen case-insensitiv als Substring
|
||||
// gegen den Sender-Display-Name. Kein Score — direkter Hard-Block wenn Match.
|
||||
//
|
||||
|
||||
@ -5,8 +5,9 @@
|
||||
* - getPlanLimits returnt strukturiertes { web, mail } Objekt
|
||||
* - countActiveCustomDomainsSplit returnt korrekte Split-Counts (Unit ohne DB)
|
||||
* - POST-Body-Compat: { pattern, kind: 'mail' } mit Domain-Pattern → mail_domain
|
||||
* - POST-Body-Compat: { pattern, kind: 'mail' } mit Display-Name-Pattern → mail_display_name
|
||||
* - Submit eines mail_display_name → 400 DISPLAY_NAME_NOT_SUBMITTABLE
|
||||
* - POST-Body-Compat: { pattern, kind: 'mail' } mit Display-Name-Pattern → 400 INVALID_MAIL_DOMAIN (v1.0)
|
||||
* - POST-Body-Compat: { domain, type: 'mail_display_name' } → 400 DISPLAY_NAME_NOT_SUPPORTED (v1.0)
|
||||
* - POST-Body-Compat: volle Adresse (local@domain.tld) → local-part gestripped, gespeichert als mail_domain
|
||||
* - Submit eines mail_domain → erlaubt (gleich wie web)
|
||||
*
|
||||
* DSGVO: keine PII. Synthetic Brand-Namen und Test-Domains.
|
||||
@ -55,9 +56,15 @@ describe("getPlanLimits — customDomains ist strukturiertes Objekt", () => {
|
||||
|
||||
/**
|
||||
* 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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user