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 <[email protected]>
This commit is contained in:
Julien HerrandClaude Opus 4.7 committed 2026-05-24 22:46:37 +02:00
1 parent f7f10779bc
commit 1a4a479190
43 files changed
+649 -149

No files matched your search

+17 -26
View File
@@ -1,36 +1,27 @@
import { describe, it, expect } from "vitest";
import { FeedId } from "./feed-id";
describe("FeedId.parse", () => {
it("extracts the feed id from an inbound address", () => {
expect(FeedId.parse("[email protected]")?.value).toBe(
"river.castle.42",
);
});
it("preserves the original casing of the local part", () => {
expect(FeedId.parse("[email protected]")?.value).toBe(
"River.Castle.42",
);
});
it("rejects malformed feed ids", () => {
expect(FeedId.parse("[email protected]")).toBeNull();
expect(FeedId.parse("notanemail")).toBeNull();
expect(FeedId.parse("[email protected]")).toBeNull();
expect(FeedId.parse("[email protected]")).toBeNull();
});
});
describe("FeedId.generate", () => {
it("produces the noun.noun.NN format", () => {
it("produces an opaque base64url token", () => {
for (let i = 0; i < 50; i++) {
expect(FeedId.generate().value).toMatch(/^[a-z]+\.[a-z]+\.\d{2}$/);
expect(FeedId.generate().value).toMatch(/^[A-Za-z0-9_-]{22}$/);
}
});
it("round-trips through parse from an address", () => {
const id = FeedId.generate();
expect(FeedId.parse(`${id.value}@example.com`)?.value).toBe(id.value);
it("is unguessable: 50 ids are all distinct", () => {
const ids = new Set(
Array.from({ length: 50 }, () => FeedId.generate().value),
);
expect(ids.size).toBe(50);
});
it("does not produce the legacy noun.noun.NN format", () => {
expect(FeedId.generate().value).not.toMatch(/^[a-z]+\.[a-z]+\.\d{2}$/);
});
});
describe("FeedId.unchecked", () => {
it("wraps a value without validation", () => {
expect(FeedId.unchecked("anything").value).toBe("anything");
});
});
+23 -19
View File
@@ -1,37 +1,41 @@
import { nouns } from "../../data/nouns";
// Feed IDs are noun1.noun2.XY (two lowercase nouns + a 2-digit suffix).
const FEED_ID_IN_ADDRESS = /^([a-z]+\.[a-z]+\.\d{2})@/i;
/** Encode bytes as unpadded base64url (URL- and KV-key-safe). */
function base64url(bytes: Uint8Array): string {
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
/**
* A feed identifier. `parse` pulls it from the local part of an inbound email
* address; `generate` mints a fresh one. The original casing is preserved.
* A feed's identity: the KV storage key AND the public read id in `/rss/:feedId`
* etc. It is an opaque, high-entropy random token — unguessable, so a feed's read
* URL can be shared without revealing its inbound address (the `MailboxId`, a
* separate value that resolves here via the `inbound:` index at reception).
*
* `generate` mints a fresh token; `unchecked` wraps a route param or stored key
* without revalidation (a wrong id simply misses in KV and 404s downstream).
*/
export class FeedId {
private constructor(readonly value: string) {}
/** Extract the feed id from an inbound address (`noun.noun.NN@domain`). */
static parse(emailAddress: string): FeedId | null {
const match = emailAddress.match(FEED_ID_IN_ADDRESS);
return match ? new FeedId(match[1]) : null;
}
/**
* Wrap a string as a FeedId WITHOUT revalidating it. The caller asserts the id
* originated from our own minting — a route param echoing a stored id, a
* `feeds:list` entry, or an email/KV key. The name is deliberately blunt: a
* wrong id is not rejected here, it simply misses in KV and 404s downstream.
* Untrusted external input (an inbound address) must go through `parse` instead.
* `feeds:list` entry, an `inbound:` index value, or a KV key. The name is
* deliberately blunt: a wrong id is not rejected here, it simply misses in KV
* and 404s downstream.
*/
static unchecked(value: string): FeedId {
return new FeedId(value);
}
/** Mint a fresh, opaque identity (128 bits of entropy → 22 base64url chars). */
static generate(): FeedId {
const noun1 = nouns[Math.floor(Math.random() * nouns.length)];
const noun2 = nouns[Math.floor(Math.random() * nouns.length)];
const number = Math.floor(Math.random() * 90) + 10;
return new FeedId(`${noun1}.${noun2}.${number}`);
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return new FeedId(base64url(bytes));
}
toString(): string {
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { MailboxId } from "./mailbox-id";
describe("MailboxId.parse", () => {
it("extracts the mailbox id from an inbound address", () => {
expect(MailboxId.parse("[email protected]")?.value).toBe(
"river.castle.42",
);
});
it("preserves the original casing of the local part", () => {
expect(MailboxId.parse("[email protected]")?.value).toBe(
"River.Castle.42",
);
});
it("rejects malformed mailbox ids", () => {
expect(MailboxId.parse("[email protected]")).toBeNull();
expect(MailboxId.parse("notanemail")).toBeNull();
expect(MailboxId.parse("[email protected]")).toBeNull();
expect(MailboxId.parse("[email protected]")).toBeNull();
});
});
describe("MailboxId.generate", () => {
it("produces the noun.noun.NN format", () => {
for (let i = 0; i < 50; i++) {
expect(MailboxId.generate().value).toMatch(/^[a-z]+\.[a-z]+\.\d{2}$/);
}
});
it("round-trips through parse from an address", () => {
const id = MailboxId.generate();
expect(MailboxId.parse(`${id.value}@example.com`)?.value).toBe(id.value);
});
});
describe("MailboxId.unchecked", () => {
it("wraps a value without validation", () => {
expect(MailboxId.unchecked("anything").value).toBe("anything");
});
});
describe("MailboxId.emailAddress", () => {
it("builds the full inbound address from the mailbox and a domain", () => {
expect(
MailboxId.unchecked("river.castle.42").emailAddress("news.app"),
).toBe("[email protected]");
});
});
+49
View File
@@ -0,0 +1,49 @@
import { nouns } from "../../data/nouns";
// Inbound mailbox ids are noun1.noun2.XY (two lowercase nouns + a 2-digit suffix).
const MAILBOX_IN_ADDRESS = /^([a-z]+\.[a-z]+\.\d{2})@/i;
/**
* A feed's inbound mailbox identifier — the friendly `noun.noun.NN` local part of
* the address newsletters are sent to (`<mailboxId>@domain`). It is deliberately
* NOT the feed's identity: a `MailboxId` resolves to a `FeedId` through the
* `inbound:` index at reception, so the public read URL (the opaque `FeedId`) and
* the inbound address stay decoupled.
*
* `parse` pulls it from an untrusted inbound address (the most untrusted input in
* the system); `generate` mints a fresh one. The original casing is preserved.
*/
export class MailboxId {
private constructor(readonly value: string) {}
/** Extract the mailbox id from an inbound address (`noun.noun.NN@domain`). */
static parse(emailAddress: string): MailboxId | null {
const match = emailAddress.match(MAILBOX_IN_ADDRESS);
return match ? new MailboxId(match[1]) : null;
}
/**
* Wrap a string as a MailboxId WITHOUT revalidating it. The caller asserts the
* value originated from our own minting (a stored `mailbox_id`). Untrusted
* external input (an inbound address) must go through `parse` instead.
*/
static unchecked(value: string): MailboxId {
return new MailboxId(value);
}
static generate(): MailboxId {
const noun1 = nouns[Math.floor(Math.random() * nouns.length)];
const noun2 = nouns[Math.floor(Math.random() * nouns.length)];
const number = Math.floor(Math.random() * 90) + 10;
return new MailboxId(`${noun1}.${noun2}.${number}`);
}
/** The full inbound email address (`<mailboxId>@<domain>`) newsletters target. */
emailAddress(domain: string): string {
return `${this.value}@${domain}`;
}
toString(): string {
return this.value;
}
}