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,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",
);
});
});