feat: decouple read FeedId from inbound MailboxId

Separate the two feed identities so the public read URL never reveals the
inbound address and vice-versa:

- FeedId becomes an opaque high-entropy token (read id + KV key); MailboxId
  (noun.noun.NN) owns the inbound address and the untrusted-input boundary
  via MailboxId.parse. They map only through the inbound:<mailbox> secondary
  index, resolved solely at reception.
- inbound index lifecycle is owned by FeedRepository: written by save/saveConfig,
  dropped by removeFromList(Bulk) — symmetric, never mirrored by hand (removes the
  manual delete in feed-service + the cron loop, and a silent empty-catch).
- Feed.mailboxId exposes a MailboxId VO (symmetry with Feed.id); the
  mailbox@domain shape lives on MailboxId.emailAddress(domain).
- Distinguish mailbox_unknown (no feed claims the address) from feed_not_found
  (dangling index) for observability; both forwardable, both 404.
- Drop the redundant EmailParser.extractMailbox pass-through so MailboxId.parse
  is the single parse boundary.

Docs (README/INSTALL/CLAUDE.md/landing) and tests updated; 439 tests green,
tsc clean, build dry-run OK.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-24 22:46:37 +02:00
parent f7f10779bc
commit 1a4a479190
43 changed files with 649 additions and 149 deletions
+3 -1
View File
@@ -50,7 +50,9 @@ Gaps found by reading every open/closed issue + PR on [kill-the-newsletter](http
- [ ] `P1·M` **Subscription confirmation handling**_the single most recurring upstream request_ ([#5](https://github.com/leafac/kill-the-newsletter/issues/5), [#23](https://github.com/leafac/kill-the-newsletter/issues/23), [#57](https://github.com/leafac/kill-the-newsletter/issues/57), [#73](https://github.com/leafac/kill-the-newsletter/issues/73), [#89](https://github.com/leafac/kill-the-newsletter/issues/89), [#95](https://github.com/leafac/kill-the-newsletter/issues/95), [#97](https://github.com/leafac/kill-the-newsletter/issues/97)). Newsletters require a "click to confirm your email" step; users can't easily find/click the link buried in a feed reader. Our admin already lists emails, but nothing **surfaces** the confirmation link or shows the first email inline right after feed creation. Low effort, high payoff (admin UX in `src/routes/admin/feeds.tsx` + maybe extract candidate confirm links during ingestion in `src/application/email-processor.ts`).
- [ ] `P1·M` **Separate write (email) / read (feed) IDs**_most-requested privacy gap, still open upstream_ ([#114](https://github.com/leafac/kill-the-newsletter/issues/114), [#93](https://github.com/leafac/kill-the-newsletter/issues/93), [#75](https://github.com/leafac/kill-the-newsletter/issues/75)). Today `feedEmailAddress = <feedId>@domain` and `/rss/<feedId>` reuse the **same** id (`src/infrastructure/urls.ts`), so anyone with the inbound address can read the feed (and vice-versa) — you can't share a feed without leaking its subscribe address. Add a distinct read id alongside the write id: touch `FeedState`, id generation (`FeedId`), `urls.ts`, the `inbound` parse, and the feed-list/registry. Medium effort.
- [x] `P1·M` **Separate write (email) / read (feed) IDs**_most-requested privacy gap, still open upstream_ ([#114](https://github.com/leafac/kill-the-newsletter/issues/114), [#93](https://github.com/leafac/kill-the-newsletter/issues/93), [#75](https://github.com/leafac/kill-the-newsletter/issues/75)). The two identities are now decoupled: `FeedId` is an **opaque random token** (`FeedId.generate()` → 22-char base64url) used as the KV storage key and the public read id (`/rss/:feedId`), while the inbound address is a separate `MailboxId` VO (`noun.noun.NN`, the old format) resolved to its feed **only at reception** via a new `inbound:<mailboxId>` secondary index (`src/infrastructure/feed-repository.ts` `resolveInbound`). `MailboxId.parse` owns the untrusted-input boundary (moved off `FeedId`); the mailbox lives on `FeedState.mailboxId` / `mailbox_id` and is projected into `feeds:list`. Reading `/rss/<noun.noun.NN>` 404s and no public feed output contains the inbound address. Pre-release, so no migration/backward-compat. — _origin: [ktn#114](https://github.com/leafac/kill-the-newsletter/issues/114), [ktn#93](https://github.com/leafac/kill-the-newsletter/issues/93), [ktn#75](https://github.com/leafac/kill-the-newsletter/issues/75)_
- [ ] `P2·S` **Rotate the inbound mailbox and/or feed id**_follow-up to the write/read separation above_. Now that the inbound address (`MailboxId`) and the read id (`FeedId`) are decoupled, offer an admin + REST action to **re-mint** either one to revoke a leaked subscribe address or a shared feed URL. Rotating the mailbox: generate a new `MailboxId`, write the new `inbound:<new>` index, delete the old; rotating the read id is heavier (it's the KV storage key — would require re-keying `feed:<id>:*`, so prefer rotating only the mailbox first). Touch `feed-service.ts`, `feed-repository.ts`, admin UI, `api/index.ts`. — _origin: internal (privacy)_
- [ ] `P2·M` **Proxy/prefetch remote images** ([#69](https://github.com/leafac/kill-the-newsletter/issues/69)). We already proxy inline `cid:` images via R2, but remote `<img src="https://…">` stay remote → tracking pixels fire on read. Extend `src/infrastructure/html-processor.ts` to rewrite remote image src through a worker proxy/cache endpoint (reuse the R2 + Cache API pattern from favicons).