chahinebrini 7dbcac6700 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).
2026-05-16 01:53:59 +02:00

77 lines
2.7 KiB
TypeScript

import { submitDomainForReview } from "../../../db/domains";
import { getProfile } from "../../../db/profile";
import { getPlanLimits } from "../../../utils/plan-features";
import { usePrisma } from "../../../utils/prisma";
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const id = getRouterParam(event, "id");
if (!id) throw createError({ statusCode: 400, message: "ID fehlt" });
// Only Pro/Legend can submit
const profile = await getProfile(user.id);
const plan = profile?.plan ?? "free";
const limits = getPlanLimits(plan);
if (!limits.domainRefill) {
throw createError({
statusCode: 403,
message: "Nur Pro-User können Domains einreichen",
});
}
const db = usePrisma();
// Verify ownership + status
const existing = await db.userCustomDomain.findFirst({
where: { id, userId: user.id },
select: { id: true, domain: true, status: true, type: true },
});
if (!existing)
throw createError({ statusCode: 404, message: "Domain nicht gefunden" });
if (existing.status !== "active" && existing.status !== "rejected") {
throw createError({
statusCode: 409,
message: "Domain wurde bereits eingereicht oder genehmigt",
});
}
// Tier-Routing:
// - Pro: Community-Post mit Voting-Flow erstellen
// - 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 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,
category: "domain_vote",
content: postContent,
},
select: { id: true },
});
postId = post.id;
}
const { submission } = await submitDomainForReview(
user.id,
id,
plan as "free" | "pro" | "legend",
postId ?? undefined,
);
return {
ok: true,
postId,
submissionId: submission.id,
domain: existing.domain,
type: existing.type,
route: plan === "legend" ? "admin_direct" : "community_vote",
};
});