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