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,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: "[email protected]" }),
|
||||
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: `[email protected]` }),
|
||||
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 () => {
|
||||
|
||||
Reference in new issue
Block a user