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
+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 () => {