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.
**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
@@ -75,7 +75,7 @@ src/
events.ts # FeedEvent union (FeedCreated, EmailIngested) — each carries its feedId
email-parser.ts # Email parsing (addresses, headers, encoded words)
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)
feed-service.ts # createFeedRecord / editFeedDetails / editFeed / deleteFeedRecord (admin UI + REST API)
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 |
| --------------------------- | ---------------------------------------------------------------------------------------------- |
| `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>:metadata` | `{ emails: Array<{ key, subject, receivedAt, size?, attachmentIds?, inlineAttachmentIds? }> }` |
| `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) |
| `icon:<domain>` | Cached favicon record (base64 + content type; negative entries allowed) |
| `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
@@ -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 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.
- **`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).
- 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.
- **`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`)
+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.
### 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
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
- Resizable + sortable table columns in the admin dashboard (Table view)
- 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)
- ForwardEmail webhook ingestion with source-IP verification (optional alternative)
- 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:
1. Incoming email arrives at `user@yourdomain.com`.
2. The Worker resolves the feed from the recipient address and stores the email in KV.
3. `https://yourdomain.com/rss/:feedId` renders RSS from stored items.
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 (via the `inbound:` index) and stores the email in KV.
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.
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` **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).
+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>
</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-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>
+29 -5
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest";
import { http, HttpResponse } from "msw";
import { createMockEnv, MockR2, server } from "../test/setup";
import { createMockEnv, MockR2, seedInboundIndex, server } from "../test/setup";
import {
processEmail,
ProcessEmailInput,
@@ -30,8 +30,10 @@ function makeInput(
describe("processEmail", () => {
let env: ReturnType<typeof createMockEnv>;
beforeEach(() => {
beforeEach(async () => {
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 () => {
@@ -42,7 +44,18 @@ describe("processEmail", () => {
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);
expect(res).toMatchObject({ ok: false, reason: "feed_not_found" });
});
@@ -319,14 +332,14 @@ describe("processEmail", () => {
passThroughOnException: () => {},
} 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(
makeInput({ toAddress: `no.such.99@test.getmynews.app` }),
env as any,
ctx,
);
expect(res).toMatchObject({ ok: false, reason: "feed_not_found" });
expect(res).toMatchObject({ ok: false, reason: "mailbox_unknown" });
expect(waitUntilCalled).toBe(false);
});
});
@@ -343,6 +356,7 @@ describe("processEmail — attachments", () => {
it("skips R2 upload when ATTACHMENT_BUCKET is not configured", async () => {
const env = createMockEnv();
await seedInboundIndex(env, VALID_FEED_ID);
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
@@ -366,6 +380,7 @@ describe("processEmail — attachments", () => {
it("skips R2 upload when ATTACHMENTS_ENABLED is 'false' even with R2 bound", async () => {
const env = createMockEnv({ withR2: true });
await seedInboundIndex(env, VALID_FEED_ID);
(env as any).ATTACHMENTS_ENABLED = "false";
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
await env.EMAIL_STORAGE.put(
@@ -392,6 +407,7 @@ describe("processEmail — attachments", () => {
it("uploads attachments to R2 and stores AttachmentData in emailData", async () => {
const env = createMockEnv({ withR2: true });
await seedInboundIndex(env, VALID_FEED_ID);
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
@@ -423,6 +439,7 @@ describe("processEmail — attachments", () => {
it("stores attachmentIds in EmailMetadata for trim-time cleanup", async () => {
const env = createMockEnv({ withR2: true });
await seedInboundIndex(env, VALID_FEED_ID);
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
@@ -439,6 +456,7 @@ describe("processEmail — attachments", () => {
it("classifies a cid-referenced image as inline, not a downloadable attachment", async () => {
const env = createMockEnv({ withR2: true });
await seedInboundIndex(env, VALID_FEED_ID);
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
@@ -484,6 +502,7 @@ describe("processEmail — attachments", () => {
it("deletes inline image R2 objects when a trimmed email had them", async () => {
const env = createMockEnv({ withR2: true });
await seedInboundIndex(env, VALID_FEED_ID);
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
@@ -537,6 +556,7 @@ describe("processEmail — attachments", () => {
it("deletes R2 objects when a trimmed email had attachments", async () => {
const env = createMockEnv({ withR2: true });
await seedInboundIndex(env, VALID_FEED_ID);
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
@@ -604,6 +624,7 @@ describe("processEmail — deduplication", () => {
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
await seedInboundIndex(env, VALID_FEED_ID);
});
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", () => {
it("increments emails_received and sets last_email_at on success", async () => {
const env = createMockEnv();
await seedInboundIndex(env, VALID_FEED_ID);
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
@@ -760,6 +782,7 @@ describe("processEmail — feed icon", () => {
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
await seedInboundIndex(env, VALID_FEED_ID);
});
it("persists the latest sender domain on the feed metadata", async () => {
@@ -811,6 +834,7 @@ describe("processEmail — unsubscribe capture", () => {
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
await seedInboundIndex(env, VALID_FEED_ID);
});
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 { bumpCounters } from "../application/stats";
import { dispatchFeedEvents } from "../application/feed-events";
@@ -33,6 +33,7 @@ export interface ProcessEmailInput {
export type IngestRejectionReason =
| "invalid_address"
| "mailbox_unknown"
| "feed_not_found"
| "feed_expired"
| "sender_blocked";
@@ -79,17 +80,33 @@ async function loadAcceptingFeed(
): Promise<
{ ok: true; feed: Feed } | { ok: false; reason: IngestRejectionReason }
> {
const feedId = EmailParser.extractFeedId(input.toAddress);
if (!feedId) {
// MailboxId.parse is the single boundary where an untrusted inbound address
// (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", {
toAddress: input.toAddress,
});
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) {
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" };
}
if (feed.isExpired()) {
+3
View File
@@ -21,6 +21,9 @@ export async function fetchFeedData(
title: `Newsletter Feed ${feedId.value}`,
description: "Converted email newsletter",
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(),
};
+42 -1
View File
@@ -1,8 +1,15 @@
import { describe, it, expect } from "vitest";
import { createMockEnv } from "../test/setup";
import { createFeedRecord, editFeed } from "./feed-service";
import {
createFeedRecord,
editFeed,
deleteFeedRecord,
deleteFeedFastDetailed,
} from "./feed-service";
import { getCounters } from "./stats";
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";
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", () => {
it("recomputes expiry from the server override on edit", async () => {
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 { BackgroundScheduler } from "../infrastructure/worker";
import { FeedId } from "../domain/value-objects/feed-id";
import { MailboxId } from "../domain/value-objects/mailbox-id";
import { Lifetime } from "../domain/value-objects/lifetime";
import {
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
* list, and bump the `feeds_created` counter. Returns the new feed id + config.
* Create a feed: mint an opaque `FeedId` (the read id) and a friendly `MailboxId`
* (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(
env: Env,
input: CreateFeedInput,
): Promise<{ feedId: string; config: FeedConfig }> {
): Promise<{ feedId: string; mailboxId: string; config: FeedConfig }> {
const repo = FeedRepository.from(env);
const feed = Feed.create(FeedId.generate(), input, {
mailboxId: MailboxId.generate(),
lifetime: resolveLifetime(env, input.lifetimeHours),
});
// save() also writes the inbound:<mailbox> → feedId index.
await repo.save(feed);
// FeedCreated → bumps the feeds_created counter (no background work to schedule).
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 =
@@ -118,8 +127,10 @@ type DeleteFeedFastResult = {
};
/**
* Delete a feed's config + metadata keys, reporting per-key outcomes. The
* larger email/attachment cleanup is handled separately via purgeFeedKeysStep.
* Delete a feed's config + metadata keys, reporting per-key outcomes. The larger
* 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(
emailStorage: KVNamespace,
@@ -166,6 +177,7 @@ export async function deleteFeedRecord(
const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId);
await deleteFeedFastDetailed(emailStorage, feedId);
// removeFromList also drops the feed's inbound mailbox index.
const removed = await repo.removeFromList(feedId);
if (removed) {
await bumpCounters(emailStorage, { feeds_deleted: 1 });
+2 -31
View File
@@ -1,37 +1,8 @@
import { describe, it, expect } from "vitest";
import { EmailParser } from "./email-parser";
describe("EmailParser.extractFeedId", () => {
it("extracts a valid feed ID from an email address", () => {
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();
});
});
// Inbound mailbox parsing lives on the MailboxId VO (see mailbox-id.test.ts);
// EmailParser no longer wraps it.
describe("EmailParser.decodeEncodedWords", () => {
it("returns plain text unchanged", () => {
-11
View File
@@ -1,17 +1,6 @@
import { EmailData } from "../types";
import { FeedId } from "./value-objects/feed-id";
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
static parseForwardEmailPayload(payload: any): EmailData {
if (!payload) {
+3
View File
@@ -13,6 +13,9 @@ export const feedKeys = {
config: (feedId: string): string => `feed:${feedId}:config`,
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). */
feedPrefix: (feedId: string): string => `feed:${feedId}:`,
+3
View File
@@ -11,6 +11,9 @@ export interface FeedState {
title: string;
description?: 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;
allowedSenders: string[];
blockedSenders: string[];
+30 -12
View File
@@ -3,12 +3,14 @@ import { createMockEnv } from "../test/setup";
import { Feed, CreateFeedInput } from "./feed.aggregate";
import { FeedRepository } from "../infrastructure/feed-repository";
import { FeedId } from "./value-objects/feed-id";
import { MailboxId } from "./value-objects/mailbox-id";
import { Lifetime } from "./value-objects/lifetime";
import { FeedState } from "./feed-state";
import { Clock } from "./clock";
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;
@@ -27,6 +29,7 @@ const createInput = (
const state = (overrides: Partial<FeedState> = {}): FeedState => ({
title: "T",
language: "en",
mailboxId: "a.b.42",
allowedSenders: [],
blockedSenders: [],
createdAt: 0,
@@ -43,8 +46,9 @@ const entry = (overrides: Partial<EmailMetadata> = {}): EmailMetadata => ({
describe("Feed.create", () => {
it("builds a config with an empty email index and no expiry by default", () => {
const feed = Feed.create(FID, createInput());
expect(feed.id.value).toBe("a.b.42");
const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
expect(feed.id.value).toBe("opaque-feed-id");
expect(feed.mailboxId.value).toBe("a.b.42");
expect(feed.title).toBe("News");
expect(feed.expiresAt).toBeUndefined();
expect(feed.emails).toEqual([]);
@@ -53,6 +57,7 @@ describe("Feed.create", () => {
it("resolves expiry from the supplied lifetime using the injected clock", () => {
const NOW = 1_000_000;
const feed = Feed.create(FID, createInput(), {
mailboxId: MBOX,
clock: fixedClock(NOW),
lifetime: Lifetime.ofHours(2),
});
@@ -64,18 +69,24 @@ describe("Feed.create", () => {
it("trusts only deps.lifetime, not the client lifetimeHours field", () => {
// The aggregate no longer parses lifetime policy: the application resolves
// 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();
});
it("treats a non-positive lifetime as no expiry", () => {
expect(
Feed.create(FID, createInput(), { lifetime: Lifetime.ofHours(0) })
.expiresAt,
Feed.create(FID, createInput(), {
mailboxId: MBOX,
lifetime: Lifetime.ofHours(0),
}).expiresAt,
).toBeUndefined();
expect(
Feed.create(FID, createInput(), { lifetime: Lifetime.ofHours(-5) })
.expiresAt,
Feed.create(FID, createInput(), {
mailboxId: MBOX,
lifetime: Lifetime.ofHours(-5),
}).expiresAt,
).toBeUndefined();
});
});
@@ -191,7 +202,7 @@ describe("Feed.removeEmails", () => {
describe("Feed events", () => {
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 }]);
// Draining clears: a second pull is empty.
expect(feed.pullEvents()).toEqual([]);
@@ -225,18 +236,25 @@ describe("Feed events", () => {
describe("FeedRepository.load / save round-trip", () => {
it("persists a created feed and reflects later mutations", async () => {
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);
const loaded = await repo.load(FID);
expect(loaded).not.toBeNull();
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!);
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 () => {
+10 -1
View File
@@ -1,6 +1,7 @@
import { FeedMetadata, EmailMetadata } from "../types";
import { FeedState } from "./feed-state";
import { FeedId } from "./value-objects/feed-id";
import { MailboxId } from "./value-objects/mailbox-id";
import { Lifetime } from "./value-objects/lifetime";
import { SenderPolicy, SenderDecision } from "./value-objects/sender-policy";
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.
*/
export interface CreateFeedDeps {
/** The feed's inbound mailbox, minted by the application alongside its FeedId. */
mailboxId: MailboxId;
clock?: Clock;
/** Effective lifetime, already resolved by the application. */
lifetime?: Lifetime;
@@ -82,7 +85,7 @@ export class Feed {
static create(
id: FeedId,
input: CreateFeedInput,
deps: CreateFeedDeps = {},
deps: CreateFeedDeps,
): Feed {
const clock = deps.clock ?? systemClock;
const now = clock.now();
@@ -91,6 +94,7 @@ export class Feed {
title: input.title,
description: input.description,
language: input.language,
mailboxId: deps.mailboxId.value,
allowedSenders: input.allowedSenders,
blockedSenders: input.blockedSenders,
createdAt: now,
@@ -130,6 +134,11 @@ export class Feed {
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 {
return this._state.createdAt;
}
+17 -26
View File
@@ -1,36 +1,27 @@
import { describe, it, expect } from "vitest";
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", () => {
it("produces the noun.noun.NN format", () => {
it("produces an opaque base64url token", () => {
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", () => {
const id = FeedId.generate();
expect(FeedId.parse(`${id.value}@example.com`)?.value).toBe(id.value);
it("is unguessable: 50 ids are all distinct", () => {
const ids = new Set(
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";
// Feed IDs are noun1.noun2.XY (two lowercase nouns + a 2-digit suffix).
const FEED_ID_IN_ADDRESS = /^([a-z]+\.[a-z]+\.\d{2})@/i;
/** Encode bytes as unpadded base64url (URL- and KV-key-safe). */
function base64url(bytes: Uint8Array): string {
let binary = "";
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
* address; `generate` mints a fresh one. The original casing is preserved.
* A feed's identity: the KV storage key AND the public read id in `/rss/:feedId`
* 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 {
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
* 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
* wrong id is not rejected here, it simply misses in KV and 404s downstream.
* Untrusted external input (an inbound address) must go through `parse` instead.
* `feeds:list` entry, an `inbound:` index value, or a KV key. The name is
* deliberately blunt: a wrong id is not rejected here, it simply misses in KV
* and 404s downstream.
*/
static unchecked(value: string): FeedId {
return new FeedId(value);
}
/** Mint a fresh, opaque identity (128 bits of entropy → 22 base64url chars). */
static generate(): FeedId {
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 FeedId(`${noun1}.${noun2}.${number}`);
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return new FeedId(base64url(bytes));
}
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 worker from "./index";
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";
const env = createMockEnv();
const noopCtx = {
waitUntil: () => {},
passThroughOnException: () => {},
} as unknown as ExecutionContext;
function req(path: string, init: RequestInit = {}): Request {
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", () => {
it("returns 200 and disallows the private feed/entry paths", async () => {
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) {
// removeFromListBulk also drops each feed's inbound mailbox index.
await repo.removeFromListBulk(expiredIds);
await bumpCounters(env.EMAIL_STORAGE, {
feeds_deleted: expiredIds.length,
+3 -2
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest";
import "../test/setup";
import { createMockEnv } from "../test/setup";
import { createMockEnv, seedInboundIndex } from "../test/setup";
import { handleCloudflareEmail } from "./cloudflare-email";
import { getCounters } from "../application/stats";
@@ -62,8 +62,9 @@ const FALLBACK = "fallback@personal.example";
describe("handleCloudflareEmail", () => {
let env: ReturnType<typeof createMockEnv>;
beforeEach(() => {
beforeEach(async () => {
env = createMockEnv();
await seedInboundIndex(env, VALID_FEED_ID);
});
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.
const FORWARDABLE_REASONS = new Set<IngestRejectionReason>([
"invalid_address",
"mailbox_unknown",
"feed_not_found",
]);
+7 -4
View File
@@ -10,6 +10,7 @@ const mockFeedConfig: FeedConfig = {
title: "Test Newsletter",
description: "A test feed",
language: "en",
mailbox_id: "test.news.42",
created_at: 1700000000000,
};
@@ -146,14 +147,15 @@ describe("generateRssFeed", () => {
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(
mockFeedConfig,
mockEmails,
BASE_URL,
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", () => {
@@ -263,14 +265,15 @@ describe("generateAtomFeed", () => {
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(
mockFeedConfig,
mockEmails,
BASE_URL,
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", () => {
+3 -2
View File
@@ -43,8 +43,9 @@ function buildFeed(
// 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).
id: `${baseUrl}/rss/${feedId}`,
// Link points to the admin emails page — the "website" this feed represents.
link: `${baseUrl}/admin/feeds/${feedId}/emails`,
// Public "website" for this feed: its own read URL (never the inbound address
// or an auth-gated admin path, so the feed output leaks neither).
link: `${baseUrl}/rss/${feedId}`,
language: feedConfig.language,
updated: new Date(),
generator: "kill-the-news",
+3
View File
@@ -7,6 +7,7 @@ const fullConfig: FeedConfig = {
title: "News",
description: "desc",
language: "en",
mailbox_id: "a.b.42",
author: "Jane",
allowed_senders: ["a@x.com"],
blocked_senders: ["b@y.com"],
@@ -24,6 +25,7 @@ describe("feed-mapper", () => {
const state = fromConfigDTO({
title: "T",
language: "en",
mailbox_id: "t.t.42",
created_at: 1,
});
expect(state.allowedSenders).toEqual([]);
@@ -39,6 +41,7 @@ describe("feed-mapper", () => {
id: "a.b.42",
title: "News",
description: "desc",
mailbox_id: "a.b.42",
expires_at: 3000,
});
});
+3
View File
@@ -16,6 +16,7 @@ export function fromConfigDTO(dto: FeedConfig): FeedState {
title: dto.title,
description: dto.description,
language: dto.language,
mailboxId: dto.mailbox_id,
author: dto.author,
allowedSenders: dto.allowed_senders ?? [],
blockedSenders: dto.blocked_senders ?? [],
@@ -31,6 +32,7 @@ export function toConfigDTO(state: FeedState): FeedConfig {
title: state.title,
description: state.description,
language: state.language,
mailbox_id: state.mailboxId,
author: state.author,
allowed_senders: state.allowedSenders,
blocked_senders: state.blockedSenders,
@@ -46,6 +48,7 @@ export function toListItemDTO(id: FeedId, state: FeedState): FeedListItem {
id: id.value,
title: state.title,
description: state.description,
mailbox_id: state.mailboxId,
expires_at: state.expiresAt,
};
}
@@ -3,6 +3,7 @@ import { createMockEnv } from "../test/setup";
import { FeedRepository } from "./feed-repository";
import { Feed } from "../domain/feed.aggregate";
import { FeedId } from "../domain/value-objects/feed-id";
import { MailboxId } from "../domain/value-objects/mailbox-id";
import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
const mockEnv = () => createMockEnv() as unknown as Env;
@@ -11,6 +12,7 @@ const fid = (value: string) => FeedId.unchecked(value);
const sampleConfig = (overrides: Partial<FeedConfig> = {}): FeedConfig => ({
title: "Test Feed",
language: "en",
mailbox_id: "test.feed.42",
created_at: 1000,
...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", () => {
it("round-trips and deletes a feed config", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
@@ -106,6 +146,7 @@ describe("FeedRepository feed list", () => {
{
title,
language: "en",
mailboxId: `${id}.mbox`,
allowedSenders: [],
blockedSenders: [],
createdAt: 1000,
@@ -153,4 +194,23 @@ describe("FeedRepository feed list", () => {
expect(removed.sort()).toEqual(["a.b.42", "e.f.10"]);
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 { Feed } from "../domain/feed.aggregate";
import { FeedId } from "../domain/value-objects/feed-id";
import { MailboxId } from "../domain/value-objects/mailbox-id";
import { fromConfigDTO, toConfigDTO, toListItemDTO } from "./feed-mapper";
import { logger } from "./logger";
@@ -87,6 +88,7 @@ export class FeedRepository {
this.putConfig(feed.id, toConfigDTO(feed.state())),
this.putMetadata(feed.id, feed.toMetadataSnapshot()),
this.upsertListEntry(toListItemDTO(feed.id, feed.state())),
this.putInboundIndex(feed.mailboxId, feed.id),
]);
}
@@ -108,9 +110,31 @@ export class FeedRepository {
await Promise.all([
this.putConfig(feed.id, toConfigDTO(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 ───────────────────────────────────────────────────────────
async getConfig(feedId: FeedId): Promise<FeedConfig | null> {
@@ -209,11 +233,13 @@ export class FeedRepository {
if (toRemove.size === 0) return [];
const removed: string[] = [];
const droppedMailboxes: string[] = [];
const nextFeeds: FeedListItem[] = [];
for (const feed of feedList.feeds) {
if (toRemove.has(feed.id)) {
removed.push(feed.id);
if (feed.mailbox_id) droppedMailboxes.push(feed.mailbox_id);
continue;
}
nextFeeds.push(feed);
@@ -223,6 +249,17 @@ export class FeedRepository {
feedList.feeds = nextFeeds;
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;
} catch (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) {
case "invalid_address":
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":
return new Response("Feed does not exist", { status: 404 });
case "feed_expired":
+6 -2
View File
@@ -1,4 +1,5 @@
import { Env } from "../types";
import { MailboxId } from "../domain/value-objects/mailbox-id";
export function baseUrl(env: Env): string {
return `https://${env.DOMAIN}`;
@@ -20,8 +21,11 @@ export function feedUrl(
return format === "rss" ? feedRssUrl(feedId, env) : feedAtomUrl(feedId, env);
}
export function feedEmailAddress(feedId: string, env: Env): string {
return `${feedId}@${env.EMAIL_DOMAIN ?? env.DOMAIN}`;
export function feedEmailAddress(mailboxId: string, env: Env): string {
// 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 {
+1
View File
@@ -60,6 +60,7 @@ async function buildFeedXml(
title: `Newsletter Feed ${feedId.value}`,
description: "Converted email newsletter",
language: "en",
mailbox_id: "",
created_at: Date.now(),
};
+48
View File
@@ -168,6 +168,27 @@ describe("Admin Routes", () => {
expect(feedConfig).toBeTruthy();
expect((feedConfig as any).title).toBe("Test Feed");
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 () => {
@@ -732,6 +753,15 @@ describe("Admin Routes", () => {
it("lists attachments with download links on the email detail page", async () => {
const authCookie = await loginAndGetCookie();
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`;
await mockEnv.EMAIL_STORAGE.put(
emailKey,
@@ -769,6 +799,15 @@ describe("Admin Routes", () => {
it("renders inline cid images in place and hides them from the attachments list", async () => {
const authCookie = await loginAndGetCookie();
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`;
await mockEnv.EMAIL_STORAGE.put(
emailKey,
@@ -814,6 +853,15 @@ describe("Admin Routes", () => {
it("does not render an attachments section when the email has none", async () => {
const authCookie = await loginAndGetCookie();
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`;
await mockEnv.EMAIL_STORAGE.put(
emailKey,
+5 -2
View File
@@ -666,7 +666,10 @@ app.get("/", async (c) => {
</thead>
<tbody id="feed-table-body">
{feedsWithConfig.map((feed) => {
const emailAddress = feedEmailAddress(feed.id, env);
const emailAddress = feedEmailAddress(
feed.mailbox_id,
env,
);
const rssUrl = feedRssUrl(feed.id, env);
const atomUrl = feedAtomUrl(feed.id, env);
const titleDisplay = clampText(feed.title, 160);
@@ -823,7 +826,7 @@ app.get("/", async (c) => {
<ul class="feed-list">
{feedsWithConfig.map((feed) => {
const emailAddress = feedEmailAddress(feed.id, env);
const emailAddress = feedEmailAddress(feed.mailbox_id, env);
const rssUrl = feedRssUrl(feed.id, env);
const atomUrl = feedAtomUrl(feed.id, env);
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);
}
const emailAddress = feedEmailAddress(feedId, env);
const emailAddress = feedEmailAddress(feedConfig.mailbox_id, env);
const rssUrl = feedRssUrl(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);
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.
const attachments = (emailData.attachments ?? []).filter((a) => !a.inline);
@@ -584,7 +586,10 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
value={new Date(emailData.receivedAt).toLocaleString()}
/>
<SenderField from={emailData.from} feedId={feedId} />
<CopyField label="To:" value={feedEmailAddress(feedId, env)} />
<CopyField
label="To:"
value={feedEmailAddress(feedConfig.mailbox_id, env)}
/>
</div>
</div>
+2 -2
View File
@@ -121,7 +121,7 @@ feedsRouter.post("/create", async (c) => {
? parseInt(lifetimeHoursRaw, 10)
: undefined;
const { feedId } = await createFeedRecord(env, {
const { feedId, mailboxId } = await createFeedRecord(env, {
title: parsedData.title,
description: parsedData.description,
language: parsedData.language,
@@ -133,7 +133,7 @@ feedsRouter.post("/create", async (c) => {
if (isJson) {
return c.json({
feedId,
email: feedEmailAddress(feedId, env),
email: feedEmailAddress(mailboxId, env),
feedUrl: feedRssUrl(feedId, env),
});
}
+2 -2
View File
@@ -63,7 +63,7 @@ function toFeed(
updatedAt: config.updated_at,
expiresAt: config.expires_at,
emailCount,
emailAddress: feedEmailAddress(id, env),
emailAddress: feedEmailAddress(config.mailbox_id, env),
rssUrl: feedRssUrl(id, env),
atomUrl: feedAtomUrl(id, env),
};
@@ -117,7 +117,7 @@ apiApp.openapi(
title: f.title,
description: f.description,
expiresAt: f.expires_at,
emailAddress: feedEmailAddress(f.id, env),
emailAddress: feedEmailAddress(f.mailbox_id, env),
rssUrl: feedRssUrl(f.id, env),
atomUrl: feedAtomUrl(f.id, env),
})),
+3 -1
View File
@@ -14,7 +14,9 @@ export const FeedIdParam = z.object({
.min(1)
.openapi({
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 { http, HttpResponse } from "msw";
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 { ForwardEmailPayload } from "../infrastructure/forwardemail";
@@ -64,6 +64,7 @@ describe("POST /api/inbound — IP middleware", () => {
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: [] }),
);
await seedInboundIndex(env, VALID_FEED_ID);
});
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", () => {
let env: Env;
beforeEach(() => {
beforeEach(async () => {
stubForwardEmailIps();
env = createMockEnv() as unknown as Env;
await seedInboundIndex(env, VALID_FEED_ID);
});
it("returns 500 on malformed JSON body", async () => {
@@ -232,9 +234,10 @@ describe("POST /api/inbound — handler logic", () => {
describe("POST /api/inbound — attachment upload", () => {
let env: Env;
beforeEach(() => {
beforeEach(async () => {
stubForwardEmailIps();
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 () => {
+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)", () => {
const FEED_ID = "test-feed-rss-cget";
const EMAIL_RECEIVED_AT = 1700000001000;
+14
View File
@@ -1,5 +1,6 @@
import { beforeAll, afterAll, afterEach } from "vitest";
import { setupServer } from "msw/node";
import { feedKeys } from "../domain/feed-keys";
// Minimal Node.js built-ins used only in this test setup file.
// 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 }
: {}),
});
/**
* 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 {
title: 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[];
blocked_senders?: string[];
language: string;
@@ -82,6 +85,7 @@ export interface FeedListItem {
id: string;
title: 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
}