mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<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`:
|
||||
|
||||
```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.
|
||||
Reference in New Issue
Block a user