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).
100 lines
3.2 KiB
TypeScript
100 lines
3.2 KiB
TypeScript
import { awardPoints } from "../../utils/scoring";
|
|
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);
|
|
|
|
// 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" },
|
|
});
|
|
}
|
|
}
|
|
|
|
// Shared Slot-Pool prüfen (alle Types zusammen)
|
|
const profile = await getProfile(user.id);
|
|
const limits = getPlanLimits(profile?.plan ?? "free");
|
|
|
|
if (limits.customDomains !== Infinity) {
|
|
const activeCount = await countActiveCustomDomains(user.id);
|
|
if (activeCount >= limits.customDomains) {
|
|
throw createError({
|
|
statusCode: 403,
|
|
data: {
|
|
error: "PLAN_LIMIT",
|
|
resource: "custom_domains",
|
|
current: activeCount,
|
|
limit: limits.customDomains,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
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 });
|
|
}
|
|
});
|