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:
1 parent
f7f10779bc
commit
1a4a479190
43 files changed
+649
-149
No files matched your search
@@ -1,37 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { EmailParser } from "./email-parser";
|
||||
|
||||
describe("EmailParser.extractFeedId", () => {
|
||||
it("extracts a valid feed ID from an email address", () => {
|
||||
expect(
|
||||
EmailParser.extractFeedId("[email protected]")?.value,
|
||||
).toBe("river.castle.42");
|
||||
});
|
||||
|
||||
it("is case-insensitive for the local part", () => {
|
||||
expect(
|
||||
EmailParser.extractFeedId("[email protected]")?.value,
|
||||
).toBe("River.Castle.42");
|
||||
});
|
||||
|
||||
it("returns null for an address with no feed ID format", () => {
|
||||
expect(EmailParser.extractFeedId("[email protected]")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a plain string without @", () => {
|
||||
expect(EmailParser.extractFeedId("notanemail")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the numeric suffix is only one digit", () => {
|
||||
expect(EmailParser.extractFeedId("[email protected]")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the numeric suffix has more than two digits", () => {
|
||||
expect(
|
||||
EmailParser.extractFeedId("[email protected]"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
// Inbound mailbox parsing lives on the MailboxId VO (see mailbox-id.test.ts);
|
||||
// EmailParser no longer wraps it.
|
||||
|
||||
describe("EmailParser.decodeEncodedWords", () => {
|
||||
it("returns plain text unchanged", () => {
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import { EmailData } from "../types";
|
||||
import { FeedId } from "./value-objects/feed-id";
|
||||
|
||||
export class EmailParser {
|
||||
/**
|
||||
* Extract the feed id from an inbound recipient address. Returns a validated
|
||||
* `FeedId` value object (not a raw string) so the most untrusted input in the
|
||||
* system — an address typed by a sender — is guarded at the parse boundary and
|
||||
* never needs `FeedId.unchecked` downstream.
|
||||
*/
|
||||
static extractFeedId(emailAddress: string): FeedId | null {
|
||||
return FeedId.parse(emailAddress);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
static parseForwardEmailPayload(payload: any): EmailData {
|
||||
if (!payload) {
|
||||
|
||||
@@ -13,6 +13,9 @@ export const feedKeys = {
|
||||
config: (feedId: string): string => `feed:${feedId}:config`,
|
||||
metadata: (feedId: string): string => `feed:${feedId}:metadata`,
|
||||
|
||||
/** Secondary index: inbound mailbox local part → feed id (resolved at reception). */
|
||||
inbound: (mailboxId: string): string => `inbound:${mailboxId}`,
|
||||
|
||||
/** Prefix covering every key owned by a feed (config, metadata, emails). */
|
||||
feedPrefix: (feedId: string): string => `feed:${feedId}:`,
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface FeedState {
|
||||
title: string;
|
||||
description?: string;
|
||||
language: string;
|
||||
/** The feed's inbound mailbox local part (`noun.noun.NN`) — its email address
|
||||
* is `mailboxId@domain`. Decoupled from the feed's `FeedId` (the read id). */
|
||||
mailboxId: string;
|
||||
author?: string;
|
||||
allowedSenders: string[];
|
||||
blockedSenders: string[];
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FeedMetadata, EmailMetadata } from "../types";
|
||||
import { FeedState } from "./feed-state";
|
||||
import { FeedId } from "./value-objects/feed-id";
|
||||
import { MailboxId } from "./value-objects/mailbox-id";
|
||||
import { Lifetime } from "./value-objects/lifetime";
|
||||
import { SenderPolicy, SenderDecision } from "./value-objects/sender-policy";
|
||||
import { Clock, systemClock } from "./clock";
|
||||
@@ -32,6 +33,8 @@ export interface UpdateFeedInput {
|
||||
* applying any server-side `FEED_TTL_HOURS` override — and hands the VO in.
|
||||
*/
|
||||
export interface CreateFeedDeps {
|
||||
/** The feed's inbound mailbox, minted by the application alongside its FeedId. */
|
||||
mailboxId: MailboxId;
|
||||
clock?: Clock;
|
||||
/** Effective lifetime, already resolved by the application. */
|
||||
lifetime?: Lifetime;
|
||||
@@ -82,7 +85,7 @@ export class Feed {
|
||||
static create(
|
||||
id: FeedId,
|
||||
input: CreateFeedInput,
|
||||
deps: CreateFeedDeps = {},
|
||||
deps: CreateFeedDeps,
|
||||
): Feed {
|
||||
const clock = deps.clock ?? systemClock;
|
||||
const now = clock.now();
|
||||
@@ -91,6 +94,7 @@ export class Feed {
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
language: input.language,
|
||||
mailboxId: deps.mailboxId.value,
|
||||
allowedSenders: input.allowedSenders,
|
||||
blockedSenders: input.blockedSenders,
|
||||
createdAt: now,
|
||||
@@ -130,6 +134,11 @@ export class Feed {
|
||||
return this._state.language;
|
||||
}
|
||||
|
||||
/** The inbound mailbox (`noun.noun.NN`) — the feed's email address is `mailboxId@domain`. */
|
||||
get mailboxId(): MailboxId {
|
||||
return MailboxId.unchecked(this._state.mailboxId);
|
||||
}
|
||||
|
||||
get createdAt(): number {
|
||||
return this._state.createdAt;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user