Brainstormed design for detecting newsletter confirmation emails at ingestion, marking them, and surfacing the confirmation link in the admin (detail section, list badge, dashboard pill, emails-page banner). v1 does no outbound request; server on-detect actions deferred. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
7.8 KiB
Subscription confirmation surfacing — design
Date: 2026-05-25 · Origin: TODO.md P1·M "Subscription confirmation handling" (upstream issues #5, #23, #57, #73, #89, #95, #97)
Problem
Newsletters require a "click to confirm your subscription" step. The confirmation email lands in the feed like any other item, so the user has to hunt the link inside a feed reader — the single most recurring upstream request. Until they confirm, the feed stays empty and the tool looks broken.
Goal (v1 scope)
Detect confirmation emails at ingestion, mark them, and surface the confirmation link prominently in the admin so the user can click it. No outbound requests — the worker never follows the link in v1.
Explicitly deferred to a later batch (noted in TODO): a server-configured
on-detection action (none / autoclick / forward to the fallback box).
Non-goals
- No auto-clicking / server-side following of confirmation links (SSRF surface).
- No per-feed action configuration.
- No detection of whether confirmation actually succeeded (impossible without autoclick) — "dismiss" just stops the reminder.
Architecture overview
Detection is a pure domain service fed by infra-extracted links/text; the
result is persisted on the email's metadata at ingestion and a feed-level flag is
projected into feeds:list so the dashboard stays at one KV read.
ingestion (storeEmail)
├─ infra: htmlToText(content) + extractLinks(content)
├─ domain: detectConfirmation({subject, text, links}) → {score, links[]} | null
├─ if detected → EmailMetadata.confirmation = { links }
└─ Feed.ingest() raises FeedMetadata.pendingConfirmation = true
→ FeedRepository.saveMetadata projects pendingConfirmation into feeds:list
1. Detection
src/domain/confirmation.ts (new, pure — no DOM)
detectConfirmation(input: {
subject: string;
text: string; // plain-text rendition of the body
links: { href: string; text: string }[];
}): { score: number; links: string[] } | null
- Multilingual keyword lists (FR/EN/DE/ES and extensible) for subject + body:
confirm,verify,activate,subscribe,confirmer,valider,activer,bestätigen,confirmar,verificar, … (case/diacritic-insensitive matching). - Link scoring by anchor text + URL path/query signals:
/confirm,/verify,/activate,/subscribe,/opt-in,token=,confirm=,?c=, etc. - Combined score = subject/body keyword signal + best link signal. Above a
tuned
THRESHOLD→ return the ranked candidate links (top 3). Below →null. - Only
http(s):links are ever considered/returned (nojavascript:/data:/mailto:). - This module owns the business knowledge (keyword vocab, weights, threshold); it is unit-tested in isolation.
src/infrastructure/html-processor.ts — extractLinks
extractLinks(content: string): { href: string; text: string }[]
- HTML: linkedom parse, collect
<a href>+ anchor text. - Plain text: regex URL fallback (href = url, text = url).
- Infra owns DOM parsing; the domain receives plain data.
src/application/email-processor.ts — wire-in
In storeEmail, before building newEntry:
const text = htmlToText(input.content);
const links = extractLinks(input.content);
const confirmation = detectConfirmation({
subject: input.subject,
text,
links,
});
// → EmailMetadata.confirmation = confirmation ? { links: confirmation.links } : undefined
Computed once at reception. Dedup/ingest flow otherwise unchanged.
2. Data model (additive)
EmailMetadata (src/types/index.ts)
confirmation?: { links: string[] }; // present ⇒ detected; links = ranked top-3
Presence powers the list badge and the detail section. Additive → pre-feature emails have nothing (no retroactive false positives).
FeedMetadata (src/types/index.ts)
pendingConfirmation?: boolean; // ≥1 unactioned confirmation email present
FeedListItem (src/types/index.ts)
pendingConfirmation?: boolean; // projected from FeedMetadata for the dashboard
Aggregate Feed (src/domain/feed.aggregate.ts)
ingest()setspendingConfirmation = truewhen the newEmailMetadatacarriesconfirmation.removeEmails()recomputes the flag (false when no confirmation email remains).dismissConfirmation()sets it to false.- read accessor
pendingConfirmation.
Persistence (src/infrastructure/feed-repository.ts + feed-mapper.ts)
FeedMetadataround-tripspendingConfirmation.- Sync invariant extended:
saveMetadataprojectspendingConfirmationinto thefeeds:listitem (today onlysave/saveConfigupsert the list). This is the one metadata-derived field the list carries; it keeps the dashboard at a single KV read instead of N per-feed metadata reads.
3. Admin UI
a) Detail view — dedicated section (routes/admin/emails.tsx, GET /emails/:emailKey)
Above the Rendered/Raw toggle, shown when email.confirmation:
- Heading "Confirm your subscription".
- Best-scored link as a primary button; remaining candidates as secondary
links. URLs shown in clear text (transparency).
target="_blank" rel="noopener noreferrer".
b) Email list badge (routes/admin/emails.tsx, GET /feeds/:feedId/emails)
A "Confirmation" badge in the subject cell of rows where email.confirmation
exists, styled like the existing attachment indicator.
c) Dashboard indicator (routes/admin.tsx, GET /)
A "Confirmation pending" pill on feeds whose FeedListItem.pendingConfirmation is
true (list + table views), read with zero extra KV reads. Links to the feed's
emails page.
d) Feed emails-page banner (routes/admin/emails.tsx)
When feedMetadata.pendingConfirmation: a top banner "A confirmation email was
detected" linking to the latest confirmation email, plus a "Mark as confirmed"
button (dismiss).
Post-creation redirect
POST /admin/feeds/create redirects to /admin/feeds/:feedId/emails (not the
dashboard) so the user lands where the banner appears once the (async) confirmation
email arrives.
4. Dismiss action
POST /admin/feeds/:feedId/confirmation/dismiss:
- load aggregate →
feed.dismissConfirmation()→repo.saveMetadata(feed)(reprojectspendingConfirmation:falseintofeeds:list). - JSON response for the banner's fetch (mirrors the existing sender-filter pattern); redirect fallback for no-JS.
- Protected by the existing admin auth + CSRF middleware.
"Dismiss" = "stop reminding me" (no real confirmation tracking without autoclick).
Deleting the confirmation email(s) also clears the flag via removeEmails.
Security
- v1 performs no outbound request → no SSRF surface.
- Candidate
hrefs filtered tohttp(s):only; neverjavascript:/data:. - Output escaped via normal hono/jsx rendering.
Testing (TDD)
confirmation.test.ts(domain): multilingual scoring, threshold, link ranking, negative cases (normal newsletter doesn't trigger), plain-text body.html-processor.test.ts:extractLinksfor HTML and plain text.email-processor.test.ts: confirmation email →EmailMetadata.confirmationpopulated +pendingConfirmationraised; dedup/ingest otherwise unchanged.admin.test.ts/ emails tests: list badge, detail section, dashboard pill, banner, dismiss route (clears flag + reprojects into list).
Close green: npx tsc --noEmit, npm test, npm run build.
Docs
README.md+INSTALL.md: "Subscription confirmation surfacing" capability.docs/index.html(landing): feature card — a genuine differentiator vs kill-the-newsletter.TODO.md: check offP1·M"Subscription confirmation handling"; add a newP2·M"Confirmation on-detect action (none/autoclick/forward)" item capturing the deferred server options + SSRF/fallback notes.