mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import "../test/setup";
|
||||
import { createMockEnv } from "../test/setup";
|
||||
import { createMockEnv, seedInboundIndex } from "../test/setup";
|
||||
import { handleCloudflareEmail } from "./cloudflare-email";
|
||||
import { getCounters } from "../application/stats";
|
||||
|
||||
@@ -62,8 +62,9 @@ const FALLBACK = "fallback@personal.example";
|
||||
describe("handleCloudflareEmail", () => {
|
||||
let env: ReturnType<typeof createMockEnv>;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
env = createMockEnv();
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
});
|
||||
|
||||
it("stores email in KV when feed exists", async () => {
|
||||
|
||||
@@ -68,6 +68,7 @@ export async function handleCloudflareEmail(
|
||||
// dropped so a real newsletter never leaks into the fallback inbox.
|
||||
const FORWARDABLE_REASONS = new Set<IngestRejectionReason>([
|
||||
"invalid_address",
|
||||
"mailbox_unknown",
|
||||
"feed_not_found",
|
||||
]);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const mockFeedConfig: FeedConfig = {
|
||||
title: "Test Newsletter",
|
||||
description: "A test feed",
|
||||
language: "en",
|
||||
mailbox_id: "test.news.42",
|
||||
created_at: 1700000000000,
|
||||
};
|
||||
|
||||
@@ -146,14 +147,15 @@ describe("generateRssFeed", () => {
|
||||
expect(result).not.toContain("<item>");
|
||||
});
|
||||
|
||||
it("feed link points to admin emails page", () => {
|
||||
it("feed link points to the public read URL, never an admin path", () => {
|
||||
const result = generateRssFeed(
|
||||
mockFeedConfig,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain(`${BASE_URL}/admin/feeds/${FEED_ID}/emails`);
|
||||
expect(result).toContain(`<link>${BASE_URL}/rss/${FEED_ID}</link>`);
|
||||
expect(result).not.toContain("/admin/");
|
||||
});
|
||||
|
||||
it("strips html/head/body wrapper from item description", () => {
|
||||
@@ -263,14 +265,15 @@ describe("generateAtomFeed", () => {
|
||||
expect(result).not.toContain("<entry>");
|
||||
});
|
||||
|
||||
it("feed link points to admin emails page", () => {
|
||||
it("feed link points to the public read URL, never an admin path", () => {
|
||||
const result = generateAtomFeed(
|
||||
mockFeedConfig,
|
||||
mockEmails,
|
||||
BASE_URL,
|
||||
FEED_ID,
|
||||
);
|
||||
expect(result).toContain(`${BASE_URL}/admin/feeds/${FEED_ID}/emails`);
|
||||
expect(result).toContain(`${BASE_URL}/rss/${FEED_ID}`);
|
||||
expect(result).not.toContain("/admin/");
|
||||
});
|
||||
|
||||
it("strips html/head/body wrapper from entry content", () => {
|
||||
|
||||
@@ -43,8 +43,9 @@ function buildFeed(
|
||||
// Computed dynamically so the id is always canonical regardless of what
|
||||
// was stored in KV at feed-creation time (which may have used a stale domain).
|
||||
id: `${baseUrl}/rss/${feedId}`,
|
||||
// Link points to the admin emails page — the "website" this feed represents.
|
||||
link: `${baseUrl}/admin/feeds/${feedId}/emails`,
|
||||
// Public "website" for this feed: its own read URL (never the inbound address
|
||||
// or an auth-gated admin path, so the feed output leaks neither).
|
||||
link: `${baseUrl}/rss/${feedId}`,
|
||||
language: feedConfig.language,
|
||||
updated: new Date(),
|
||||
generator: "kill-the-news",
|
||||
|
||||
@@ -7,6 +7,7 @@ const fullConfig: FeedConfig = {
|
||||
title: "News",
|
||||
description: "desc",
|
||||
language: "en",
|
||||
mailbox_id: "a.b.42",
|
||||
author: "Jane",
|
||||
allowed_senders: ["a@x.com"],
|
||||
blocked_senders: ["b@y.com"],
|
||||
@@ -24,6 +25,7 @@ describe("feed-mapper", () => {
|
||||
const state = fromConfigDTO({
|
||||
title: "T",
|
||||
language: "en",
|
||||
mailbox_id: "t.t.42",
|
||||
created_at: 1,
|
||||
});
|
||||
expect(state.allowedSenders).toEqual([]);
|
||||
@@ -39,6 +41,7 @@ describe("feed-mapper", () => {
|
||||
id: "a.b.42",
|
||||
title: "News",
|
||||
description: "desc",
|
||||
mailbox_id: "a.b.42",
|
||||
expires_at: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ export function fromConfigDTO(dto: FeedConfig): FeedState {
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
language: dto.language,
|
||||
mailboxId: dto.mailbox_id,
|
||||
author: dto.author,
|
||||
allowedSenders: dto.allowed_senders ?? [],
|
||||
blockedSenders: dto.blocked_senders ?? [],
|
||||
@@ -31,6 +32,7 @@ export function toConfigDTO(state: FeedState): FeedConfig {
|
||||
title: state.title,
|
||||
description: state.description,
|
||||
language: state.language,
|
||||
mailbox_id: state.mailboxId,
|
||||
author: state.author,
|
||||
allowed_senders: state.allowedSenders,
|
||||
blocked_senders: state.blockedSenders,
|
||||
@@ -46,6 +48,7 @@ export function toListItemDTO(id: FeedId, state: FeedState): FeedListItem {
|
||||
id: id.value,
|
||||
title: state.title,
|
||||
description: state.description,
|
||||
mailbox_id: state.mailboxId,
|
||||
expires_at: state.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createMockEnv } from "../test/setup";
|
||||
import { FeedRepository } from "./feed-repository";
|
||||
import { Feed } from "../domain/feed.aggregate";
|
||||
import { FeedId } from "../domain/value-objects/feed-id";
|
||||
import { MailboxId } from "../domain/value-objects/mailbox-id";
|
||||
import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
|
||||
|
||||
const mockEnv = () => createMockEnv() as unknown as Env;
|
||||
@@ -11,6 +12,7 @@ const fid = (value: string) => FeedId.unchecked(value);
|
||||
const sampleConfig = (overrides: Partial<FeedConfig> = {}): FeedConfig => ({
|
||||
title: "Test Feed",
|
||||
language: "en",
|
||||
mailbox_id: "test.feed.42",
|
||||
created_at: 1000,
|
||||
...overrides,
|
||||
});
|
||||
@@ -46,6 +48,44 @@ describe("FeedRepository key schema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeedRepository inbound index", () => {
|
||||
const mbox = (v: string) => MailboxId.unchecked(v);
|
||||
|
||||
it("resolves a mailbox to its feed id and back to null after delete", async () => {
|
||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||
expect(await repo.resolveInbound(mbox("river.castle.42"))).toBeNull();
|
||||
|
||||
await repo.putInboundIndex(mbox("river.castle.42"), fid("opaque-id-1"));
|
||||
expect((await repo.resolveInbound(mbox("river.castle.42")))?.value).toBe(
|
||||
"opaque-id-1",
|
||||
);
|
||||
|
||||
await repo.deleteInboundIndex(mbox("river.castle.42"));
|
||||
expect(await repo.resolveInbound(mbox("river.castle.42"))).toBeNull();
|
||||
});
|
||||
|
||||
it("save() writes the inbound index from the aggregate's mailbox", async () => {
|
||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||
await repo.save(
|
||||
Feed.reconstitute(
|
||||
fid("opaque-id-2"),
|
||||
{
|
||||
title: "T",
|
||||
language: "en",
|
||||
mailboxId: "lake.tower.77",
|
||||
allowedSenders: [],
|
||||
blockedSenders: [],
|
||||
createdAt: 1000,
|
||||
},
|
||||
{ emails: [] },
|
||||
),
|
||||
);
|
||||
expect((await repo.resolveInbound(mbox("lake.tower.77")))?.value).toBe(
|
||||
"opaque-id-2",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeedRepository config & metadata", () => {
|
||||
it("round-trips and deletes a feed config", async () => {
|
||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||
@@ -106,6 +146,7 @@ describe("FeedRepository feed list", () => {
|
||||
{
|
||||
title,
|
||||
language: "en",
|
||||
mailboxId: `${id}.mbox`,
|
||||
allowedSenders: [],
|
||||
blockedSenders: [],
|
||||
createdAt: 1000,
|
||||
@@ -153,4 +194,23 @@ describe("FeedRepository feed list", () => {
|
||||
expect(removed.sort()).toEqual(["a.b.42", "e.f.10"]);
|
||||
expect((await repo.listFeeds()).map((f) => f.id)).toEqual(["c.d.99"]);
|
||||
});
|
||||
|
||||
it("drops each removed feed's inbound index (symmetric with save)", async () => {
|
||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||
const mbox = (v: string) => MailboxId.unchecked(v);
|
||||
await repo.save(feedWith("a.b.42", "One"));
|
||||
await repo.save(feedWith("c.d.99", "Two"));
|
||||
|
||||
// Both addresses resolve before removal.
|
||||
expect(await repo.resolveInbound(mbox("a.b.42.mbox"))).not.toBeNull();
|
||||
expect(await repo.resolveInbound(mbox("c.d.99.mbox"))).not.toBeNull();
|
||||
|
||||
await repo.removeFromListBulk(["a.b.42"]);
|
||||
|
||||
// The removed feed's address stops resolving; the survivor's still does.
|
||||
expect(await repo.resolveInbound(mbox("a.b.42.mbox"))).toBeNull();
|
||||
expect((await repo.resolveInbound(mbox("c.d.99.mbox")))?.value).toBe(
|
||||
"c.d.99",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -15,6 +15,8 @@ export function ingestResultToResponse(result: IngestResult): Response {
|
||||
switch (result.reason) {
|
||||
case "invalid_address":
|
||||
return new Response("Invalid email address format", { status: 400 });
|
||||
case "mailbox_unknown":
|
||||
return new Response("No feed for this address", { status: 404 });
|
||||
case "feed_not_found":
|
||||
return new Response("Feed does not exist", { status: 404 });
|
||||
case "feed_expired":
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Env } from "../types";
|
||||
import { MailboxId } from "../domain/value-objects/mailbox-id";
|
||||
|
||||
export function baseUrl(env: Env): string {
|
||||
return `https://${env.DOMAIN}`;
|
||||
@@ -20,8 +21,11 @@ export function feedUrl(
|
||||
return format === "rss" ? feedRssUrl(feedId, env) : feedAtomUrl(feedId, env);
|
||||
}
|
||||
|
||||
export function feedEmailAddress(feedId: string, env: Env): string {
|
||||
return `${feedId}@${env.EMAIL_DOMAIN ?? env.DOMAIN}`;
|
||||
export function feedEmailAddress(mailboxId: string, env: Env): string {
|
||||
// The mailbox→address shape lives on the VO; this edge only resolves the domain.
|
||||
return MailboxId.unchecked(mailboxId).emailAddress(
|
||||
env.EMAIL_DOMAIN ?? env.DOMAIN,
|
||||
);
|
||||
}
|
||||
|
||||
export function feedTopicPattern(env: Env): RegExp {
|
||||
|
||||
@@ -60,6 +60,7 @@ async function buildFeedXml(
|
||||
title: `Newsletter Feed ${feedId.value}`,
|
||||
description: "Converted email newsletter",
|
||||
language: "en",
|
||||
mailbox_id: "",
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user