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
+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) });