feat: decouple read FeedId from inbound MailboxId

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-24 22:46:37 +02:00
parent f7f10779bc
commit 1a4a479190
43 changed files with 649 additions and 149 deletions
+3 -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(),
};