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
+10 -6
View File
@@ -32,7 +32,7 @@ Work **test-first (TDD)** and **domain-driven (DDD)** in this repo — both are
**TDD.** Write or extend a test before/with the change, then make it pass. Mirror the existing test layout (`*.test.ts` next to the source, `createMockEnv()` from `src/test/setup.ts`, MSW for outbound HTTP). End every change green: `npx tsc --noEmit`, `npm test`, and `npm run build` (dry-run deploy) must all pass before declaring done. **TDD.** Write or extend a test before/with the change, then make it pass. Mirror the existing test layout (`*.test.ts` next to the source, `createMockEnv()` from `src/test/setup.ts`, MSW for outbound HTTP). End every change green: `npx tsc --noEmit`, `npm test`, and `npm run build` (dry-run deploy) must all pass before declaring done.
**DDD.** Before adding logic, check whether the domain already models the concept — reach for the value objects in `src/domain/value-objects/` (`EmailAddress`, `Domain`, `FeedId`, `Lifetime`, `SenderPolicy`) and the `Feed` aggregate rather than re-deriving things ad hoc. New behavior belongs on the type that owns the data (e.g. "sender site URL" lives on `EmailAddress`, not in a helper). Respect the layering and aggregate rules below — imports point inward (routes → application → domain; infrastructure implements ports), and never reach across a layer for convenience (e.g. importing a favicon/infra helper just to parse a domain). When the same derivation appears twice, that's the signal to push it onto a domain type. **DDD.** Before adding logic, check whether the domain already models the concept — reach for the value objects in `src/domain/value-objects/` (`EmailAddress`, `Domain`, `FeedId`, `MailboxId`, `Lifetime`, `SenderPolicy`) and the `Feed` aggregate rather than re-deriving things ad hoc. New behavior belongs on the type that owns the data (e.g. "sender site URL" lives on `EmailAddress`, not in a helper). Respect the layering and aggregate rules below — imports point inward (routes → application → domain; infrastructure implements ports), and never reach across a layer for convenience (e.g. importing a favicon/infra helper just to parse a domain). When the same derivation appears twice, that's the signal to push it onto a domain type.
## Architecture ## Architecture
@@ -75,7 +75,7 @@ src/
events.ts # FeedEvent union (FeedCreated, EmailIngested) — each carries its feedId events.ts # FeedEvent union (FeedCreated, EmailIngested) — each carries its feedId
email-parser.ts # Email parsing (addresses, headers, encoded words) email-parser.ts # Email parsing (addresses, headers, encoded words)
format.ts # Pure formatting helpers (formatBytes) format.ts # Pure formatting helpers (formatBytes)
value-objects/ # FeedId, EmailAddress, Domain, SenderPolicy, Lifetime (immutable, self-validating) value-objects/ # FeedId (opaque read id), MailboxId (inbound noun.noun.NN), EmailAddress, Domain, SenderPolicy, Lifetime (immutable, self-validating)
application/ # Use-cases / orchestration (wires domain + infrastructure) application/ # Use-cases / orchestration (wires domain + infrastructure)
feed-service.ts # createFeedRecord / editFeedDetails / editFeed / deleteFeedRecord (admin UI + REST API) feed-service.ts # createFeedRecord / editFeedDetails / editFeed / deleteFeedRecord (admin UI + REST API)
feed-cleanup.ts # Feed/email storage cleanup: purgeFeedKeysStep, collectUnsubscribeUrls, attachment+key deletion feed-cleanup.ts # Feed/email storage cleanup: purgeFeedKeysStep, collectUnsubscribeUrls, attachment+key deletion
@@ -141,15 +141,18 @@ All data lives in the `EMAIL_STORAGE` KV namespace:
| Key | Value | | Key | Value |
| --------------------------- | ---------------------------------------------------------------------------------------------- | | --------------------------- | ---------------------------------------------------------------------------------------------- |
| `feeds:list` | `{ feeds: Array<{ id, title, description?, expires_at? }> }` | | `feeds:list` | `{ feeds: Array<{ id, title, description?, mailbox_id?, expires_at? }> }` |
| `feed:<feedId>:config` | `FeedConfig` | | `feed:<feedId>:config` | `FeedConfig` |
| `feed:<feedId>:metadata` | `{ emails: Array<{ key, subject, receivedAt, size?, attachmentIds?, inlineAttachmentIds? }> }` | | `feed:<feedId>:metadata` | `{ emails: Array<{ key, subject, receivedAt, size?, attachmentIds?, inlineAttachmentIds? }> }` |
| `feed:<feedId>:<timestamp>` | Full `EmailData` | | `feed:<feedId>:<timestamp>` | Full `EmailData` |
| `inbound:<mailboxId>` | The feed id this inbound address (`noun.noun.NN`) routes to (resolved only at reception) |
| `websub:subs:<feedId>` | `WebSubSubscription[]` (per-feed subscriber list) | | `websub:subs:<feedId>` | `WebSubSubscription[]` (per-feed subscriber list) |
| `icon:<domain>` | Cached favicon record (base64 + content type; negative entries allowed) | | `icon:<domain>` | Cached favicon record (base64 + content type; negative entries allowed) |
| `stats:counters` | `Counters` (cumulative monitoring counters singleton) | | `stats:counters` | `Counters` (cumulative monitoring counters singleton) |
The KV key schema lives in `src/domain/feed-keys.ts` (pure, framework-agnostic) — never inline a `feed:`/`feeds:list`/`websub:`/`icon:`/`stats:counters` key string anywhere else. KV access is owned by four repository **adapters** in `src/infrastructure/`, each for one concern: `FeedRepository` (the Feed aggregate + global list + email bodies), `IconRepository` (`icon:*`), `WebSubSubscriptionRepository` (`websub:subs:*`), and `CountersRepository` (`stats:counters`). Go through a repository, never `env.EMAIL_STORAGE.get/put` directly. The domain depends only on the key schema, not on these adapters. `feedId` is an **opaque random token** — the feed's identity, its KV storage key, and the public read id (`/rss/:feedId`). It is **decoupled** from the inbound email address: each feed also has a friendly `MailboxId` (`noun.noun.NN`) whose only mapping to the feed is the `inbound:<mailboxId>` secondary index, read **only** at email reception. So the feed's read URL never reveals its inbound address and vice-versa; reading `/rss/<noun.noun.NN>` 404s.
The KV key schema lives in `src/domain/feed-keys.ts` (pure, framework-agnostic) — never inline a `feed:`/`feeds:list`/`inbound:`/`websub:`/`icon:`/`stats:counters` key string anywhere else. KV access is owned by four repository **adapters** in `src/infrastructure/`, each for one concern: `FeedRepository` (the Feed aggregate + global list + email bodies), `IconRepository` (`icon:*`), `WebSubSubscriptionRepository` (`websub:subs:*`), and `CountersRepository` (`stats:counters`). Go through a repository, never `env.EMAIL_STORAGE.get/put` directly. The domain depends only on the key schema, not on these adapters.
### Domain & layering rules ### Domain & layering rules
@@ -158,11 +161,12 @@ The KV key schema lives in `src/domain/feed-keys.ts` (pure, framework-agnostic)
- **The domain never speaks the storage dialect.** The aggregate holds its config as domain `FeedState` (camelCase), never the snake_case `FeedConfig` DTO. The translation `FeedState ↔ FeedConfig/FeedListItem` lives in `infrastructure/feed-mapper.ts` — the only place outside the HTTP edge that knows the persisted field names. `FeedRepository.load` maps DTO→state on the way in; `save`/`saveConfig` map state→DTO on the way out. - **The domain never speaks the storage dialect.** The aggregate holds its config as domain `FeedState` (camelCase), never the snake_case `FeedConfig` DTO. The translation `FeedState ↔ FeedConfig/FeedListItem` lives in `infrastructure/feed-mapper.ts` — the only place outside the HTTP edge that knows the persisted field names. `FeedRepository.load` maps DTO→state on the way in; `save`/`saveConfig` map state→DTO on the way out.
- **The aggregate never exposes its raw state.** It has no `state`/`metadata` getters (a shallow `Readonly<…>` would still leak mutable arrays). Read named accessors (`title`, `expiresAt`, `emails`, `allowedSenders()`, …) which return copies; the repository reads `state()`/`toMetadataSnapshot()` (copies) and runs them through the mapper. - **The aggregate never exposes its raw state.** It has no `state`/`metadata` getters (a shallow `Readonly<…>` would still leak mutable arrays). Read named accessors (`title`, `expiresAt`, `emails`, `allowedSenders()`, …) which return copies; the repository reads `state()`/`toMetadataSnapshot()` (copies) and runs them through the mapper.
- **One edit path.** `edit(patch, { lifetime? })` is the single mutation for config. A `Lifetime` VO is resolved by the application (env `FEED_TTL_HOURS` override + client request); its **presence recomputes expiry, its absence preserves it** — which is exactly the dashboard's title/description quick-edit (no lifetime passed). It rejects an already-expired feed, so a quick-edit can no more touch an expired feed than a full edit can. - **One edit path.** `edit(patch, { lifetime? })` is the single mutation for config. A `Lifetime` VO is resolved by the application (env `FEED_TTL_HOURS` override + client request); its **presence recomputes expiry, its absence preserves it** — which is exactly the dashboard's title/description quick-edit (no lifetime passed). It rejects an already-expired feed, so a quick-edit can no more touch an expired feed than a full edit can.
- **`feeds:list` stays in sync automatically.** `FeedRepository.save`/`saveConfig` upsert the registry entry via `toListItemDTO(feed.id, feed.state())` — services never mirror title/description/expiry into the list by hand. - **`feeds:list` and the `inbound:` index stay in sync automatically.** `FeedRepository.save`/`saveConfig` upsert the registry entry via `toListItemDTO(feed.id, feed.state())` _and_ write the `inbound:<mailbox> → feedId` index — services never mirror title/description/expiry into the list by hand. Symmetrically, `removeFromList`/`removeFromListBulk` drop the inbound index (the mailbox is cached on the list item) — so the index lives outside the `feed:<id>:` prefix the key purge sweeps, but is still cleared wherever a feed leaves the list (`deleteFeedRecord`, bulk admin delete, the cron). `deleteFeedFastDetailed` only removes config+metadata; it does **not** touch the index.
- Read-only RSS/Atom rendering uses the `feed-fetcher` read model, not the aggregate (no invariant to enforce on the hot path). - Read-only RSS/Atom rendering uses the `feed-fetcher` read model, not the aggregate (no invariant to enforce on the hot path).
- KV has no multi-key transaction; the aggregate is the seam a future Durable Object would wrap to serialise concurrent ingests (see `email-processor.ts`). - KV has no multi-key transaction; the aggregate is the seam a future Durable Object would wrap to serialise concurrent ingests (see `email-processor.ts`).
- **Side effects via domain events.** Mutations with consequences record a `FeedEvent` (`FeedCreated`, `EmailIngested`), each carrying its own `feedId`. After persisting, the caller hands the aggregate to `application/feed-events.dispatchFeedEvents(feed, env, schedule)` — the single dispatch entry point that drains `pullEvents()` and runs the counters/WebSub/favicon. Don't pull events or thread the feed id by hand at call sites. Side effects with no aggregate mutation (a rejected email, feed deletion that bypasses the aggregate, bulk admin ops, the cron) stay imperative — they have no event to ride on. - **Side effects via domain events.** Mutations with consequences record a `FeedEvent` (`FeedCreated`, `EmailIngested`), each carrying its own `feedId`. After persisting, the caller hands the aggregate to `application/feed-events.dispatchFeedEvents(feed, env, schedule)` — the single dispatch entry point that drains `pullEvents()` and runs the counters/WebSub/favicon. Don't pull events or thread the feed id by hand at call sites. Side effects with no aggregate mutation (a rejected email, feed deletion that bypasses the aggregate, bulk admin ops, the cron) stay imperative — they have no event to ride on.
- **`FeedId` flows through the layers.** It is the identity type taken by the domain (`Feed.id`), the application use-cases (`editFeed`, `editFeedDetails`, `deleteFeedRecord`, `fetchFeedData`, the cleanup steps) and the infrastructure repositories/services (`FeedRepository`, `WebSubSubscriptionRepository`, `notifySubscribers`, …). Mint it **once** at the edge — `FeedId.parse(address)` for inbound email (validates), `FeedId.unchecked(param)` at the HTTP edge (no revalidation: a bad id just misses in KV and 404s), `FeedId.generate()` for a new feed — then pass the VO inward. Unwrap to `.value` (string) only at the true serialisation edges: URL builders (`urls.ts`), XML generation (`feed-generator.ts`), the KV key schema (`feed-keys.ts`), logs and JSON responses. - **`FeedId` flows through the layers.** It is the identity type taken by the domain (`Feed.id`), the application use-cases (`editFeed`, `editFeedDetails`, `deleteFeedRecord`, `fetchFeedData`, the cleanup steps) and the infrastructure repositories/services (`FeedRepository`, `WebSubSubscriptionRepository`, `notifySubscribers`, …). Mint it **once** at the edge — `FeedId.generate()` (opaque) for a new feed, `FeedId.unchecked(param)` at the read/HTTP edge (no revalidation: a bad id just misses in KV and 404s) — then pass the VO inward. Unwrap to `.value` (string) only at the true serialisation edges: URL builders (`urls.ts`), XML generation (`feed-generator.ts`), the KV key schema (`feed-keys.ts`), logs and JSON responses.
- **`FeedId` (read) vs `MailboxId` (write) are distinct identities.** `MailboxId` (`noun.noun.NN`) owns the inbound address and the untrusted-input boundary: `MailboxId.parse(address)` at reception is the only place an external string becomes a mailbox. Ingestion then resolves it to the feed via `FeedRepository.resolveInbound(mailbox)` and loads by the resulting `FeedId`. The mailbox is an attribute of the feed (held on `FeedState.mailboxId`, exposed as `Feed.mailboxId: MailboxId`, persisted as `mailbox_id`, projected into `feeds:list`); the `mailbox@domain` shape lives on the VO (`MailboxId.emailAddress(domain)`), with `urls.feedEmailAddress` only resolving the env domain. Never derive one id from the other — the decoupling is the privacy guarantee.
### Worker bindings (`Env`) ### Worker bindings (`Env`)
+9
View File
@@ -137,6 +137,15 @@ What gets forwarded vs dropped:
Expired feeds and blocked senders are dropped on purpose, so a real newsletter never leaks into your fallback inbox. Leave the variable unset to keep the original drop-and-log behavior. Expired feeds and blocked senders are dropped on purpose, so a real newsletter never leaks into your fallback inbox. Leave the variable unset to keep the original drop-and-log behavior.
### Inbound address vs feed URL
Each feed has **two independent identifiers**, on purpose:
- a friendly **inbound address** you subscribe newsletters with — `noun.noun.NN@yourdomain.com` (e.g. `apple.mountain.42@yourdomain.com`);
- an **opaque feed URL** for your reader — `https://yourdomain.com/rss/<random-id>` (also `/atom/<id>`, `/json/<id>`).
They are not derivable from each other. This means you can hand someone a feed URL without revealing the address that feeds it, and an address harvested by a newsletter sender can't be turned into your feed (requesting `/rss/<your-address>` returns 404). The admin dashboard shows both per feed; copy the address into signup forms and the feed URL into your reader. (Internally the inbound address is mapped to the feed by an `inbound:<address>` KV entry, resolved only when mail arrives.)
### Feed size limit ### Feed size limit
By default the worker keeps emails until the feed's stored data exceeds **512 KB**, then drops the oldest entries (and their KV records) to stay under the limit. This is more robust than a fixed entry count for HTML-heavy newsletters. By default the worker keeps emails until the feed's stored data exceeds **512 KB**, then drops the oldest entries (and their KV records) to stay under the limit. This is more robust than a fixed entry count for HTML-heavy newsletters.
+4 -3
View File
@@ -17,6 +17,7 @@ kill-the-news keeps the same workflow while avoiding shared domains and shared d
- Inline double-confirm delete interactions with toast feedback in the admin dashboard - Inline double-confirm delete interactions with toast feedback in the admin dashboard
- Resizable + sortable table columns in the admin dashboard (Table view) - Resizable + sortable table columns in the admin dashboard (Table view)
- Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`) - Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`)
- **Separate inbound address and feed URL** — the address you subscribe with (`apple.mountain.42@yourdomain.com`) and the public feed URL (`/rss/<opaque-id>`) use **independent** ids, so you can share a feed without leaking the address that feeds it, and an address harvested by a newsletter can't be used to read your feed (`/rss/<your-address>` 404s)
- Cloudflare Email Workers ingestion (no third-party service) - Cloudflare Email Workers ingestion (no third-party service)
- ForwardEmail webhook ingestion with source-IP verification (optional alternative) - ForwardEmail webhook ingestion with source-IP verification (optional alternative)
- Optional per-feed sender allowlist (`email@domain.com` or `domain.com`) - Optional per-feed sender allowlist (`email@domain.com` or `domain.com`)
@@ -45,9 +46,9 @@ Two ingestion methods are supported — pick one or use both:
Common path: Common path:
1. Incoming email arrives at `user@yourdomain.com`. 1. Incoming email arrives at `apple.mountain.42@yourdomain.com` (the feed's inbound address).
2. The Worker resolves the feed from the recipient address and stores the email in KV. 2. The Worker resolves the feed from the recipient address (via the `inbound:` index) and stores the email in KV.
3. `https://yourdomain.com/rss/:feedId` renders RSS from stored items. 3. `https://yourdomain.com/rss/<opaque-feed-id>` renders RSS from stored items — note the feed id is a separate opaque token, not the inbound address.
4. `/admin` provides feed management and email deletion. 4. `/admin` provides feed management and email deletion.
5. `https://yourdomain.com/` shows a public status page with monitoring counters and a link to the admin. 5. `https://yourdomain.com/` shows a public status page with monitoring counters and a link to the admin.
+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` **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). - [ ] `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).
+8
View File
@@ -778,6 +778,14 @@
<p>Your emails and feeds live exclusively in your own Cloudflare account. No shared infrastructure, no data mining.</p> <p>Your emails and feeds live exclusively in your own Cloudflare account. No shared infrastructure, no data mining.</p>
</div> </div>
<div class="feature-card">
<div class="feature-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>
</div>
<h3>Separate Address &amp; Feed URL</h3>
<p>Your subscribe address and your feed's reading URL are independent, unguessable ids. Share a feed without leaking the inbox that feeds it — and an address a newsletter harvests can never be turned into your feed.</p>
</div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon"> <div class="feature-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
+29 -5
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach } from "vitest";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { createMockEnv, MockR2, server } from "../test/setup"; import { createMockEnv, MockR2, seedInboundIndex, server } from "../test/setup";
import { import {
processEmail, processEmail,
ProcessEmailInput, ProcessEmailInput,
@@ -30,8 +30,10 @@ function makeInput(
describe("processEmail", () => { describe("processEmail", () => {
let env: ReturnType<typeof createMockEnv>; let env: ReturnType<typeof createMockEnv>;
beforeEach(() => { beforeEach(async () => {
env = createMockEnv(); env = createMockEnv();
// The inbound address resolves to a feed of the same id in these unit tests.
await seedInboundIndex(env, VALID_FEED_ID);
}); });
it("returns 400 when toAddress has no valid feedId", async () => { it("returns 400 when toAddress has no valid feedId", async () => {
@@ -42,7 +44,18 @@ describe("processEmail", () => {
expect(res).toMatchObject({ ok: false, reason: "invalid_address" }); expect(res).toMatchObject({ ok: false, reason: "invalid_address" });
}); });
it("returns 404 when feed does not exist", async () => { it("returns mailbox_unknown when no feed claims the inbound address", async () => {
// A well-formed mailbox (noun.noun.NN) that was never registered in the
// inbound index — distinct from a dangling index pointing at a missing feed.
const res = await processEmail(
makeInput({ toAddress: "unknown.mailbox.99@test.getmynews.app" }),
env as any,
);
expect(res).toMatchObject({ ok: false, reason: "mailbox_unknown" });
});
it("returns feed_not_found when the index resolves but the feed is gone", async () => {
// The inbound index is seeded (beforeEach) but no config exists for it.
const res = await processEmail(makeInput(), env as any); const res = await processEmail(makeInput(), env as any);
expect(res).toMatchObject({ ok: false, reason: "feed_not_found" }); expect(res).toMatchObject({ ok: false, reason: "feed_not_found" });
}); });
@@ -319,14 +332,14 @@ describe("processEmail", () => {
passThroughOnException: () => {}, passThroughOnException: () => {},
} as unknown as ExecutionContext; } as unknown as ExecutionContext;
// Feed ID is valid format but config doesn't exist → 404 // Well-formed mailbox but not registered → mailbox_unknown (an error path).
const res = await processEmail( const res = await processEmail(
makeInput({ toAddress: `no.such.99@test.getmynews.app` }), makeInput({ toAddress: `no.such.99@test.getmynews.app` }),
env as any, env as any,
ctx, ctx,
); );
expect(res).toMatchObject({ ok: false, reason: "feed_not_found" }); expect(res).toMatchObject({ ok: false, reason: "mailbox_unknown" });
expect(waitUntilCalled).toBe(false); expect(waitUntilCalled).toBe(false);
}); });
}); });
@@ -343,6 +356,7 @@ describe("processEmail — attachments", () => {
it("skips R2 upload when ATTACHMENT_BUCKET is not configured", async () => { it("skips R2 upload when ATTACHMENT_BUCKET is not configured", async () => {
const env = createMockEnv(); const env = createMockEnv();
await seedInboundIndex(env, VALID_FEED_ID);
await env.EMAIL_STORAGE.put( await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`, `feed:${VALID_FEED_ID}:config`,
JSON.stringify({}), JSON.stringify({}),
@@ -366,6 +380,7 @@ describe("processEmail — attachments", () => {
it("skips R2 upload when ATTACHMENTS_ENABLED is 'false' even with R2 bound", async () => { it("skips R2 upload when ATTACHMENTS_ENABLED is 'false' even with R2 bound", async () => {
const env = createMockEnv({ withR2: true }); const env = createMockEnv({ withR2: true });
await seedInboundIndex(env, VALID_FEED_ID);
(env as any).ATTACHMENTS_ENABLED = "false"; (env as any).ATTACHMENTS_ENABLED = "false";
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2; const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
await env.EMAIL_STORAGE.put( await env.EMAIL_STORAGE.put(
@@ -392,6 +407,7 @@ describe("processEmail — attachments", () => {
it("uploads attachments to R2 and stores AttachmentData in emailData", async () => { it("uploads attachments to R2 and stores AttachmentData in emailData", async () => {
const env = createMockEnv({ withR2: true }); const env = createMockEnv({ withR2: true });
await seedInboundIndex(env, VALID_FEED_ID);
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2; const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
await env.EMAIL_STORAGE.put( await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`, `feed:${VALID_FEED_ID}:config`,
@@ -423,6 +439,7 @@ describe("processEmail — attachments", () => {
it("stores attachmentIds in EmailMetadata for trim-time cleanup", async () => { it("stores attachmentIds in EmailMetadata for trim-time cleanup", async () => {
const env = createMockEnv({ withR2: true }); const env = createMockEnv({ withR2: true });
await seedInboundIndex(env, VALID_FEED_ID);
await env.EMAIL_STORAGE.put( await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`, `feed:${VALID_FEED_ID}:config`,
JSON.stringify({}), JSON.stringify({}),
@@ -439,6 +456,7 @@ describe("processEmail — attachments", () => {
it("classifies a cid-referenced image as inline, not a downloadable attachment", async () => { it("classifies a cid-referenced image as inline, not a downloadable attachment", async () => {
const env = createMockEnv({ withR2: true }); const env = createMockEnv({ withR2: true });
await seedInboundIndex(env, VALID_FEED_ID);
await env.EMAIL_STORAGE.put( await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`, `feed:${VALID_FEED_ID}:config`,
JSON.stringify({}), JSON.stringify({}),
@@ -484,6 +502,7 @@ describe("processEmail — attachments", () => {
it("deletes inline image R2 objects when a trimmed email had them", async () => { it("deletes inline image R2 objects when a trimmed email had them", async () => {
const env = createMockEnv({ withR2: true }); const env = createMockEnv({ withR2: true });
await seedInboundIndex(env, VALID_FEED_ID);
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2; const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
await env.EMAIL_STORAGE.put( await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`, `feed:${VALID_FEED_ID}:config`,
@@ -537,6 +556,7 @@ describe("processEmail — attachments", () => {
it("deletes R2 objects when a trimmed email had attachments", async () => { it("deletes R2 objects when a trimmed email had attachments", async () => {
const env = createMockEnv({ withR2: true }); const env = createMockEnv({ withR2: true });
await seedInboundIndex(env, VALID_FEED_ID);
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2; const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
await env.EMAIL_STORAGE.put( await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`, `feed:${VALID_FEED_ID}:config`,
@@ -604,6 +624,7 @@ describe("processEmail — deduplication", () => {
`feed:${VALID_FEED_ID}:config`, `feed:${VALID_FEED_ID}:config`,
JSON.stringify({}), JSON.stringify({}),
); );
await seedInboundIndex(env, VALID_FEED_ID);
}); });
it("stores only one email when the same Message-ID is delivered twice", async () => { it("stores only one email when the same Message-ID is delivered twice", async () => {
@@ -726,6 +747,7 @@ describe("processEmail — deduplication", () => {
describe("processEmail — monitoring counters", () => { describe("processEmail — monitoring counters", () => {
it("increments emails_received and sets last_email_at on success", async () => { it("increments emails_received and sets last_email_at on success", async () => {
const env = createMockEnv(); const env = createMockEnv();
await seedInboundIndex(env, VALID_FEED_ID);
await env.EMAIL_STORAGE.put( await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`, `feed:${VALID_FEED_ID}:config`,
JSON.stringify({}), JSON.stringify({}),
@@ -760,6 +782,7 @@ describe("processEmail — feed icon", () => {
`feed:${VALID_FEED_ID}:config`, `feed:${VALID_FEED_ID}:config`,
JSON.stringify({}), JSON.stringify({}),
); );
await seedInboundIndex(env, VALID_FEED_ID);
}); });
it("persists the latest sender domain on the feed metadata", async () => { it("persists the latest sender domain on the feed metadata", async () => {
@@ -811,6 +834,7 @@ describe("processEmail — unsubscribe capture", () => {
`feed:${VALID_FEED_ID}:config`, `feed:${VALID_FEED_ID}:config`,
JSON.stringify({}), JSON.stringify({}),
); );
await seedInboundIndex(env, VALID_FEED_ID);
}); });
it("stores the one-click unsubscribe URL on the feed metadata, keyed by sender", async () => { it("stores the one-click unsubscribe URL on the feed metadata, keyed by sender", async () => {
+22 -5
View File
@@ -1,4 +1,4 @@
import { EmailParser } from "../domain/email-parser"; import { MailboxId } from "../domain/value-objects/mailbox-id";
import { AttachmentData, EmailMetadata, Env } from "../types"; import { AttachmentData, EmailMetadata, Env } from "../types";
import { bumpCounters } from "../application/stats"; import { bumpCounters } from "../application/stats";
import { dispatchFeedEvents } from "../application/feed-events"; import { dispatchFeedEvents } from "../application/feed-events";
@@ -33,6 +33,7 @@ export interface ProcessEmailInput {
export type IngestRejectionReason = export type IngestRejectionReason =
| "invalid_address" | "invalid_address"
| "mailbox_unknown"
| "feed_not_found" | "feed_not_found"
| "feed_expired" | "feed_expired"
| "sender_blocked"; | "sender_blocked";
@@ -79,17 +80,33 @@ async function loadAcceptingFeed(
): Promise< ): Promise<
{ ok: true; feed: Feed } | { ok: false; reason: IngestRejectionReason } { ok: true; feed: Feed } | { ok: false; reason: IngestRejectionReason }
> { > {
const feedId = EmailParser.extractFeedId(input.toAddress); // MailboxId.parse is the single boundary where an untrusted inbound address
if (!feedId) { // (the most untrusted input in the system) becomes a validated mailbox.
const mailbox = MailboxId.parse(input.toAddress);
if (!mailbox) {
logger.error("Invalid email address format", { logger.error("Invalid email address format", {
toAddress: input.toAddress, toAddress: input.toAddress,
}); });
return { ok: false, reason: "invalid_address" }; return { ok: false, reason: "invalid_address" };
} }
const feed = await FeedRepository.from(env).load(feedId); // Resolve the inbound mailbox to the feed's opaque id (decoupled identities).
const repo = FeedRepository.from(env);
const feedId = await repo.resolveInbound(mailbox);
if (!feedId) {
// No feed claims this address — the common "wrong/unknown alias" case.
logger.error("Unknown inbound mailbox", { mailbox: mailbox.value });
return { ok: false, reason: "mailbox_unknown" };
}
const feed = await repo.load(feedId);
if (!feed) { if (!feed) {
logger.error("Feed not found", { feedId: feedId.value }); // The index resolved but the feed is gone — a dangling index (should be
// near-impossible now the index is dropped on feed deletion).
logger.error("Feed not found", {
mailbox: mailbox.value,
feedId: feedId.value,
});
return { ok: false, reason: "feed_not_found" }; return { ok: false, reason: "feed_not_found" };
} }
if (feed.isExpired()) { if (feed.isExpired()) {
+3
View File
@@ -21,6 +21,9 @@ export async function fetchFeedData(
title: `Newsletter Feed ${feedId.value}`, title: `Newsletter Feed ${feedId.value}`,
description: "Converted email newsletter", description: "Converted email newsletter",
language: "en", language: "en",
// Read-model fallback only: the RSS/Atom/JSON path never builds the inbound
// address, so an empty mailbox is inert here (the real one lives on config).
mailbox_id: "",
created_at: Date.now(), created_at: Date.now(),
}; };
+42 -1
View File
@@ -1,8 +1,15 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { createMockEnv } from "../test/setup"; import { createMockEnv } from "../test/setup";
import { createFeedRecord, editFeed } from "./feed-service"; import {
createFeedRecord,
editFeed,
deleteFeedRecord,
deleteFeedFastDetailed,
} from "./feed-service";
import { getCounters } from "./stats"; import { getCounters } from "./stats";
import { FeedRepository } from "../infrastructure/feed-repository";
import { FeedId } from "../domain/value-objects/feed-id"; import { FeedId } from "../domain/value-objects/feed-id";
import { MailboxId } from "../domain/value-objects/mailbox-id";
import type { Env } from "../types"; import type { Env } from "../types";
const mkEnv = (overrides: Partial<Env> = {}) => const mkEnv = (overrides: Partial<Env> = {}) =>
@@ -54,6 +61,40 @@ describe("createFeedRecord — TTL policy", () => {
}); });
}); });
describe("deleting a feed drops its inbound mailbox index", () => {
it("deleteFeedRecord removes the inbound index so the address stops resolving", async () => {
const env = mkEnv();
const { feedId, mailboxId } = await createFeedRecord(env, { ...baseInput });
const repo = FeedRepository.from(env);
// Sanity: the address resolves to the feed before deletion.
expect(
(await repo.resolveInbound(MailboxId.unchecked(mailboxId)))?.value,
).toBe(feedId);
await deleteFeedRecord(env, FeedId.unchecked(feedId), () => {});
expect(
await repo.resolveInbound(MailboxId.unchecked(mailboxId)),
).toBeNull();
});
it("the bulk path (deleteFeedFastDetailed + removeFromListBulk) clears the inbound index", async () => {
const env = mkEnv();
const { feedId, mailboxId } = await createFeedRecord(env, { ...baseInput });
const repo = FeedRepository.from(env);
// The bulk admin path drops config/metadata, then removes from the list —
// the latter is what clears the inbound index (symmetric with save()).
await deleteFeedFastDetailed(env.EMAIL_STORAGE, FeedId.unchecked(feedId));
await repo.removeFromListBulk([feedId]);
expect(
await repo.resolveInbound(MailboxId.unchecked(mailboxId)),
).toBeNull();
});
});
describe("editFeed — TTL policy", () => { describe("editFeed — TTL policy", () => {
it("recomputes expiry from the server override on edit", async () => { it("recomputes expiry from the server override on edit", async () => {
const env = mkEnv({ FEED_TTL_HOURS: "1" }); const env = mkEnv({ FEED_TTL_HOURS: "1" });
+18 -6
View File
@@ -7,6 +7,7 @@ import { FeedRepository } from "../infrastructure/feed-repository";
import { toConfigDTO } from "../infrastructure/feed-mapper"; import { toConfigDTO } from "../infrastructure/feed-mapper";
import { BackgroundScheduler } from "../infrastructure/worker"; import { BackgroundScheduler } from "../infrastructure/worker";
import { FeedId } from "../domain/value-objects/feed-id"; import { FeedId } from "../domain/value-objects/feed-id";
import { MailboxId } from "../domain/value-objects/mailbox-id";
import { Lifetime } from "../domain/value-objects/lifetime"; import { Lifetime } from "../domain/value-objects/lifetime";
import { import {
Feed, Feed,
@@ -33,24 +34,32 @@ function resolveLifetime(env: Env, requestedHours?: number): Lifetime {
} }
/** /**
* Create a feed: write its config + empty metadata, register it in the global * Create a feed: mint an opaque `FeedId` (the read id) and a friendly `MailboxId`
* list, and bump the `feeds_created` counter. Returns the new feed id + config. * (the inbound address), write its config + empty metadata, register it in the
* global list + inbound index, and bump the `feeds_created` counter. Returns the
* new feed id, its mailbox, and config.
*/ */
export async function createFeedRecord( export async function createFeedRecord(
env: Env, env: Env,
input: CreateFeedInput, input: CreateFeedInput,
): Promise<{ feedId: string; config: FeedConfig }> { ): Promise<{ feedId: string; mailboxId: string; config: FeedConfig }> {
const repo = FeedRepository.from(env); const repo = FeedRepository.from(env);
const feed = Feed.create(FeedId.generate(), input, { const feed = Feed.create(FeedId.generate(), input, {
mailboxId: MailboxId.generate(),
lifetime: resolveLifetime(env, input.lifetimeHours), lifetime: resolveLifetime(env, input.lifetimeHours),
}); });
// save() also writes the inbound:<mailbox> → feedId index.
await repo.save(feed); await repo.save(feed);
// FeedCreated → bumps the feeds_created counter (no background work to schedule). // FeedCreated → bumps the feeds_created counter (no background work to schedule).
await dispatchFeedEvents(feed, env, () => {}); await dispatchFeedEvents(feed, env, () => {});
return { feedId: feed.id.value, config: toConfigDTO(feed.state()) }; return {
feedId: feed.id.value,
mailboxId: feed.mailboxId.value,
config: toConfigDTO(feed.state()),
};
} }
export type UpdateFeedResult = export type UpdateFeedResult =
@@ -118,8 +127,10 @@ type DeleteFeedFastResult = {
}; };
/** /**
* Delete a feed's config + metadata keys, reporting per-key outcomes. The * Delete a feed's config + metadata keys, reporting per-key outcomes. The larger
* larger email/attachment cleanup is handled separately via purgeFeedKeysStep. * email/attachment cleanup is handled separately via purgeFeedKeysStep, and the
* inbound `inbound:<mailbox>` index is dropped by `removeFromList(Bulk)` (which
* every caller invokes next) symmetric with `save()` writing it.
*/ */
export async function deleteFeedFastDetailed( export async function deleteFeedFastDetailed(
emailStorage: KVNamespace, emailStorage: KVNamespace,
@@ -166,6 +177,7 @@ export async function deleteFeedRecord(
const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId); const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId);
await deleteFeedFastDetailed(emailStorage, feedId); await deleteFeedFastDetailed(emailStorage, feedId);
// removeFromList also drops the feed's inbound mailbox index.
const removed = await repo.removeFromList(feedId); const removed = await repo.removeFromList(feedId);
if (removed) { if (removed) {
await bumpCounters(emailStorage, { feeds_deleted: 1 }); await bumpCounters(emailStorage, { feeds_deleted: 1 });
+2 -31
View File
@@ -1,37 +1,8 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { EmailParser } from "./email-parser"; import { EmailParser } from "./email-parser";
describe("EmailParser.extractFeedId", () => { // Inbound mailbox parsing lives on the MailboxId VO (see mailbox-id.test.ts);
it("extracts a valid feed ID from an email address", () => { // EmailParser no longer wraps it.
expect(
EmailParser.extractFeedId("river.castle.42@example.com")?.value,
).toBe("river.castle.42");
});
it("is case-insensitive for the local part", () => {
expect(
EmailParser.extractFeedId("River.Castle.42@example.com")?.value,
).toBe("River.Castle.42");
});
it("returns null for an address with no feed ID format", () => {
expect(EmailParser.extractFeedId("user@example.com")).toBeNull();
});
it("returns null for a plain string without @", () => {
expect(EmailParser.extractFeedId("notanemail")).toBeNull();
});
it("returns null when the numeric suffix is only one digit", () => {
expect(EmailParser.extractFeedId("river.castle.4@example.com")).toBeNull();
});
it("returns null when the numeric suffix has more than two digits", () => {
expect(
EmailParser.extractFeedId("river.castle.123@example.com"),
).toBeNull();
});
});
describe("EmailParser.decodeEncodedWords", () => { describe("EmailParser.decodeEncodedWords", () => {
it("returns plain text unchanged", () => { it("returns plain text unchanged", () => {
-11
View File
@@ -1,17 +1,6 @@
import { EmailData } from "../types"; import { EmailData } from "../types";
import { FeedId } from "./value-objects/feed-id";
export class EmailParser { export class EmailParser {
/**
* Extract the feed id from an inbound recipient address. Returns a validated
* `FeedId` value object (not a raw string) so the most untrusted input in the
* system an address typed by a sender is guarded at the parse boundary and
* never needs `FeedId.unchecked` downstream.
*/
static extractFeedId(emailAddress: string): FeedId | null {
return FeedId.parse(emailAddress);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
static parseForwardEmailPayload(payload: any): EmailData { static parseForwardEmailPayload(payload: any): EmailData {
if (!payload) { if (!payload) {
+3
View File
@@ -13,6 +13,9 @@ export const feedKeys = {
config: (feedId: string): string => `feed:${feedId}:config`, config: (feedId: string): string => `feed:${feedId}:config`,
metadata: (feedId: string): string => `feed:${feedId}:metadata`, metadata: (feedId: string): string => `feed:${feedId}:metadata`,
/** Secondary index: inbound mailbox local part → feed id (resolved at reception). */
inbound: (mailboxId: string): string => `inbound:${mailboxId}`,
/** Prefix covering every key owned by a feed (config, metadata, emails). */ /** Prefix covering every key owned by a feed (config, metadata, emails). */
feedPrefix: (feedId: string): string => `feed:${feedId}:`, feedPrefix: (feedId: string): string => `feed:${feedId}:`,
+3
View File
@@ -11,6 +11,9 @@ export interface FeedState {
title: string; title: string;
description?: string; description?: string;
language: string; language: string;
/** The feed's inbound mailbox local part (`noun.noun.NN`) its email address
* is `mailboxId@domain`. Decoupled from the feed's `FeedId` (the read id). */
mailboxId: string;
author?: string; author?: string;
allowedSenders: string[]; allowedSenders: string[];
blockedSenders: string[]; blockedSenders: string[];
+30 -12
View File
@@ -3,12 +3,14 @@ import { createMockEnv } from "../test/setup";
import { Feed, CreateFeedInput } from "./feed.aggregate"; import { Feed, CreateFeedInput } from "./feed.aggregate";
import { FeedRepository } from "../infrastructure/feed-repository"; import { FeedRepository } from "../infrastructure/feed-repository";
import { FeedId } from "./value-objects/feed-id"; import { FeedId } from "./value-objects/feed-id";
import { MailboxId } from "./value-objects/mailbox-id";
import { Lifetime } from "./value-objects/lifetime"; import { Lifetime } from "./value-objects/lifetime";
import { FeedState } from "./feed-state"; import { FeedState } from "./feed-state";
import { Clock } from "./clock"; import { Clock } from "./clock";
import type { Env, EmailMetadata } from "../types"; import type { Env, EmailMetadata } from "../types";
const FID = FeedId.unchecked("a.b.42"); const FID = FeedId.unchecked("opaque-feed-id");
const MBOX = MailboxId.unchecked("a.b.42");
const mockEnv = () => createMockEnv() as unknown as Env; const mockEnv = () => createMockEnv() as unknown as Env;
@@ -27,6 +29,7 @@ const createInput = (
const state = (overrides: Partial<FeedState> = {}): FeedState => ({ const state = (overrides: Partial<FeedState> = {}): FeedState => ({
title: "T", title: "T",
language: "en", language: "en",
mailboxId: "a.b.42",
allowedSenders: [], allowedSenders: [],
blockedSenders: [], blockedSenders: [],
createdAt: 0, createdAt: 0,
@@ -43,8 +46,9 @@ const entry = (overrides: Partial<EmailMetadata> = {}): EmailMetadata => ({
describe("Feed.create", () => { describe("Feed.create", () => {
it("builds a config with an empty email index and no expiry by default", () => { it("builds a config with an empty email index and no expiry by default", () => {
const feed = Feed.create(FID, createInput()); const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
expect(feed.id.value).toBe("a.b.42"); expect(feed.id.value).toBe("opaque-feed-id");
expect(feed.mailboxId.value).toBe("a.b.42");
expect(feed.title).toBe("News"); expect(feed.title).toBe("News");
expect(feed.expiresAt).toBeUndefined(); expect(feed.expiresAt).toBeUndefined();
expect(feed.emails).toEqual([]); expect(feed.emails).toEqual([]);
@@ -53,6 +57,7 @@ describe("Feed.create", () => {
it("resolves expiry from the supplied lifetime using the injected clock", () => { it("resolves expiry from the supplied lifetime using the injected clock", () => {
const NOW = 1_000_000; const NOW = 1_000_000;
const feed = Feed.create(FID, createInput(), { const feed = Feed.create(FID, createInput(), {
mailboxId: MBOX,
clock: fixedClock(NOW), clock: fixedClock(NOW),
lifetime: Lifetime.ofHours(2), lifetime: Lifetime.ofHours(2),
}); });
@@ -64,18 +69,24 @@ describe("Feed.create", () => {
it("trusts only deps.lifetime, not the client lifetimeHours field", () => { it("trusts only deps.lifetime, not the client lifetimeHours field", () => {
// The aggregate no longer parses lifetime policy: the application resolves // The aggregate no longer parses lifetime policy: the application resolves
// the effective Lifetime (env override etc.) and hands it in. // the effective Lifetime (env override etc.) and hands it in.
const feed = Feed.create(FID, createInput({ lifetimeHours: 9999 })); const feed = Feed.create(FID, createInput({ lifetimeHours: 9999 }), {
mailboxId: MBOX,
});
expect(feed.expiresAt).toBeUndefined(); expect(feed.expiresAt).toBeUndefined();
}); });
it("treats a non-positive lifetime as no expiry", () => { it("treats a non-positive lifetime as no expiry", () => {
expect( expect(
Feed.create(FID, createInput(), { lifetime: Lifetime.ofHours(0) }) Feed.create(FID, createInput(), {
.expiresAt, mailboxId: MBOX,
lifetime: Lifetime.ofHours(0),
}).expiresAt,
).toBeUndefined(); ).toBeUndefined();
expect( expect(
Feed.create(FID, createInput(), { lifetime: Lifetime.ofHours(-5) }) Feed.create(FID, createInput(), {
.expiresAt, mailboxId: MBOX,
lifetime: Lifetime.ofHours(-5),
}).expiresAt,
).toBeUndefined(); ).toBeUndefined();
}); });
}); });
@@ -191,7 +202,7 @@ describe("Feed.removeEmails", () => {
describe("Feed events", () => { describe("Feed events", () => {
it("records FeedCreated on create and drains it once", () => { it("records FeedCreated on create and drains it once", () => {
const feed = Feed.create(FID, createInput()); const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
expect(feed.pullEvents()).toEqual([{ type: "FeedCreated", feedId: FID }]); expect(feed.pullEvents()).toEqual([{ type: "FeedCreated", feedId: FID }]);
// Draining clears: a second pull is empty. // Draining clears: a second pull is empty.
expect(feed.pullEvents()).toEqual([]); expect(feed.pullEvents()).toEqual([]);
@@ -225,18 +236,25 @@ describe("Feed events", () => {
describe("FeedRepository.load / save round-trip", () => { describe("FeedRepository.load / save round-trip", () => {
it("persists a created feed and reflects later mutations", async () => { it("persists a created feed and reflects later mutations", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
const created = Feed.create(FID, createInput({ title: "Round" })); const created = Feed.create(FID, createInput({ title: "Round" }), {
mailboxId: MBOX,
});
await repo.save(created); await repo.save(created);
const loaded = await repo.load(FID); const loaded = await repo.load(FID);
expect(loaded).not.toBeNull(); expect(loaded).not.toBeNull();
expect(loaded!.title).toBe("Round"); expect(loaded!.title).toBe("Round");
expect(loaded!.mailboxId.value).toBe("a.b.42");
loaded!.ingest(entry({ key: "feed:a.b.42:1" }), { maxBytes: 1_000_000 }); loaded!.ingest(entry({ key: "feed:opaque-feed-id:1" }), {
maxBytes: 1_000_000,
});
await repo.saveMetadata(loaded!); await repo.saveMetadata(loaded!);
const reloaded = await repo.load(FID); const reloaded = await repo.load(FID);
expect(reloaded!.emails.map((e) => e.key)).toEqual(["feed:a.b.42:1"]); expect(reloaded!.emails.map((e) => e.key)).toEqual([
"feed:opaque-feed-id:1",
]);
}); });
it("returns null when the feed has no config", async () => { it("returns null when the feed has no config", async () => {
+10 -1
View File
@@ -1,6 +1,7 @@
import { FeedMetadata, EmailMetadata } from "../types"; import { FeedMetadata, EmailMetadata } from "../types";
import { FeedState } from "./feed-state"; import { FeedState } from "./feed-state";
import { FeedId } from "./value-objects/feed-id"; import { FeedId } from "./value-objects/feed-id";
import { MailboxId } from "./value-objects/mailbox-id";
import { Lifetime } from "./value-objects/lifetime"; import { Lifetime } from "./value-objects/lifetime";
import { SenderPolicy, SenderDecision } from "./value-objects/sender-policy"; import { SenderPolicy, SenderDecision } from "./value-objects/sender-policy";
import { Clock, systemClock } from "./clock"; import { Clock, systemClock } from "./clock";
@@ -32,6 +33,8 @@ export interface UpdateFeedInput {
* applying any server-side `FEED_TTL_HOURS` override and hands the VO in. * applying any server-side `FEED_TTL_HOURS` override and hands the VO in.
*/ */
export interface CreateFeedDeps { export interface CreateFeedDeps {
/** The feed's inbound mailbox, minted by the application alongside its FeedId. */
mailboxId: MailboxId;
clock?: Clock; clock?: Clock;
/** Effective lifetime, already resolved by the application. */ /** Effective lifetime, already resolved by the application. */
lifetime?: Lifetime; lifetime?: Lifetime;
@@ -82,7 +85,7 @@ export class Feed {
static create( static create(
id: FeedId, id: FeedId,
input: CreateFeedInput, input: CreateFeedInput,
deps: CreateFeedDeps = {}, deps: CreateFeedDeps,
): Feed { ): Feed {
const clock = deps.clock ?? systemClock; const clock = deps.clock ?? systemClock;
const now = clock.now(); const now = clock.now();
@@ -91,6 +94,7 @@ export class Feed {
title: input.title, title: input.title,
description: input.description, description: input.description,
language: input.language, language: input.language,
mailboxId: deps.mailboxId.value,
allowedSenders: input.allowedSenders, allowedSenders: input.allowedSenders,
blockedSenders: input.blockedSenders, blockedSenders: input.blockedSenders,
createdAt: now, createdAt: now,
@@ -130,6 +134,11 @@ export class Feed {
return this._state.language; return this._state.language;
} }
/** The inbound mailbox (`noun.noun.NN`) — the feed's email address is `mailboxId@domain`. */
get mailboxId(): MailboxId {
return MailboxId.unchecked(this._state.mailboxId);
}
get createdAt(): number { get createdAt(): number {
return this._state.createdAt; return this._state.createdAt;
} }
+17 -26
View File
@@ -1,36 +1,27 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { FeedId } from "./feed-id"; import { FeedId } from "./feed-id";
describe("FeedId.parse", () => {
it("extracts the feed id from an inbound address", () => {
expect(FeedId.parse("river.castle.42@example.com")?.value).toBe(
"river.castle.42",
);
});
it("preserves the original casing of the local part", () => {
expect(FeedId.parse("River.Castle.42@example.com")?.value).toBe(
"River.Castle.42",
);
});
it("rejects malformed feed ids", () => {
expect(FeedId.parse("user@example.com")).toBeNull();
expect(FeedId.parse("notanemail")).toBeNull();
expect(FeedId.parse("river.castle.4@example.com")).toBeNull();
expect(FeedId.parse("river.castle.123@example.com")).toBeNull();
});
});
describe("FeedId.generate", () => { describe("FeedId.generate", () => {
it("produces the noun.noun.NN format", () => { it("produces an opaque base64url token", () => {
for (let i = 0; i < 50; i++) { for (let i = 0; i < 50; i++) {
expect(FeedId.generate().value).toMatch(/^[a-z]+\.[a-z]+\.\d{2}$/); expect(FeedId.generate().value).toMatch(/^[A-Za-z0-9_-]{22}$/);
} }
}); });
it("round-trips through parse from an address", () => { it("is unguessable: 50 ids are all distinct", () => {
const id = FeedId.generate(); const ids = new Set(
expect(FeedId.parse(`${id.value}@example.com`)?.value).toBe(id.value); Array.from({ length: 50 }, () => FeedId.generate().value),
);
expect(ids.size).toBe(50);
});
it("does not produce the legacy noun.noun.NN format", () => {
expect(FeedId.generate().value).not.toMatch(/^[a-z]+\.[a-z]+\.\d{2}$/);
});
});
describe("FeedId.unchecked", () => {
it("wraps a value without validation", () => {
expect(FeedId.unchecked("anything").value).toBe("anything");
}); });
}); });
+23 -19
View File
@@ -1,37 +1,41 @@
import { nouns } from "../../data/nouns"; /** Encode bytes as unpadded base64url (URL- and KV-key-safe). */
function base64url(bytes: Uint8Array): string {
// Feed IDs are noun1.noun2.XY (two lowercase nouns + a 2-digit suffix). let binary = "";
const FEED_ID_IN_ADDRESS = /^([a-z]+\.[a-z]+\.\d{2})@/i; for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
/** /**
* A feed identifier. `parse` pulls it from the local part of an inbound email * A feed's identity: the KV storage key AND the public read id in `/rss/:feedId`
* address; `generate` mints a fresh one. The original casing is preserved. * etc. It is an opaque, high-entropy random token unguessable, so a feed's read
* URL can be shared without revealing its inbound address (the `MailboxId`, a
* separate value that resolves here via the `inbound:` index at reception).
*
* `generate` mints a fresh token; `unchecked` wraps a route param or stored key
* without revalidation (a wrong id simply misses in KV and 404s downstream).
*/ */
export class FeedId { export class FeedId {
private constructor(readonly value: string) {} private constructor(readonly value: string) {}
/** Extract the feed id from an inbound address (`noun.noun.NN@domain`). */
static parse(emailAddress: string): FeedId | null {
const match = emailAddress.match(FEED_ID_IN_ADDRESS);
return match ? new FeedId(match[1]) : null;
}
/** /**
* Wrap a string as a FeedId WITHOUT revalidating it. The caller asserts the id * Wrap a string as a FeedId WITHOUT revalidating it. The caller asserts the id
* originated from our own minting a route param echoing a stored id, a * originated from our own minting a route param echoing a stored id, a
* `feeds:list` entry, or an email/KV key. The name is deliberately blunt: a * `feeds:list` entry, an `inbound:` index value, or a KV key. The name is
* wrong id is not rejected here, it simply misses in KV and 404s downstream. * deliberately blunt: a wrong id is not rejected here, it simply misses in KV
* Untrusted external input (an inbound address) must go through `parse` instead. * and 404s downstream.
*/ */
static unchecked(value: string): FeedId { static unchecked(value: string): FeedId {
return new FeedId(value); return new FeedId(value);
} }
/** Mint a fresh, opaque identity (128 bits of entropy → 22 base64url chars). */
static generate(): FeedId { static generate(): FeedId {
const noun1 = nouns[Math.floor(Math.random() * nouns.length)]; const bytes = new Uint8Array(16);
const noun2 = nouns[Math.floor(Math.random() * nouns.length)]; crypto.getRandomValues(bytes);
const number = Math.floor(Math.random() * 90) + 10; return new FeedId(base64url(bytes));
return new FeedId(`${noun1}.${noun2}.${number}`);
} }
toString(): string { toString(): string {
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { MailboxId } from "./mailbox-id";
describe("MailboxId.parse", () => {
it("extracts the mailbox id from an inbound address", () => {
expect(MailboxId.parse("river.castle.42@example.com")?.value).toBe(
"river.castle.42",
);
});
it("preserves the original casing of the local part", () => {
expect(MailboxId.parse("River.Castle.42@example.com")?.value).toBe(
"River.Castle.42",
);
});
it("rejects malformed mailbox ids", () => {
expect(MailboxId.parse("user@example.com")).toBeNull();
expect(MailboxId.parse("notanemail")).toBeNull();
expect(MailboxId.parse("river.castle.4@example.com")).toBeNull();
expect(MailboxId.parse("river.castle.123@example.com")).toBeNull();
});
});
describe("MailboxId.generate", () => {
it("produces the noun.noun.NN format", () => {
for (let i = 0; i < 50; i++) {
expect(MailboxId.generate().value).toMatch(/^[a-z]+\.[a-z]+\.\d{2}$/);
}
});
it("round-trips through parse from an address", () => {
const id = MailboxId.generate();
expect(MailboxId.parse(`${id.value}@example.com`)?.value).toBe(id.value);
});
});
describe("MailboxId.unchecked", () => {
it("wraps a value without validation", () => {
expect(MailboxId.unchecked("anything").value).toBe("anything");
});
});
describe("MailboxId.emailAddress", () => {
it("builds the full inbound address from the mailbox and a domain", () => {
expect(
MailboxId.unchecked("river.castle.42").emailAddress("news.app"),
).toBe("river.castle.42@news.app");
});
});
+49
View File
@@ -0,0 +1,49 @@
import { nouns } from "../../data/nouns";
// Inbound mailbox ids are noun1.noun2.XY (two lowercase nouns + a 2-digit suffix).
const MAILBOX_IN_ADDRESS = /^([a-z]+\.[a-z]+\.\d{2})@/i;
/**
* A feed's inbound mailbox identifier the friendly `noun.noun.NN` local part of
* the address newsletters are sent to (`<mailboxId>@domain`). It is deliberately
* NOT the feed's identity: a `MailboxId` resolves to a `FeedId` through the
* `inbound:` index at reception, so the public read URL (the opaque `FeedId`) and
* the inbound address stay decoupled.
*
* `parse` pulls it from an untrusted inbound address (the most untrusted input in
* the system); `generate` mints a fresh one. The original casing is preserved.
*/
export class MailboxId {
private constructor(readonly value: string) {}
/** Extract the mailbox id from an inbound address (`noun.noun.NN@domain`). */
static parse(emailAddress: string): MailboxId | null {
const match = emailAddress.match(MAILBOX_IN_ADDRESS);
return match ? new MailboxId(match[1]) : null;
}
/**
* Wrap a string as a MailboxId WITHOUT revalidating it. The caller asserts the
* value originated from our own minting (a stored `mailbox_id`). Untrusted
* external input (an inbound address) must go through `parse` instead.
*/
static unchecked(value: string): MailboxId {
return new MailboxId(value);
}
static generate(): MailboxId {
const noun1 = nouns[Math.floor(Math.random() * nouns.length)];
const noun2 = nouns[Math.floor(Math.random() * nouns.length)];
const number = Math.floor(Math.random() * 90) + 10;
return new MailboxId(`${noun1}.${noun2}.${number}`);
}
/** The full inbound email address (`<mailboxId>@<domain>`) newsletters target. */
emailAddress(domain: string): string {
return `${this.value}@${domain}`;
}
toString(): string {
return this.value;
}
}
+42
View File
@@ -1,10 +1,19 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import worker from "./index"; import worker from "./index";
import { createMockEnv } from "./test/setup"; import { createMockEnv } from "./test/setup";
import { createFeedRecord } from "./application/feed-service";
import { FeedRepository } from "./infrastructure/feed-repository";
import { FeedId } from "./domain/value-objects/feed-id";
import { MailboxId } from "./domain/value-objects/mailbox-id";
import type { Env } from "./types"; import type { Env } from "./types";
const env = createMockEnv(); const env = createMockEnv();
const noopCtx = {
waitUntil: () => {},
passThroughOnException: () => {},
} as unknown as ExecutionContext;
function req(path: string, init: RequestInit = {}): Request { function req(path: string, init: RequestInit = {}): Request {
return new Request(`https://test.getmynews.app${path}`, init); return new Request(`https://test.getmynews.app${path}`, init);
} }
@@ -55,6 +64,39 @@ describe("CORS middleware", () => {
}); });
}); });
describe("scheduled (cron) TTL cleanup", () => {
it("drops the inbound mailbox index when an expired feed is purged", async () => {
const cronEnv = createMockEnv() as unknown as Env;
const { feedId, mailboxId } = await createFeedRecord(cronEnv, {
title: "Expiring",
language: "en",
allowedSenders: [],
blockedSenders: [],
});
const repo = FeedRepository.from(cronEnv);
// The address resolves to the feed before the cron runs.
expect(
(await repo.resolveInbound(MailboxId.unchecked(mailboxId)))?.value,
).toBe(feedId);
// Backdate the feed so the cron treats it as expired.
const list = (await cronEnv.EMAIL_STORAGE.get("feeds:list", "json")) as {
feeds: Array<{ id: string; expires_at?: number; mailbox_id?: string }>;
};
list.feeds[0].expires_at = Date.now() - 1000;
await cronEnv.EMAIL_STORAGE.put("feeds:list", JSON.stringify(list));
await worker.scheduled({} as ScheduledEvent, cronEnv, noopCtx);
// The feed is gone AND its inbound address no longer resolves.
expect(await repo.getConfig(FeedId.unchecked(feedId))).toBeNull();
expect(
await repo.resolveInbound(MailboxId.unchecked(mailboxId)),
).toBeNull();
});
});
describe("GET /robots.txt", () => { describe("GET /robots.txt", () => {
it("returns 200 and disallows the private feed/entry paths", async () => { it("returns 200 and disallows the private feed/entry paths", async () => {
const res = await worker.fetch(req("/robots.txt"), env as unknown as Env); const res = await worker.fetch(req("/robots.txt"), env as unknown as Env);
+1
View File
@@ -228,6 +228,7 @@ export default {
); );
} }
if (expiredIds.length > 0) { if (expiredIds.length > 0) {
// removeFromListBulk also drops each feed's inbound mailbox index.
await repo.removeFromListBulk(expiredIds); await repo.removeFromListBulk(expiredIds);
await bumpCounters(env.EMAIL_STORAGE, { await bumpCounters(env.EMAIL_STORAGE, {
feeds_deleted: expiredIds.length, feeds_deleted: expiredIds.length,
+3 -2
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach } from "vitest";
import "../test/setup"; import "../test/setup";
import { createMockEnv } from "../test/setup"; import { createMockEnv, seedInboundIndex } from "../test/setup";
import { handleCloudflareEmail } from "./cloudflare-email"; import { handleCloudflareEmail } from "./cloudflare-email";
import { getCounters } from "../application/stats"; import { getCounters } from "../application/stats";
@@ -62,8 +62,9 @@ const FALLBACK = "fallback@personal.example";
describe("handleCloudflareEmail", () => { describe("handleCloudflareEmail", () => {
let env: ReturnType<typeof createMockEnv>; let env: ReturnType<typeof createMockEnv>;
beforeEach(() => { beforeEach(async () => {
env = createMockEnv(); env = createMockEnv();
await seedInboundIndex(env, VALID_FEED_ID);
}); });
it("stores email in KV when feed exists", async () => { it("stores email in KV when feed exists", async () => {
+1
View File
@@ -68,6 +68,7 @@ export async function handleCloudflareEmail(
// dropped so a real newsletter never leaks into the fallback inbox. // dropped so a real newsletter never leaks into the fallback inbox.
const FORWARDABLE_REASONS = new Set<IngestRejectionReason>([ const FORWARDABLE_REASONS = new Set<IngestRejectionReason>([
"invalid_address", "invalid_address",
"mailbox_unknown",
"feed_not_found", "feed_not_found",
]); ]);
+7 -4
View File
@@ -10,6 +10,7 @@ const mockFeedConfig: FeedConfig = {
title: "Test Newsletter", title: "Test Newsletter",
description: "A test feed", description: "A test feed",
language: "en", language: "en",
mailbox_id: "test.news.42",
created_at: 1700000000000, created_at: 1700000000000,
}; };
@@ -146,14 +147,15 @@ describe("generateRssFeed", () => {
expect(result).not.toContain("<item>"); expect(result).not.toContain("<item>");
}); });
it("feed link points to admin emails page", () => { it("feed link points to the public read URL, never an admin path", () => {
const result = generateRssFeed( const result = generateRssFeed(
mockFeedConfig, mockFeedConfig,
mockEmails, mockEmails,
BASE_URL, BASE_URL,
FEED_ID, FEED_ID,
); );
expect(result).toContain(`${BASE_URL}/admin/feeds/${FEED_ID}/emails`); expect(result).toContain(`<link>${BASE_URL}/rss/${FEED_ID}</link>`);
expect(result).not.toContain("/admin/");
}); });
it("strips html/head/body wrapper from item description", () => { it("strips html/head/body wrapper from item description", () => {
@@ -263,14 +265,15 @@ describe("generateAtomFeed", () => {
expect(result).not.toContain("<entry>"); expect(result).not.toContain("<entry>");
}); });
it("feed link points to admin emails page", () => { it("feed link points to the public read URL, never an admin path", () => {
const result = generateAtomFeed( const result = generateAtomFeed(
mockFeedConfig, mockFeedConfig,
mockEmails, mockEmails,
BASE_URL, BASE_URL,
FEED_ID, FEED_ID,
); );
expect(result).toContain(`${BASE_URL}/admin/feeds/${FEED_ID}/emails`); expect(result).toContain(`${BASE_URL}/rss/${FEED_ID}`);
expect(result).not.toContain("/admin/");
}); });
it("strips html/head/body wrapper from entry content", () => { it("strips html/head/body wrapper from entry content", () => {
+3 -2
View File
@@ -43,8 +43,9 @@ function buildFeed(
// Computed dynamically so the id is always canonical regardless of what // Computed dynamically so the id is always canonical regardless of what
// was stored in KV at feed-creation time (which may have used a stale domain). // was stored in KV at feed-creation time (which may have used a stale domain).
id: `${baseUrl}/rss/${feedId}`, id: `${baseUrl}/rss/${feedId}`,
// Link points to the admin emails page — the "website" this feed represents. // Public "website" for this feed: its own read URL (never the inbound address
link: `${baseUrl}/admin/feeds/${feedId}/emails`, // or an auth-gated admin path, so the feed output leaks neither).
link: `${baseUrl}/rss/${feedId}`,
language: feedConfig.language, language: feedConfig.language,
updated: new Date(), updated: new Date(),
generator: "kill-the-news", generator: "kill-the-news",
+3
View File
@@ -7,6 +7,7 @@ const fullConfig: FeedConfig = {
title: "News", title: "News",
description: "desc", description: "desc",
language: "en", language: "en",
mailbox_id: "a.b.42",
author: "Jane", author: "Jane",
allowed_senders: ["a@x.com"], allowed_senders: ["a@x.com"],
blocked_senders: ["b@y.com"], blocked_senders: ["b@y.com"],
@@ -24,6 +25,7 @@ describe("feed-mapper", () => {
const state = fromConfigDTO({ const state = fromConfigDTO({
title: "T", title: "T",
language: "en", language: "en",
mailbox_id: "t.t.42",
created_at: 1, created_at: 1,
}); });
expect(state.allowedSenders).toEqual([]); expect(state.allowedSenders).toEqual([]);
@@ -39,6 +41,7 @@ describe("feed-mapper", () => {
id: "a.b.42", id: "a.b.42",
title: "News", title: "News",
description: "desc", description: "desc",
mailbox_id: "a.b.42",
expires_at: 3000, expires_at: 3000,
}); });
}); });
+3
View File
@@ -16,6 +16,7 @@ export function fromConfigDTO(dto: FeedConfig): FeedState {
title: dto.title, title: dto.title,
description: dto.description, description: dto.description,
language: dto.language, language: dto.language,
mailboxId: dto.mailbox_id,
author: dto.author, author: dto.author,
allowedSenders: dto.allowed_senders ?? [], allowedSenders: dto.allowed_senders ?? [],
blockedSenders: dto.blocked_senders ?? [], blockedSenders: dto.blocked_senders ?? [],
@@ -31,6 +32,7 @@ export function toConfigDTO(state: FeedState): FeedConfig {
title: state.title, title: state.title,
description: state.description, description: state.description,
language: state.language, language: state.language,
mailbox_id: state.mailboxId,
author: state.author, author: state.author,
allowed_senders: state.allowedSenders, allowed_senders: state.allowedSenders,
blocked_senders: state.blockedSenders, blocked_senders: state.blockedSenders,
@@ -46,6 +48,7 @@ export function toListItemDTO(id: FeedId, state: FeedState): FeedListItem {
id: id.value, id: id.value,
title: state.title, title: state.title,
description: state.description, description: state.description,
mailbox_id: state.mailboxId,
expires_at: state.expiresAt, expires_at: state.expiresAt,
}; };
} }
@@ -3,6 +3,7 @@ import { createMockEnv } from "../test/setup";
import { FeedRepository } from "./feed-repository"; import { FeedRepository } from "./feed-repository";
import { Feed } from "../domain/feed.aggregate"; import { Feed } from "../domain/feed.aggregate";
import { FeedId } from "../domain/value-objects/feed-id"; import { FeedId } from "../domain/value-objects/feed-id";
import { MailboxId } from "../domain/value-objects/mailbox-id";
import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types"; import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
const mockEnv = () => createMockEnv() as unknown as Env; const mockEnv = () => createMockEnv() as unknown as Env;
@@ -11,6 +12,7 @@ const fid = (value: string) => FeedId.unchecked(value);
const sampleConfig = (overrides: Partial<FeedConfig> = {}): FeedConfig => ({ const sampleConfig = (overrides: Partial<FeedConfig> = {}): FeedConfig => ({
title: "Test Feed", title: "Test Feed",
language: "en", language: "en",
mailbox_id: "test.feed.42",
created_at: 1000, created_at: 1000,
...overrides, ...overrides,
}); });
@@ -46,6 +48,44 @@ describe("FeedRepository key schema", () => {
}); });
}); });
describe("FeedRepository inbound index", () => {
const mbox = (v: string) => MailboxId.unchecked(v);
it("resolves a mailbox to its feed id and back to null after delete", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
expect(await repo.resolveInbound(mbox("river.castle.42"))).toBeNull();
await repo.putInboundIndex(mbox("river.castle.42"), fid("opaque-id-1"));
expect((await repo.resolveInbound(mbox("river.castle.42")))?.value).toBe(
"opaque-id-1",
);
await repo.deleteInboundIndex(mbox("river.castle.42"));
expect(await repo.resolveInbound(mbox("river.castle.42"))).toBeNull();
});
it("save() writes the inbound index from the aggregate's mailbox", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
await repo.save(
Feed.reconstitute(
fid("opaque-id-2"),
{
title: "T",
language: "en",
mailboxId: "lake.tower.77",
allowedSenders: [],
blockedSenders: [],
createdAt: 1000,
},
{ emails: [] },
),
);
expect((await repo.resolveInbound(mbox("lake.tower.77")))?.value).toBe(
"opaque-id-2",
);
});
});
describe("FeedRepository config & metadata", () => { describe("FeedRepository config & metadata", () => {
it("round-trips and deletes a feed config", async () => { it("round-trips and deletes a feed config", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
@@ -106,6 +146,7 @@ describe("FeedRepository feed list", () => {
{ {
title, title,
language: "en", language: "en",
mailboxId: `${id}.mbox`,
allowedSenders: [], allowedSenders: [],
blockedSenders: [], blockedSenders: [],
createdAt: 1000, createdAt: 1000,
@@ -153,4 +194,23 @@ describe("FeedRepository feed list", () => {
expect(removed.sort()).toEqual(["a.b.42", "e.f.10"]); expect(removed.sort()).toEqual(["a.b.42", "e.f.10"]);
expect((await repo.listFeeds()).map((f) => f.id)).toEqual(["c.d.99"]); expect((await repo.listFeeds()).map((f) => f.id)).toEqual(["c.d.99"]);
}); });
it("drops each removed feed's inbound index (symmetric with save)", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
const mbox = (v: string) => MailboxId.unchecked(v);
await repo.save(feedWith("a.b.42", "One"));
await repo.save(feedWith("c.d.99", "Two"));
// Both addresses resolve before removal.
expect(await repo.resolveInbound(mbox("a.b.42.mbox"))).not.toBeNull();
expect(await repo.resolveInbound(mbox("c.d.99.mbox"))).not.toBeNull();
await repo.removeFromListBulk(["a.b.42"]);
// The removed feed's address stops resolving; the survivor's still does.
expect(await repo.resolveInbound(mbox("a.b.42.mbox"))).toBeNull();
expect((await repo.resolveInbound(mbox("c.d.99.mbox")))?.value).toBe(
"c.d.99",
);
});
}); });
+37
View File
@@ -10,6 +10,7 @@ import { FEEDS_LIST_KEY } from "../config/constants";
import { feedKeys } from "../domain/feed-keys"; import { feedKeys } from "../domain/feed-keys";
import { Feed } from "../domain/feed.aggregate"; import { Feed } from "../domain/feed.aggregate";
import { FeedId } from "../domain/value-objects/feed-id"; import { FeedId } from "../domain/value-objects/feed-id";
import { MailboxId } from "../domain/value-objects/mailbox-id";
import { fromConfigDTO, toConfigDTO, toListItemDTO } from "./feed-mapper"; import { fromConfigDTO, toConfigDTO, toListItemDTO } from "./feed-mapper";
import { logger } from "./logger"; import { logger } from "./logger";
@@ -87,6 +88,7 @@ export class FeedRepository {
this.putConfig(feed.id, toConfigDTO(feed.state())), this.putConfig(feed.id, toConfigDTO(feed.state())),
this.putMetadata(feed.id, feed.toMetadataSnapshot()), this.putMetadata(feed.id, feed.toMetadataSnapshot()),
this.upsertListEntry(toListItemDTO(feed.id, feed.state())), this.upsertListEntry(toListItemDTO(feed.id, feed.state())),
this.putInboundIndex(feed.mailboxId, feed.id),
]); ]);
} }
@@ -108,9 +110,31 @@ export class FeedRepository {
await Promise.all([ await Promise.all([
this.putConfig(feed.id, toConfigDTO(feed.state())), this.putConfig(feed.id, toConfigDTO(feed.state())),
this.upsertListEntry(toListItemDTO(feed.id, feed.state())), this.upsertListEntry(toListItemDTO(feed.id, feed.state())),
this.putInboundIndex(feed.mailboxId, feed.id),
]); ]);
} }
// ── Inbound mailbox index ─────────────────────────────────────────────────
// Secondary index mapping the friendly inbound address (`noun.noun.NN`) to the
// feed's opaque id. Resolved only at reception (the write edge), so the public
// read id and the inbound address stay decoupled.
/** Resolve an inbound mailbox to its feed id, or null when no feed claims it. */
async resolveInbound(mailboxId: MailboxId): Promise<FeedId | null> {
const feedId = await this.kv.get(feedKeys.inbound(mailboxId.value), {
type: "text",
});
return feedId ? FeedId.unchecked(feedId) : null;
}
async putInboundIndex(mailboxId: MailboxId, feedId: FeedId): Promise<void> {
await this.kv.put(feedKeys.inbound(mailboxId.value), feedId.value);
}
async deleteInboundIndex(mailboxId: MailboxId): Promise<void> {
await this.kv.delete(feedKeys.inbound(mailboxId.value));
}
// ── Feed config ─────────────────────────────────────────────────────────── // ── Feed config ───────────────────────────────────────────────────────────
async getConfig(feedId: FeedId): Promise<FeedConfig | null> { async getConfig(feedId: FeedId): Promise<FeedConfig | null> {
@@ -209,11 +233,13 @@ export class FeedRepository {
if (toRemove.size === 0) return []; if (toRemove.size === 0) return [];
const removed: string[] = []; const removed: string[] = [];
const droppedMailboxes: string[] = [];
const nextFeeds: FeedListItem[] = []; const nextFeeds: FeedListItem[] = [];
for (const feed of feedList.feeds) { for (const feed of feedList.feeds) {
if (toRemove.has(feed.id)) { if (toRemove.has(feed.id)) {
removed.push(feed.id); removed.push(feed.id);
if (feed.mailbox_id) droppedMailboxes.push(feed.mailbox_id);
continue; continue;
} }
nextFeeds.push(feed); nextFeeds.push(feed);
@@ -223,6 +249,17 @@ export class FeedRepository {
feedList.feeds = nextFeeds; feedList.feeds = nextFeeds;
await this.kv.put(FEEDS_LIST_KEY, JSON.stringify(feedList)); await this.kv.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
// Drop each removed feed's inbound index — symmetric with save() writing
// it. The index lives outside the feed:<id>: prefix the key purge sweeps,
// so a deleted feed's address would keep resolving if left behind. The
// mailbox is cached on the list item we just removed.
await Promise.all(
droppedMailboxes.map((mailbox) =>
this.deleteInboundIndex(MailboxId.unchecked(mailbox)),
),
);
return removed; return removed;
} catch (error) { } catch (error) {
logger.error("Error removing feeds from list", { error: String(error) }); logger.error("Error removing feeds from list", { error: String(error) });
+2
View File
@@ -15,6 +15,8 @@ export function ingestResultToResponse(result: IngestResult): Response {
switch (result.reason) { switch (result.reason) {
case "invalid_address": case "invalid_address":
return new Response("Invalid email address format", { status: 400 }); return new Response("Invalid email address format", { status: 400 });
case "mailbox_unknown":
return new Response("No feed for this address", { status: 404 });
case "feed_not_found": case "feed_not_found":
return new Response("Feed does not exist", { status: 404 }); return new Response("Feed does not exist", { status: 404 });
case "feed_expired": case "feed_expired":
+6 -2
View File
@@ -1,4 +1,5 @@
import { Env } from "../types"; import { Env } from "../types";
import { MailboxId } from "../domain/value-objects/mailbox-id";
export function baseUrl(env: Env): string { export function baseUrl(env: Env): string {
return `https://${env.DOMAIN}`; return `https://${env.DOMAIN}`;
@@ -20,8 +21,11 @@ export function feedUrl(
return format === "rss" ? feedRssUrl(feedId, env) : feedAtomUrl(feedId, env); return format === "rss" ? feedRssUrl(feedId, env) : feedAtomUrl(feedId, env);
} }
export function feedEmailAddress(feedId: string, env: Env): string { export function feedEmailAddress(mailboxId: string, env: Env): string {
return `${feedId}@${env.EMAIL_DOMAIN ?? env.DOMAIN}`; // The mailbox→address shape lives on the VO; this edge only resolves the domain.
return MailboxId.unchecked(mailboxId).emailAddress(
env.EMAIL_DOMAIN ?? env.DOMAIN,
);
} }
export function feedTopicPattern(env: Env): RegExp { export function feedTopicPattern(env: Env): RegExp {
+1
View File
@@ -60,6 +60,7 @@ async function buildFeedXml(
title: `Newsletter Feed ${feedId.value}`, title: `Newsletter Feed ${feedId.value}`,
description: "Converted email newsletter", description: "Converted email newsletter",
language: "en", language: "en",
mailbox_id: "",
created_at: Date.now(), created_at: Date.now(),
}; };
+48
View File
@@ -168,6 +168,27 @@ describe("Admin Routes", () => {
expect(feedConfig).toBeTruthy(); expect(feedConfig).toBeTruthy();
expect((feedConfig as any).title).toBe("Test Feed"); expect((feedConfig as any).title).toBe("Test Feed");
expect((feedConfig as any).description).toBe("Test Description"); expect((feedConfig as any).description).toBe("Test Description");
// Two-id model: the feed id is an opaque read id; the inbound address is
// a separate noun.noun.NN mailbox, mapped via the inbound: index.
const mailboxId = (feedConfig as any).mailbox_id as string;
expect(mailboxId).toMatch(/^[a-z]+\.[a-z]+\.\d{2}$/);
expect(feedId).toMatch(/^[A-Za-z0-9_-]{22}$/);
expect(feedId).not.toBe(mailboxId);
expect((feedList?.feeds[0] as any).mailbox_id).toBe(mailboxId);
expect(
await mockEnv.EMAIL_STORAGE.get(`inbound:${mailboxId}`, "text"),
).toBe(feedId);
// The dashboard shows the inbound address and the opaque feed URL,
// distinctly — and never exposes the address as a readable feed URL.
const dash = await request("/admin", {
headers: { Cookie: authCookie },
});
const html = await dash.text();
expect(html).toContain(`${mailboxId}@test.getmynews.app`);
expect(html).toContain(`/rss/${feedId}`);
expect(html).not.toContain(`/rss/${mailboxId}`);
}); });
it("should reject feed creation with missing title", async () => { it("should reject feed creation with missing title", async () => {
@@ -732,6 +753,15 @@ describe("Admin Routes", () => {
it("lists attachments with download links on the email detail page", async () => { it("lists attachments with download links on the email detail page", async () => {
const authCookie = await loginAndGetCookie(); const authCookie = await loginAndGetCookie();
const feedId = "detail-feed"; const feedId = "detail-feed";
await mockEnv.EMAIL_STORAGE.put(
`feed:${feedId}:config`,
JSON.stringify({
title: "Detail Feed",
mailbox_id: "detail.feed.10",
language: "en",
created_at: 1,
}),
);
const emailKey = `feed:${feedId}:1`; const emailKey = `feed:${feedId}:1`;
await mockEnv.EMAIL_STORAGE.put( await mockEnv.EMAIL_STORAGE.put(
emailKey, emailKey,
@@ -769,6 +799,15 @@ describe("Admin Routes", () => {
it("renders inline cid images in place and hides them from the attachments list", async () => { it("renders inline cid images in place and hides them from the attachments list", async () => {
const authCookie = await loginAndGetCookie(); const authCookie = await loginAndGetCookie();
const feedId = "detail-feed"; const feedId = "detail-feed";
await mockEnv.EMAIL_STORAGE.put(
`feed:${feedId}:config`,
JSON.stringify({
title: "Detail Feed",
mailbox_id: "detail.feed.10",
language: "en",
created_at: 1,
}),
);
const emailKey = `feed:${feedId}:3`; const emailKey = `feed:${feedId}:3`;
await mockEnv.EMAIL_STORAGE.put( await mockEnv.EMAIL_STORAGE.put(
emailKey, emailKey,
@@ -814,6 +853,15 @@ describe("Admin Routes", () => {
it("does not render an attachments section when the email has none", async () => { it("does not render an attachments section when the email has none", async () => {
const authCookie = await loginAndGetCookie(); const authCookie = await loginAndGetCookie();
const feedId = "detail-feed"; const feedId = "detail-feed";
await mockEnv.EMAIL_STORAGE.put(
`feed:${feedId}:config`,
JSON.stringify({
title: "Detail Feed",
mailbox_id: "detail.feed.10",
language: "en",
created_at: 1,
}),
);
const emailKey = `feed:${feedId}:2`; const emailKey = `feed:${feedId}:2`;
await mockEnv.EMAIL_STORAGE.put( await mockEnv.EMAIL_STORAGE.put(
emailKey, emailKey,
+5 -2
View File
@@ -666,7 +666,10 @@ app.get("/", async (c) => {
</thead> </thead>
<tbody id="feed-table-body"> <tbody id="feed-table-body">
{feedsWithConfig.map((feed) => { {feedsWithConfig.map((feed) => {
const emailAddress = feedEmailAddress(feed.id, env); const emailAddress = feedEmailAddress(
feed.mailbox_id,
env,
);
const rssUrl = feedRssUrl(feed.id, env); const rssUrl = feedRssUrl(feed.id, env);
const atomUrl = feedAtomUrl(feed.id, env); const atomUrl = feedAtomUrl(feed.id, env);
const titleDisplay = clampText(feed.title, 160); const titleDisplay = clampText(feed.title, 160);
@@ -823,7 +826,7 @@ app.get("/", async (c) => {
<ul class="feed-list"> <ul class="feed-list">
{feedsWithConfig.map((feed) => { {feedsWithConfig.map((feed) => {
const emailAddress = feedEmailAddress(feed.id, env); const emailAddress = feedEmailAddress(feed.mailbox_id, env);
const rssUrl = feedRssUrl(feed.id, env); const rssUrl = feedRssUrl(feed.id, env);
const atomUrl = feedAtomUrl(feed.id, env); const atomUrl = feedAtomUrl(feed.id, env);
const titleDisplay = clampText(feed.title, 140); const titleDisplay = clampText(feed.title, 140);
+7 -2
View File
@@ -169,7 +169,7 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
return c.text("Feed not found", 404); return c.text("Feed not found", 404);
} }
const emailAddress = feedEmailAddress(feedId, env); const emailAddress = feedEmailAddress(feedConfig.mailbox_id, env);
const rssUrl = feedRssUrl(feedId, env); const rssUrl = feedRssUrl(feedId, env);
const atomUrl = feedAtomUrl(feedId, env); const atomUrl = feedAtomUrl(feedId, env);
@@ -466,6 +466,8 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
if (!emailData) return c.text("Email not found", 404); if (!emailData) return c.text("Email not found", 404);
const feedId = repo.feedIdFromEmailKey(emailKey); const feedId = repo.feedIdFromEmailKey(emailKey);
const feedConfig = await repo.getConfig(FeedId.unchecked(feedId));
if (!feedConfig) return c.text("Feed not found", 404);
// Inline images render in place; only downloadable attachments go in the list. // Inline images render in place; only downloadable attachments go in the list.
const attachments = (emailData.attachments ?? []).filter((a) => !a.inline); const attachments = (emailData.attachments ?? []).filter((a) => !a.inline);
@@ -584,7 +586,10 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
value={new Date(emailData.receivedAt).toLocaleString()} value={new Date(emailData.receivedAt).toLocaleString()}
/> />
<SenderField from={emailData.from} feedId={feedId} /> <SenderField from={emailData.from} feedId={feedId} />
<CopyField label="To:" value={feedEmailAddress(feedId, env)} /> <CopyField
label="To:"
value={feedEmailAddress(feedConfig.mailbox_id, env)}
/>
</div> </div>
</div> </div>
+2 -2
View File
@@ -121,7 +121,7 @@ feedsRouter.post("/create", async (c) => {
? parseInt(lifetimeHoursRaw, 10) ? parseInt(lifetimeHoursRaw, 10)
: undefined; : undefined;
const { feedId } = await createFeedRecord(env, { const { feedId, mailboxId } = await createFeedRecord(env, {
title: parsedData.title, title: parsedData.title,
description: parsedData.description, description: parsedData.description,
language: parsedData.language, language: parsedData.language,
@@ -133,7 +133,7 @@ feedsRouter.post("/create", async (c) => {
if (isJson) { if (isJson) {
return c.json({ return c.json({
feedId, feedId,
email: feedEmailAddress(feedId, env), email: feedEmailAddress(mailboxId, env),
feedUrl: feedRssUrl(feedId, env), feedUrl: feedRssUrl(feedId, env),
}); });
} }
+2 -2
View File
@@ -63,7 +63,7 @@ function toFeed(
updatedAt: config.updated_at, updatedAt: config.updated_at,
expiresAt: config.expires_at, expiresAt: config.expires_at,
emailCount, emailCount,
emailAddress: feedEmailAddress(id, env), emailAddress: feedEmailAddress(config.mailbox_id, env),
rssUrl: feedRssUrl(id, env), rssUrl: feedRssUrl(id, env),
atomUrl: feedAtomUrl(id, env), atomUrl: feedAtomUrl(id, env),
}; };
@@ -117,7 +117,7 @@ apiApp.openapi(
title: f.title, title: f.title,
description: f.description, description: f.description,
expiresAt: f.expires_at, expiresAt: f.expires_at,
emailAddress: feedEmailAddress(f.id, env), emailAddress: feedEmailAddress(f.mailbox_id, env),
rssUrl: feedRssUrl(f.id, env), rssUrl: feedRssUrl(f.id, env),
atomUrl: feedAtomUrl(f.id, env), atomUrl: feedAtomUrl(f.id, env),
})), })),
+3 -1
View File
@@ -14,7 +14,9 @@ export const FeedIdParam = z.object({
.min(1) .min(1)
.openapi({ .openapi({
param: { name: "feedId", in: "path" }, param: { name: "feedId", in: "path" },
example: "happy-otter-1234", description:
"The feed's opaque id (the read id in /rss/:feedId), not the inbound address.",
example: "kZ8xQ2pLm4nR7vT1wB9yJc",
}), }),
}); });
+6 -3
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach } from "vitest";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import worker from "../index"; import worker from "../index";
import { server, createMockEnv, MockR2 } from "../test/setup"; import { server, createMockEnv, MockR2, seedInboundIndex } from "../test/setup";
import type { Env } from "../types"; import type { Env } from "../types";
import type { ForwardEmailPayload } from "../infrastructure/forwardemail"; import type { ForwardEmailPayload } from "../infrastructure/forwardemail";
@@ -64,6 +64,7 @@ describe("POST /api/inbound — IP middleware", () => {
`feed:${VALID_FEED_ID}:config`, `feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: [] }), JSON.stringify({ allowed_senders: [] }),
); );
await seedInboundIndex(env, VALID_FEED_ID);
}); });
it("returns 401 when IP is not in the ForwardEmail allowlist", async () => { it("returns 401 when IP is not in the ForwardEmail allowlist", async () => {
@@ -99,9 +100,10 @@ describe("POST /api/inbound — IP middleware", () => {
describe("POST /api/inbound — handler logic", () => { describe("POST /api/inbound — handler logic", () => {
let env: Env; let env: Env;
beforeEach(() => { beforeEach(async () => {
stubForwardEmailIps(); stubForwardEmailIps();
env = createMockEnv() as unknown as Env; env = createMockEnv() as unknown as Env;
await seedInboundIndex(env, VALID_FEED_ID);
}); });
it("returns 500 on malformed JSON body", async () => { it("returns 500 on malformed JSON body", async () => {
@@ -232,9 +234,10 @@ describe("POST /api/inbound — handler logic", () => {
describe("POST /api/inbound — attachment upload", () => { describe("POST /api/inbound — attachment upload", () => {
let env: Env; let env: Env;
beforeEach(() => { beforeEach(async () => {
stubForwardEmailIps(); stubForwardEmailIps();
env = createMockEnv({ withR2: true }) as unknown as Env; env = createMockEnv({ withR2: true }) as unknown as Env;
await seedInboundIndex(env, VALID_FEED_ID);
}); });
it("uploads attachments to R2 and records ids in metadata", async () => { it("uploads attachments to R2 and records ids in metadata", async () => {
+54
View File
@@ -54,6 +54,60 @@ describe("RSS Feed Route", () => {
}); });
}); });
describe("read/write id decoupling", () => {
const OPAQUE_ID = "kZ8xQ2pLm4nR7vT1wB9yJc";
const MAILBOX = "river.castle.42";
const RECEIVED_AT = 1700000002000;
beforeEach(async () => {
const emailKey = `feed:${OPAQUE_ID}:${RECEIVED_AT}`;
await mockEnv.EMAIL_STORAGE.put(
emailKey,
JSON.stringify({
subject: "Private",
from: "Sender <sender@example.com>",
content: "<p>secret body</p>",
receivedAt: RECEIVED_AT,
headers: {},
}),
);
await mockEnv.EMAIL_STORAGE.put(
`feed:${OPAQUE_ID}:metadata`,
JSON.stringify({
emails: [
{ key: emailKey, subject: "Private", receivedAt: RECEIVED_AT },
],
}),
);
await mockEnv.EMAIL_STORAGE.put(
`feed:${OPAQUE_ID}:config`,
JSON.stringify({
title: "Decoupled Feed",
language: "en",
mailbox_id: MAILBOX,
created_at: 1700000000000,
}),
);
// The inbound index points the address at the feed (reception only).
await mockEnv.EMAIL_STORAGE.put(`inbound:${MAILBOX}`, OPAQUE_ID);
});
it("serves the feed by its opaque read id", async () => {
const res = await testApp.request(`/${OPAQUE_ID}`, {}, mockEnv);
expect(res.status).toBe(200);
});
it("returns 404 when read by the inbound mailbox (no coupling)", async () => {
const res = await testApp.request(`/${MAILBOX}`, {}, mockEnv);
expect(res.status).toBe(404);
});
it("never leaks the inbound mailbox in the feed body", async () => {
const res = await testApp.request(`/${OPAQUE_ID}`, {}, mockEnv);
expect(await res.text()).not.toContain(MAILBOX);
});
});
describe("conditional GET (ETag + Last-Modified)", () => { describe("conditional GET (ETag + Last-Modified)", () => {
const FEED_ID = "test-feed-rss-cget"; const FEED_ID = "test-feed-rss-cget";
const EMAIL_RECEIVED_AT = 1700000001000; const EMAIL_RECEIVED_AT = 1700000001000;
+14
View File
@@ -1,5 +1,6 @@
import { beforeAll, afterAll, afterEach } from "vitest"; import { beforeAll, afterAll, afterEach } from "vitest";
import { setupServer } from "msw/node"; import { setupServer } from "msw/node";
import { feedKeys } from "../domain/feed-keys";
// Minimal Node.js built-ins used only in this test setup file. // Minimal Node.js built-ins used only in this test setup file.
// Declared locally to avoid pulling in the full @types/node package, // Declared locally to avoid pulling in the full @types/node package,
@@ -263,3 +264,16 @@ export const createMockEnv = (options: { withR2?: boolean } = {}) => ({
? { ATTACHMENT_BUCKET: new MockR2() as unknown as R2Bucket } ? { ATTACHMENT_BUCKET: new MockR2() as unknown as R2Bucket }
: {}), : {}),
}); });
/**
* Seed the `inbound:<mailbox> → <feedId>` index that email reception resolves
* through. Defaults the feed id to the mailbox (the common unit-test shape where
* a feed is keyed by the same string as its inbound address).
*/
export async function seedInboundIndex(
env: { EMAIL_STORAGE: { put: (k: string, v: string) => Promise<unknown> } },
mailboxId: string,
feedId: string = mailboxId,
): Promise<void> {
await env.EMAIL_STORAGE.put(feedKeys.inbound(mailboxId), feedId);
}
+4
View File
@@ -42,6 +42,9 @@ export interface EmailData {
export interface FeedConfig { export interface FeedConfig {
title: string; title: string;
description?: string; description?: string;
// Inbound mailbox local part (noun.noun.NN): the feed's email address is
// `mailbox_id@domain`. Decoupled from the feed's id (the opaque read id).
mailbox_id: string;
allowed_senders?: string[]; allowed_senders?: string[];
blocked_senders?: string[]; blocked_senders?: string[];
language: string; language: string;
@@ -82,6 +85,7 @@ export interface FeedListItem {
id: string; id: string;
title: string; title: string;
description?: string; description?: string;
mailbox_id: string; // Cached inbound address local part (admin/API display)
expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads
} }