Der alreadyGlobal-Pre-Check (Domain schon in der 208k-Layer-1-Blocklist →
kein Custom-Slot) gilt jetzt nur noch fuer Mail-Typen. Fuer type='web'
uebersprungen: Web-Custom-Domains speisen die Layer-2-VIP-Liste, eine separate
Schicht — eine global (Layer 1) gelistete Domain muss in die VIP koennen,
gerade weil Layer 2 das Netz ist, wenn Layer 1 aus ist.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fix 1 (scan-internal): Gmail ignoriert IMAP EXPUNGE — stattdessen messageMove()
in Trash-Folder (via specialUse='\\Trash', Fallback '[Gmail]/Trash'). Verhindert
dass Gambling-Mails bei Gmail-Usern in 'All Mail' verbleiben statt zu verschwinden.
Alle anderen Provider (iCloud, Outlook, IONOS) bleiben beim bestehenden
messageDelete() + EXPUNGE-Fallback.
Fix 2 (custom-domains): Nach erfolgreichem mail_domain-Add fire-and-forget
$fetch auf /api/mail/scan-internal — damit neue Mail-Patterns sofort (< 5s)
wirken statt erst beim nächsten 30min-Cron. Scan-Fehler blockieren den POST nicht.
Tests: 16 neue Tests (gmail-delete-strategy + scan-trigger). 259 passed, 0 failed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
POST /api/custom-domains now accepts a third body variant — { pattern }
without an explicit kind or type — which the resolver infers from the
pattern shape so the frontend can ship a single dynamic input field
instead of asking the user to choose between Seite / E-Mail in advance.
- pattern contains '@' → treat as kind='mail', strip the
local-part, store as mail_domain
after the same TLD / DOMAIN_RE
validation as the explicit-kind path
- pattern contains '.' (no '@') → treat as kind='web'
- neither → 400 INVALID_PATTERN with a clear
message ("Bitte eine Domain oder
Mail-Adresse eingeben")
Variant A ({ pattern, kind }) and Variant B ({ domain, type }) stay
fully supported, plus a `kind: 'auto'` keyword if a client prefers an
explicit opt-in to the auto-detect path. The display-name path is still
locked off in v1.0 — pure tokens without dots route into the same
INVALID_PATTERN response, which keeps the v1.0 guarantee intact.
Plan-limits.test.ts grew the matching test cases — auto-detect for a
domain, auto-detect for a full address (local-part stripped to mail_-
domain), auto-detect rejection for a bare token. All existing tests
keep their pass status.
User found that adding bet365.com (which is in the 208k global filter)
silently took a custom-domain slot — they paid a slot for something
the global blocklist already covered. Two pieces:
1. backend/custom-domains/index.post.ts: before any slot-limit check or
DB insert, look the domain up in blocklist_domain (active rows). If
present, return 200 { alreadyGlobal: true, domain }. No row gets
written, no slot consumed. The existing frontend hook + AddSheet
already handle the alreadyGlobal flag — they surface the
"bereits global blockiert" alert and don't refresh as if the entry
landed in the user's list.
2. blocker.tsx default mailOpen state flipped from true to false so the
Eigene Mails section starts collapsed on page load. Domains stays
the primary affordance; mail-patterns are an opt-in expansion.
User asked for the admin review tooling — and the lyra-bot community
post / notification text that goes out with each submission — to know
whether a submission is a website-domain or a mail-sender-domain. Until
now the type lived only on user_custom_domains and the submission
inherited it implicitly via the foreign key. Reading it back for the
admin list or the lyra prompt meant joining the source row every time.
- migration 20260516_domain_submission_type adds a type column to
rebreak.domain_submissions with a default of 'web' and backfills
every existing row from its linked user_custom_domains.type. The
backfill is idempotent (UPDATE … FROM with the type comparison).
- Composite index (type, status) so the admin pending-list can scope
by category without scanning the whole table.
- submitDomainForReview now copies the source row's type into the new
submission. The submit endpoint picks it up to vary the auto-generated
community-vote post copy: a website framing for type='web' and an
"Mail-Absender"-framing for type='mail_domain'. The user's nickname
is the only PII referenced.
- adminApproveSubmission returns the type alongside the domain so the
approve endpoint's Lyra-bot Groq prompt can swap its subject/action
labels per category. Reject path unchanged — the notification just
carries the bare domain string, no type framing needed.
- BlocklistDomain stays type-agnostic on purpose. The mail-daemon's
getBlocklistedDomainsSet is a flat string-set match against sender
domain or URL host, and works for both categories without splitting.
Adding a type there would be redundant work in v1.0 — revisit only
if we ever need a UI to surface what category each global entry
came from.
38/38 backend tests pass (8 admin/domains, 30 plan-limits including
5 new for the type-copy semantics and community-post text variants).
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.
plan-features.customDomains is now { web, mail } per plan instead of a
single number. Free 5+5, Pro 5+5, Legend 10+10 — the user explicitly
chose separate pools so users don't have to trade a website slot for a
mail-pattern slot or vice versa.
- countActiveCustomDomainsSplit(userId) groupBy type → { web, mail }
(mail aggregates mail_domain + mail_display_name). Old single-count
function stays as a deprecated alias for any caller still on it.
- POST /api/custom-domains: body-compat accepts both { pattern, kind }
(current frontend) and { domain, type } (legacy / direct). kind='mail'
is split into mail_domain vs mail_display_name server-side based on
whether the pattern looks like a domain. Slot check is per-bucket;
errors are WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED so the UI can show
the right limit-reached message per tab.
- GET /api/custom-domains: response shape extended to
{ items, counts: { web, mail }, limits: { web, mail } } so the
frontend can drive the per-tab counter without client-side estimation.
- POST /api/custom-domains/:id/submit: hard-blocks mail_display_name
with 400 DISPLAY_NAME_NOT_SUBMITTABLE. Display-name submission to the
global blocklist is deferred to v1.1 — would require a schema split
on BlocklistDomain that's risky pre-TestFlight. mail_domain still
flows through the community-vote pipeline like web entries.
- auth/me.get.ts, plan/change-preview.get.ts, coach/message.post.ts
updated for the new shape (Lyra prompts untouched, only template
variables split web vs mail counts).
24 vitest cases in backend/tests/custom-domains/plan-limits.test.ts
cover the new shape, body compat, bucket logic, and the submit guard;
216/216 total backend tests pass.
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).