From 5b54659b4deeba507c14e6b446895c9cebaee70a Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Mon, 25 May 2026 08:29:11 +0200 Subject: [PATCH] docs(spec): subscription confirmation surfacing design 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 --- ...-05-25-subscription-confirmation-design.md | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-25-subscription-confirmation-design.md diff --git a/docs/superpowers/specs/2026-05-25-subscription-confirmation-design.md b/docs/superpowers/specs/2026-05-25-subscription-confirmation-design.md new file mode 100644 index 0000000..fc25d8b --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-subscription-confirmation-design.md @@ -0,0 +1,206 @@ +# 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) + +```ts +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 (no `javascript:`/`data:`/ + `mailto:`). +- This module owns the business knowledge (keyword vocab, weights, threshold); + it is unit-tested in isolation. + +### `src/infrastructure/html-processor.ts` — `extractLinks` + +```ts +extractLinks(content: string): { href: string; text: string }[] +``` + +- HTML: linkedom parse, collect `` + 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`: + +```ts +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`) + +```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`) + +```ts +pendingConfirmation?: boolean; // ≥1 unactioned confirmation email present +``` + +### `FeedListItem` (`src/types/index.ts`) + +```ts +pendingConfirmation?: boolean; // projected from FeedMetadata for the dashboard +``` + +### Aggregate `Feed` (`src/domain/feed.aggregate.ts`) + +- `ingest()` sets `pendingConfirmation = true` when the new `EmailMetadata` carries + `confirmation`. +- `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`) + +- `FeedMetadata` round-trips `pendingConfirmation`. +- **Sync invariant extended**: `saveMetadata` projects `pendingConfirmation` into + the `feeds:list` item (today only `save`/`saveConfig` upsert 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)` + (reprojects `pendingConfirmation:false` into `feeds: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 `href`s filtered to `http(s):` only; never `javascript:`/`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`: `extractLinks` for HTML and plain text. +- `email-processor.test.ts`: confirmation email → `EmailMetadata.confirmation` + populated + `pendingConfirmation` raised; 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 off `P1·M` "Subscription confirmation handling"; add a new + `P2·M` "Confirmation on-detect action (none/autoclick/forward)" item capturing + the deferred server options + SSRF/fallback notes.