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.
173 lines
6.0 KiB
TypeScript
173 lines
6.0 KiB
TypeScript
import { awardPoints } from "../../utils/scoring";
|
|
import {
|
|
addUserCustomDomain,
|
|
countActiveCustomDomainsSplit,
|
|
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])?)+$/;
|
|
|
|
/**
|
|
* Leitet Frontend-`kind` auf internen `CustomDomainType` ab.
|
|
*
|
|
* Variante A (neues Frontend): { pattern: string, kind: 'web' | 'mail' }
|
|
* - kind='web' → type='web'
|
|
* - 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.
|
|
*/
|
|
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 { ok: true, type: "web", value: pattern };
|
|
}
|
|
|
|
if (kind === "mail") {
|
|
// 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);
|
|
}
|
|
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 { 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() ?? "";
|
|
|
|
// 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 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({
|
|
statusCode: 400,
|
|
data: { error: "INVALID_TYPE", validTypes: CUSTOM_DOMAIN_TYPES },
|
|
});
|
|
}
|
|
|
|
// domain/pattern validieren + normalisieren (nur web + mail_domain hier)
|
|
// mail_domain: value wurde in resolveTypeAndValue bereits normalisiert (lowercase, local-part stripped)
|
|
let value = rawValue;
|
|
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
|
|
const profile = await getProfile(user.id);
|
|
const limits = getPlanLimits(profile?.plan ?? "free");
|
|
|
|
// Welcher Bucket?
|
|
const bucket: "web" | "mail" = type === "web" ? "web" : "mail";
|
|
const bucketLimit = limits.customDomains[bucket];
|
|
|
|
if (bucketLimit !== Infinity) {
|
|
const split = await countActiveCustomDomainsSplit(user.id);
|
|
const currentCount = split[bucket];
|
|
if (currentCount >= bucketLimit) {
|
|
const errorCode = bucket === "web" ? "WEB_LIMIT_REACHED" : "MAIL_LIMIT_REACHED";
|
|
throw createError({
|
|
statusCode: 403,
|
|
data: {
|
|
error: errorCode,
|
|
resource: "custom_domains",
|
|
bucket,
|
|
current: currentCount,
|
|
limit: bucketLimit,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
try {
|
|
const data = await addUserCustomDomain(user.id, value, "manual", type);
|
|
|
|
await awardPoints(user.id, "custom_domain_submitted", { domain: value }).catch(
|
|
() => {},
|
|
);
|
|
|
|
return data;
|
|
} catch (err: any) {
|
|
const msg =
|
|
err.message?.includes("duplicate") || err.code === "P2002"
|
|
? "Eintrag bereits vorhanden"
|
|
: err.message ?? "Fehler";
|
|
throw createError({ statusCode: 400, message: msg });
|
|
}
|
|
});
|