mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-21 06:13: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:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user