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 { http, HttpResponse } from "msw";
|
||||
import { createMockEnv, MockR2, server } from "../test/setup";
|
||||
import { createMockEnv, MockR2, seedInboundIndex, server } from "../test/setup";
|
||||
import {
|
||||
processEmail,
|
||||
ProcessEmailInput,
|
||||
@@ -30,8 +30,10 @@ function makeInput(
|
||||
describe("processEmail", () => {
|
||||
let env: ReturnType<typeof createMockEnv>;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
env = createMockEnv();
|
||||
// The inbound address resolves to a feed of the same id in these unit tests.
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
});
|
||||
|
||||
it("returns 400 when toAddress has no valid feedId", async () => {
|
||||
@@ -42,7 +44,18 @@ describe("processEmail", () => {
|
||||
expect(res).toMatchObject({ ok: false, reason: "invalid_address" });
|
||||
});
|
||||
|
||||
it("returns 404 when feed does not exist", async () => {
|
||||
it("returns mailbox_unknown when no feed claims the inbound address", async () => {
|
||||
// A well-formed mailbox (noun.noun.NN) that was never registered in the
|
||||
// inbound index — distinct from a dangling index pointing at a missing feed.
|
||||
const res = await processEmail(
|
||||
makeInput({ toAddress: "unknown.mailbox.99@test.getmynews.app" }),
|
||||
env as any,
|
||||
);
|
||||
expect(res).toMatchObject({ ok: false, reason: "mailbox_unknown" });
|
||||
});
|
||||
|
||||
it("returns feed_not_found when the index resolves but the feed is gone", async () => {
|
||||
// The inbound index is seeded (beforeEach) but no config exists for it.
|
||||
const res = await processEmail(makeInput(), env as any);
|
||||
expect(res).toMatchObject({ ok: false, reason: "feed_not_found" });
|
||||
});
|
||||
@@ -319,14 +332,14 @@ describe("processEmail", () => {
|
||||
passThroughOnException: () => {},
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
// Feed ID is valid format but config doesn't exist → 404
|
||||
// Well-formed mailbox but not registered → mailbox_unknown (an error path).
|
||||
const res = await processEmail(
|
||||
makeInput({ toAddress: `no.such.99@test.getmynews.app` }),
|
||||
env as any,
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({ ok: false, reason: "feed_not_found" });
|
||||
expect(res).toMatchObject({ ok: false, reason: "mailbox_unknown" });
|
||||
expect(waitUntilCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -343,6 +356,7 @@ describe("processEmail — attachments", () => {
|
||||
|
||||
it("skips R2 upload when ATTACHMENT_BUCKET is not configured", async () => {
|
||||
const env = createMockEnv();
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
await env.EMAIL_STORAGE.put(
|
||||
`feed:${VALID_FEED_ID}:config`,
|
||||
JSON.stringify({}),
|
||||
@@ -366,6 +380,7 @@ describe("processEmail — attachments", () => {
|
||||
|
||||
it("skips R2 upload when ATTACHMENTS_ENABLED is 'false' even with R2 bound", async () => {
|
||||
const env = createMockEnv({ withR2: true });
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
(env as any).ATTACHMENTS_ENABLED = "false";
|
||||
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
||||
await env.EMAIL_STORAGE.put(
|
||||
@@ -392,6 +407,7 @@ describe("processEmail — attachments", () => {
|
||||
|
||||
it("uploads attachments to R2 and stores AttachmentData in emailData", async () => {
|
||||
const env = createMockEnv({ withR2: true });
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
||||
await env.EMAIL_STORAGE.put(
|
||||
`feed:${VALID_FEED_ID}:config`,
|
||||
@@ -423,6 +439,7 @@ describe("processEmail — attachments", () => {
|
||||
|
||||
it("stores attachmentIds in EmailMetadata for trim-time cleanup", async () => {
|
||||
const env = createMockEnv({ withR2: true });
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
await env.EMAIL_STORAGE.put(
|
||||
`feed:${VALID_FEED_ID}:config`,
|
||||
JSON.stringify({}),
|
||||
@@ -439,6 +456,7 @@ describe("processEmail — attachments", () => {
|
||||
|
||||
it("classifies a cid-referenced image as inline, not a downloadable attachment", async () => {
|
||||
const env = createMockEnv({ withR2: true });
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
await env.EMAIL_STORAGE.put(
|
||||
`feed:${VALID_FEED_ID}:config`,
|
||||
JSON.stringify({}),
|
||||
@@ -484,6 +502,7 @@ describe("processEmail — attachments", () => {
|
||||
|
||||
it("deletes inline image R2 objects when a trimmed email had them", async () => {
|
||||
const env = createMockEnv({ withR2: true });
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
||||
await env.EMAIL_STORAGE.put(
|
||||
`feed:${VALID_FEED_ID}:config`,
|
||||
@@ -537,6 +556,7 @@ describe("processEmail — attachments", () => {
|
||||
|
||||
it("deletes R2 objects when a trimmed email had attachments", async () => {
|
||||
const env = createMockEnv({ withR2: true });
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
||||
await env.EMAIL_STORAGE.put(
|
||||
`feed:${VALID_FEED_ID}:config`,
|
||||
@@ -604,6 +624,7 @@ describe("processEmail — deduplication", () => {
|
||||
`feed:${VALID_FEED_ID}:config`,
|
||||
JSON.stringify({}),
|
||||
);
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
});
|
||||
|
||||
it("stores only one email when the same Message-ID is delivered twice", async () => {
|
||||
@@ -726,6 +747,7 @@ describe("processEmail — deduplication", () => {
|
||||
describe("processEmail — monitoring counters", () => {
|
||||
it("increments emails_received and sets last_email_at on success", async () => {
|
||||
const env = createMockEnv();
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
await env.EMAIL_STORAGE.put(
|
||||
`feed:${VALID_FEED_ID}:config`,
|
||||
JSON.stringify({}),
|
||||
@@ -760,6 +782,7 @@ describe("processEmail — feed icon", () => {
|
||||
`feed:${VALID_FEED_ID}:config`,
|
||||
JSON.stringify({}),
|
||||
);
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
});
|
||||
|
||||
it("persists the latest sender domain on the feed metadata", async () => {
|
||||
@@ -811,6 +834,7 @@ describe("processEmail — unsubscribe capture", () => {
|
||||
`feed:${VALID_FEED_ID}:config`,
|
||||
JSON.stringify({}),
|
||||
);
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
});
|
||||
|
||||
it("stores the one-click unsubscribe URL on the feed metadata, keyed by sender", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EmailParser } from "../domain/email-parser";
|
||||
import { MailboxId } from "../domain/value-objects/mailbox-id";
|
||||
import { AttachmentData, EmailMetadata, Env } from "../types";
|
||||
import { bumpCounters } from "../application/stats";
|
||||
import { dispatchFeedEvents } from "../application/feed-events";
|
||||
@@ -33,6 +33,7 @@ export interface ProcessEmailInput {
|
||||
|
||||
export type IngestRejectionReason =
|
||||
| "invalid_address"
|
||||
| "mailbox_unknown"
|
||||
| "feed_not_found"
|
||||
| "feed_expired"
|
||||
| "sender_blocked";
|
||||
@@ -79,17 +80,33 @@ async function loadAcceptingFeed(
|
||||
): Promise<
|
||||
{ ok: true; feed: Feed } | { ok: false; reason: IngestRejectionReason }
|
||||
> {
|
||||
const feedId = EmailParser.extractFeedId(input.toAddress);
|
||||
if (!feedId) {
|
||||
// MailboxId.parse is the single boundary where an untrusted inbound address
|
||||
// (the most untrusted input in the system) becomes a validated mailbox.
|
||||
const mailbox = MailboxId.parse(input.toAddress);
|
||||
if (!mailbox) {
|
||||
logger.error("Invalid email address format", {
|
||||
toAddress: input.toAddress,
|
||||
});
|
||||
return { ok: false, reason: "invalid_address" };
|
||||
}
|
||||
|
||||
const feed = await FeedRepository.from(env).load(feedId);
|
||||
// Resolve the inbound mailbox to the feed's opaque id (decoupled identities).
|
||||
const repo = FeedRepository.from(env);
|
||||
const feedId = await repo.resolveInbound(mailbox);
|
||||
if (!feedId) {
|
||||
// No feed claims this address — the common "wrong/unknown alias" case.
|
||||
logger.error("Unknown inbound mailbox", { mailbox: mailbox.value });
|
||||
return { ok: false, reason: "mailbox_unknown" };
|
||||
}
|
||||
|
||||
const feed = await repo.load(feedId);
|
||||
if (!feed) {
|
||||
logger.error("Feed not found", { feedId: feedId.value });
|
||||
// The index resolved but the feed is gone — a dangling index (should be
|
||||
// near-impossible now the index is dropped on feed deletion).
|
||||
logger.error("Feed not found", {
|
||||
mailbox: mailbox.value,
|
||||
feedId: feedId.value,
|
||||
});
|
||||
return { ok: false, reason: "feed_not_found" };
|
||||
}
|
||||
if (feed.isExpired()) {
|
||||
|
||||
@@ -21,6 +21,9 @@ export async function fetchFeedData(
|
||||
title: `Newsletter Feed ${feedId.value}`,
|
||||
description: "Converted email newsletter",
|
||||
language: "en",
|
||||
// Read-model fallback only: the RSS/Atom/JSON path never builds the inbound
|
||||
// address, so an empty mailbox is inert here (the real one lives on config).
|
||||
mailbox_id: "",
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createMockEnv } from "../test/setup";
|
||||
import { createFeedRecord, editFeed } from "./feed-service";
|
||||
import {
|
||||
createFeedRecord,
|
||||
editFeed,
|
||||
deleteFeedRecord,
|
||||
deleteFeedFastDetailed,
|
||||
} from "./feed-service";
|
||||
import { getCounters } from "./stats";
|
||||
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||
import { FeedId } from "../domain/value-objects/feed-id";
|
||||
import { MailboxId } from "../domain/value-objects/mailbox-id";
|
||||
import type { Env } from "../types";
|
||||
|
||||
const mkEnv = (overrides: Partial<Env> = {}) =>
|
||||
@@ -54,6 +61,40 @@ describe("createFeedRecord — TTL policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleting a feed drops its inbound mailbox index", () => {
|
||||
it("deleteFeedRecord removes the inbound index so the address stops resolving", async () => {
|
||||
const env = mkEnv();
|
||||
const { feedId, mailboxId } = await createFeedRecord(env, { ...baseInput });
|
||||
const repo = FeedRepository.from(env);
|
||||
|
||||
// Sanity: the address resolves to the feed before deletion.
|
||||
expect(
|
||||
(await repo.resolveInbound(MailboxId.unchecked(mailboxId)))?.value,
|
||||
).toBe(feedId);
|
||||
|
||||
await deleteFeedRecord(env, FeedId.unchecked(feedId), () => {});
|
||||
|
||||
expect(
|
||||
await repo.resolveInbound(MailboxId.unchecked(mailboxId)),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("the bulk path (deleteFeedFastDetailed + removeFromListBulk) clears the inbound index", async () => {
|
||||
const env = mkEnv();
|
||||
const { feedId, mailboxId } = await createFeedRecord(env, { ...baseInput });
|
||||
const repo = FeedRepository.from(env);
|
||||
|
||||
// The bulk admin path drops config/metadata, then removes from the list —
|
||||
// the latter is what clears the inbound index (symmetric with save()).
|
||||
await deleteFeedFastDetailed(env.EMAIL_STORAGE, FeedId.unchecked(feedId));
|
||||
await repo.removeFromListBulk([feedId]);
|
||||
|
||||
expect(
|
||||
await repo.resolveInbound(MailboxId.unchecked(mailboxId)),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editFeed — TTL policy", () => {
|
||||
it("recomputes expiry from the server override on edit", async () => {
|
||||
const env = mkEnv({ FEED_TTL_HOURS: "1" });
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FeedRepository } from "../infrastructure/feed-repository";
|
||||
import { toConfigDTO } from "../infrastructure/feed-mapper";
|
||||
import { BackgroundScheduler } from "../infrastructure/worker";
|
||||
import { FeedId } from "../domain/value-objects/feed-id";
|
||||
import { MailboxId } from "../domain/value-objects/mailbox-id";
|
||||
import { Lifetime } from "../domain/value-objects/lifetime";
|
||||
import {
|
||||
Feed,
|
||||
@@ -33,24 +34,32 @@ function resolveLifetime(env: Env, requestedHours?: number): Lifetime {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a feed: write its config + empty metadata, register it in the global
|
||||
* list, and bump the `feeds_created` counter. Returns the new feed id + config.
|
||||
* Create a feed: mint an opaque `FeedId` (the read id) and a friendly `MailboxId`
|
||||
* (the inbound address), write its config + empty metadata, register it in the
|
||||
* global list + inbound index, and bump the `feeds_created` counter. Returns the
|
||||
* new feed id, its mailbox, and config.
|
||||
*/
|
||||
export async function createFeedRecord(
|
||||
env: Env,
|
||||
input: CreateFeedInput,
|
||||
): Promise<{ feedId: string; config: FeedConfig }> {
|
||||
): Promise<{ feedId: string; mailboxId: string; config: FeedConfig }> {
|
||||
const repo = FeedRepository.from(env);
|
||||
const feed = Feed.create(FeedId.generate(), input, {
|
||||
mailboxId: MailboxId.generate(),
|
||||
lifetime: resolveLifetime(env, input.lifetimeHours),
|
||||
});
|
||||
|
||||
// save() also writes the inbound:<mailbox> → feedId index.
|
||||
await repo.save(feed);
|
||||
|
||||
// FeedCreated → bumps the feeds_created counter (no background work to schedule).
|
||||
await dispatchFeedEvents(feed, env, () => {});
|
||||
|
||||
return { feedId: feed.id.value, config: toConfigDTO(feed.state()) };
|
||||
return {
|
||||
feedId: feed.id.value,
|
||||
mailboxId: feed.mailboxId.value,
|
||||
config: toConfigDTO(feed.state()),
|
||||
};
|
||||
}
|
||||
|
||||
export type UpdateFeedResult =
|
||||
@@ -118,8 +127,10 @@ type DeleteFeedFastResult = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a feed's config + metadata keys, reporting per-key outcomes. The
|
||||
* larger email/attachment cleanup is handled separately via purgeFeedKeysStep.
|
||||
* Delete a feed's config + metadata keys, reporting per-key outcomes. The larger
|
||||
* email/attachment cleanup is handled separately via purgeFeedKeysStep, and the
|
||||
* inbound `inbound:<mailbox>` index is dropped by `removeFromList(Bulk)` (which
|
||||
* every caller invokes next) — symmetric with `save()` writing it.
|
||||
*/
|
||||
export async function deleteFeedFastDetailed(
|
||||
emailStorage: KVNamespace,
|
||||
@@ -166,6 +177,7 @@ export async function deleteFeedRecord(
|
||||
const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId);
|
||||
|
||||
await deleteFeedFastDetailed(emailStorage, feedId);
|
||||
// removeFromList also drops the feed's inbound mailbox index.
|
||||
const removed = await repo.removeFromList(feedId);
|
||||
if (removed) {
|
||||
await bumpCounters(emailStorage, { feeds_deleted: 1 });
|
||||
|
||||
@@ -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("river.castle.42@example.com")?.value,
|
||||
).toBe("river.castle.42");
|
||||
});
|
||||
|
||||
it("is case-insensitive for the local part", () => {
|
||||
expect(
|
||||
EmailParser.extractFeedId("River.Castle.42@example.com")?.value,
|
||||
).toBe("River.Castle.42");
|
||||
});
|
||||
|
||||
it("returns null for an address with no feed ID format", () => {
|
||||
expect(EmailParser.extractFeedId("user@example.com")).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("river.castle.4@example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the numeric suffix has more than two digits", () => {
|
||||
expect(
|
||||
EmailParser.extractFeedId("river.castle.123@example.com"),
|
||||
).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("river.castle.42@example.com")?.value).toBe(
|
||||
"river.castle.42",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the original casing of the local part", () => {
|
||||
expect(FeedId.parse("River.Castle.42@example.com")?.value).toBe(
|
||||
"River.Castle.42",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed feed ids", () => {
|
||||
expect(FeedId.parse("user@example.com")).toBeNull();
|
||||
expect(FeedId.parse("notanemail")).toBeNull();
|
||||
expect(FeedId.parse("river.castle.4@example.com")).toBeNull();
|
||||
expect(FeedId.parse("river.castle.123@example.com")).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("river.castle.42@example.com")?.value).toBe(
|
||||
"river.castle.42",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the original casing of the local part", () => {
|
||||
expect(MailboxId.parse("River.Castle.42@example.com")?.value).toBe(
|
||||
"River.Castle.42",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed mailbox ids", () => {
|
||||
expect(MailboxId.parse("user@example.com")).toBeNull();
|
||||
expect(MailboxId.parse("notanemail")).toBeNull();
|
||||
expect(MailboxId.parse("river.castle.4@example.com")).toBeNull();
|
||||
expect(MailboxId.parse("river.castle.123@example.com")).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("river.castle.42@news.app");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import worker from "./index";
|
||||
import { createMockEnv } from "./test/setup";
|
||||
import { createFeedRecord } from "./application/feed-service";
|
||||
import { FeedRepository } from "./infrastructure/feed-repository";
|
||||
import { FeedId } from "./domain/value-objects/feed-id";
|
||||
import { MailboxId } from "./domain/value-objects/mailbox-id";
|
||||
import type { Env } from "./types";
|
||||
|
||||
const env = createMockEnv();
|
||||
|
||||
const noopCtx = {
|
||||
waitUntil: () => {},
|
||||
passThroughOnException: () => {},
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
function req(path: string, init: RequestInit = {}): Request {
|
||||
return new Request(`https://test.getmynews.app${path}`, init);
|
||||
}
|
||||
@@ -55,6 +64,39 @@ describe("CORS middleware", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("scheduled (cron) TTL cleanup", () => {
|
||||
it("drops the inbound mailbox index when an expired feed is purged", async () => {
|
||||
const cronEnv = createMockEnv() as unknown as Env;
|
||||
const { feedId, mailboxId } = await createFeedRecord(cronEnv, {
|
||||
title: "Expiring",
|
||||
language: "en",
|
||||
allowedSenders: [],
|
||||
blockedSenders: [],
|
||||
});
|
||||
const repo = FeedRepository.from(cronEnv);
|
||||
|
||||
// The address resolves to the feed before the cron runs.
|
||||
expect(
|
||||
(await repo.resolveInbound(MailboxId.unchecked(mailboxId)))?.value,
|
||||
).toBe(feedId);
|
||||
|
||||
// Backdate the feed so the cron treats it as expired.
|
||||
const list = (await cronEnv.EMAIL_STORAGE.get("feeds:list", "json")) as {
|
||||
feeds: Array<{ id: string; expires_at?: number; mailbox_id?: string }>;
|
||||
};
|
||||
list.feeds[0].expires_at = Date.now() - 1000;
|
||||
await cronEnv.EMAIL_STORAGE.put("feeds:list", JSON.stringify(list));
|
||||
|
||||
await worker.scheduled({} as ScheduledEvent, cronEnv, noopCtx);
|
||||
|
||||
// The feed is gone AND its inbound address no longer resolves.
|
||||
expect(await repo.getConfig(FeedId.unchecked(feedId))).toBeNull();
|
||||
expect(
|
||||
await repo.resolveInbound(MailboxId.unchecked(mailboxId)),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /robots.txt", () => {
|
||||
it("returns 200 and disallows the private feed/entry paths", async () => {
|
||||
const res = await worker.fetch(req("/robots.txt"), env as unknown as Env);
|
||||
|
||||
@@ -228,6 +228,7 @@ export default {
|
||||
);
|
||||
}
|
||||
if (expiredIds.length > 0) {
|
||||
// removeFromListBulk also drops each feed's inbound mailbox index.
|
||||
await repo.removeFromListBulk(expiredIds);
|
||||
await bumpCounters(env.EMAIL_STORAGE, {
|
||||
feeds_deleted: expiredIds.length,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -168,6 +168,27 @@ describe("Admin Routes", () => {
|
||||
expect(feedConfig).toBeTruthy();
|
||||
expect((feedConfig as any).title).toBe("Test Feed");
|
||||
expect((feedConfig as any).description).toBe("Test Description");
|
||||
|
||||
// Two-id model: the feed id is an opaque read id; the inbound address is
|
||||
// a separate noun.noun.NN mailbox, mapped via the inbound: index.
|
||||
const mailboxId = (feedConfig as any).mailbox_id as string;
|
||||
expect(mailboxId).toMatch(/^[a-z]+\.[a-z]+\.\d{2}$/);
|
||||
expect(feedId).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
||||
expect(feedId).not.toBe(mailboxId);
|
||||
expect((feedList?.feeds[0] as any).mailbox_id).toBe(mailboxId);
|
||||
expect(
|
||||
await mockEnv.EMAIL_STORAGE.get(`inbound:${mailboxId}`, "text"),
|
||||
).toBe(feedId);
|
||||
|
||||
// The dashboard shows the inbound address and the opaque feed URL,
|
||||
// distinctly — and never exposes the address as a readable feed URL.
|
||||
const dash = await request("/admin", {
|
||||
headers: { Cookie: authCookie },
|
||||
});
|
||||
const html = await dash.text();
|
||||
expect(html).toContain(`${mailboxId}@test.getmynews.app`);
|
||||
expect(html).toContain(`/rss/${feedId}`);
|
||||
expect(html).not.toContain(`/rss/${mailboxId}`);
|
||||
});
|
||||
|
||||
it("should reject feed creation with missing title", async () => {
|
||||
@@ -732,6 +753,15 @@ describe("Admin Routes", () => {
|
||||
it("lists attachments with download links on the email detail page", async () => {
|
||||
const authCookie = await loginAndGetCookie();
|
||||
const feedId = "detail-feed";
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
`feed:${feedId}:config`,
|
||||
JSON.stringify({
|
||||
title: "Detail Feed",
|
||||
mailbox_id: "detail.feed.10",
|
||||
language: "en",
|
||||
created_at: 1,
|
||||
}),
|
||||
);
|
||||
const emailKey = `feed:${feedId}:1`;
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
emailKey,
|
||||
@@ -769,6 +799,15 @@ describe("Admin Routes", () => {
|
||||
it("renders inline cid images in place and hides them from the attachments list", async () => {
|
||||
const authCookie = await loginAndGetCookie();
|
||||
const feedId = "detail-feed";
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
`feed:${feedId}:config`,
|
||||
JSON.stringify({
|
||||
title: "Detail Feed",
|
||||
mailbox_id: "detail.feed.10",
|
||||
language: "en",
|
||||
created_at: 1,
|
||||
}),
|
||||
);
|
||||
const emailKey = `feed:${feedId}:3`;
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
emailKey,
|
||||
@@ -814,6 +853,15 @@ describe("Admin Routes", () => {
|
||||
it("does not render an attachments section when the email has none", async () => {
|
||||
const authCookie = await loginAndGetCookie();
|
||||
const feedId = "detail-feed";
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
`feed:${feedId}:config`,
|
||||
JSON.stringify({
|
||||
title: "Detail Feed",
|
||||
mailbox_id: "detail.feed.10",
|
||||
language: "en",
|
||||
created_at: 1,
|
||||
}),
|
||||
);
|
||||
const emailKey = `feed:${feedId}:2`;
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
emailKey,
|
||||
|
||||
@@ -666,7 +666,10 @@ app.get("/", async (c) => {
|
||||
</thead>
|
||||
<tbody id="feed-table-body">
|
||||
{feedsWithConfig.map((feed) => {
|
||||
const emailAddress = feedEmailAddress(feed.id, env);
|
||||
const emailAddress = feedEmailAddress(
|
||||
feed.mailbox_id,
|
||||
env,
|
||||
);
|
||||
const rssUrl = feedRssUrl(feed.id, env);
|
||||
const atomUrl = feedAtomUrl(feed.id, env);
|
||||
const titleDisplay = clampText(feed.title, 160);
|
||||
@@ -823,7 +826,7 @@ app.get("/", async (c) => {
|
||||
|
||||
<ul class="feed-list">
|
||||
{feedsWithConfig.map((feed) => {
|
||||
const emailAddress = feedEmailAddress(feed.id, env);
|
||||
const emailAddress = feedEmailAddress(feed.mailbox_id, env);
|
||||
const rssUrl = feedRssUrl(feed.id, env);
|
||||
const atomUrl = feedAtomUrl(feed.id, env);
|
||||
const titleDisplay = clampText(feed.title, 140);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ function toFeed(
|
||||
updatedAt: config.updated_at,
|
||||
expiresAt: config.expires_at,
|
||||
emailCount,
|
||||
emailAddress: feedEmailAddress(id, env),
|
||||
emailAddress: feedEmailAddress(config.mailbox_id, env),
|
||||
rssUrl: feedRssUrl(id, env),
|
||||
atomUrl: feedAtomUrl(id, env),
|
||||
};
|
||||
@@ -117,7 +117,7 @@ apiApp.openapi(
|
||||
title: f.title,
|
||||
description: f.description,
|
||||
expiresAt: f.expires_at,
|
||||
emailAddress: feedEmailAddress(f.id, env),
|
||||
emailAddress: feedEmailAddress(f.mailbox_id, env),
|
||||
rssUrl: feedRssUrl(f.id, env),
|
||||
atomUrl: feedAtomUrl(f.id, env),
|
||||
})),
|
||||
|
||||
@@ -14,7 +14,9 @@ export const FeedIdParam = z.object({
|
||||
.min(1)
|
||||
.openapi({
|
||||
param: { name: "feedId", in: "path" },
|
||||
example: "happy-otter-1234",
|
||||
description:
|
||||
"The feed's opaque id (the read id in /rss/:feedId), not the inbound address.",
|
||||
example: "kZ8xQ2pLm4nR7vT1wB9yJc",
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import worker from "../index";
|
||||
import { server, createMockEnv, MockR2 } from "../test/setup";
|
||||
import { server, createMockEnv, MockR2, seedInboundIndex } from "../test/setup";
|
||||
import type { Env } from "../types";
|
||||
import type { ForwardEmailPayload } from "../infrastructure/forwardemail";
|
||||
|
||||
@@ -64,6 +64,7 @@ describe("POST /api/inbound — IP middleware", () => {
|
||||
`feed:${VALID_FEED_ID}:config`,
|
||||
JSON.stringify({ allowed_senders: [] }),
|
||||
);
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
});
|
||||
|
||||
it("returns 401 when IP is not in the ForwardEmail allowlist", async () => {
|
||||
@@ -99,9 +100,10 @@ describe("POST /api/inbound — IP middleware", () => {
|
||||
describe("POST /api/inbound — handler logic", () => {
|
||||
let env: Env;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
stubForwardEmailIps();
|
||||
env = createMockEnv() as unknown as Env;
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
});
|
||||
|
||||
it("returns 500 on malformed JSON body", async () => {
|
||||
@@ -232,9 +234,10 @@ describe("POST /api/inbound — handler logic", () => {
|
||||
describe("POST /api/inbound — attachment upload", () => {
|
||||
let env: Env;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
stubForwardEmailIps();
|
||||
env = createMockEnv({ withR2: true }) as unknown as Env;
|
||||
await seedInboundIndex(env, VALID_FEED_ID);
|
||||
});
|
||||
|
||||
it("uploads attachments to R2 and records ids in metadata", async () => {
|
||||
|
||||
@@ -54,6 +54,60 @@ describe("RSS Feed Route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("read/write id decoupling", () => {
|
||||
const OPAQUE_ID = "kZ8xQ2pLm4nR7vT1wB9yJc";
|
||||
const MAILBOX = "river.castle.42";
|
||||
const RECEIVED_AT = 1700000002000;
|
||||
|
||||
beforeEach(async () => {
|
||||
const emailKey = `feed:${OPAQUE_ID}:${RECEIVED_AT}`;
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
emailKey,
|
||||
JSON.stringify({
|
||||
subject: "Private",
|
||||
from: "Sender <sender@example.com>",
|
||||
content: "<p>secret body</p>",
|
||||
receivedAt: RECEIVED_AT,
|
||||
headers: {},
|
||||
}),
|
||||
);
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
`feed:${OPAQUE_ID}:metadata`,
|
||||
JSON.stringify({
|
||||
emails: [
|
||||
{ key: emailKey, subject: "Private", receivedAt: RECEIVED_AT },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
`feed:${OPAQUE_ID}:config`,
|
||||
JSON.stringify({
|
||||
title: "Decoupled Feed",
|
||||
language: "en",
|
||||
mailbox_id: MAILBOX,
|
||||
created_at: 1700000000000,
|
||||
}),
|
||||
);
|
||||
// The inbound index points the address at the feed (reception only).
|
||||
await mockEnv.EMAIL_STORAGE.put(`inbound:${MAILBOX}`, OPAQUE_ID);
|
||||
});
|
||||
|
||||
it("serves the feed by its opaque read id", async () => {
|
||||
const res = await testApp.request(`/${OPAQUE_ID}`, {}, mockEnv);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 404 when read by the inbound mailbox (no coupling)", async () => {
|
||||
const res = await testApp.request(`/${MAILBOX}`, {}, mockEnv);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("never leaks the inbound mailbox in the feed body", async () => {
|
||||
const res = await testApp.request(`/${OPAQUE_ID}`, {}, mockEnv);
|
||||
expect(await res.text()).not.toContain(MAILBOX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditional GET (ETag + Last-Modified)", () => {
|
||||
const FEED_ID = "test-feed-rss-cget";
|
||||
const EMAIL_RECEIVED_AT = 1700000001000;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeAll, afterAll, afterEach } from "vitest";
|
||||
import { setupServer } from "msw/node";
|
||||
import { feedKeys } from "../domain/feed-keys";
|
||||
|
||||
// Minimal Node.js built-ins used only in this test setup file.
|
||||
// Declared locally to avoid pulling in the full @types/node package,
|
||||
@@ -263,3 +264,16 @@ export const createMockEnv = (options: { withR2?: boolean } = {}) => ({
|
||||
? { ATTACHMENT_BUCKET: new MockR2() as unknown as R2Bucket }
|
||||
: {}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Seed the `inbound:<mailbox> → <feedId>` index that email reception resolves
|
||||
* through. Defaults the feed id to the mailbox (the common unit-test shape where
|
||||
* a feed is keyed by the same string as its inbound address).
|
||||
*/
|
||||
export async function seedInboundIndex(
|
||||
env: { EMAIL_STORAGE: { put: (k: string, v: string) => Promise<unknown> } },
|
||||
mailboxId: string,
|
||||
feedId: string = mailboxId,
|
||||
): Promise<void> {
|
||||
await env.EMAIL_STORAGE.put(feedKeys.inbound(mailboxId), feedId);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ export interface EmailData {
|
||||
export interface FeedConfig {
|
||||
title: string;
|
||||
description?: string;
|
||||
// Inbound mailbox local part (noun.noun.NN): the feed's email address is
|
||||
// `mailbox_id@domain`. Decoupled from the feed's id (the opaque read id).
|
||||
mailbox_id: string;
|
||||
allowed_senders?: string[];
|
||||
blocked_senders?: string[];
|
||||
language: string;
|
||||
@@ -82,6 +85,7 @@ export interface FeedListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
mailbox_id: string; // Cached inbound address local part (admin/API display)
|
||||
expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user