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]>
300 lines
8.4 KiB
TypeScript
300 lines
8.4 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import "../test/setup";
|
|
import { createMockEnv, seedInboundIndex } from "../test/setup";
|
|
import { handleCloudflareEmail } from "./cloudflare-email";
|
|
import { getCounters } from "../application/stats";
|
|
|
|
const VALID_FEED_ID = "apple.mountain.42";
|
|
const DOMAIN = "test.getmynews.app";
|
|
|
|
const RAW_EMAIL = [
|
|
"From: Sender Name <[email protected]>",
|
|
`To: ${VALID_FEED_ID}@${DOMAIN}`,
|
|
"Subject: Hello World",
|
|
"Date: Thu, 01 Jan 2026 12:00:00 +0000",
|
|
"MIME-Version: 1.0",
|
|
"Content-Type: text/plain; charset=utf-8",
|
|
"",
|
|
"This is the email body.",
|
|
].join("\r\n");
|
|
|
|
function makeMessage(
|
|
overrides: Partial<{
|
|
from: string;
|
|
to: string;
|
|
rawText: string;
|
|
forward: (rcptTo: string, headers?: Headers) => Promise<void>;
|
|
}> = {},
|
|
): ForwardableEmailMessage {
|
|
const rawText = overrides.rawText ?? RAW_EMAIL;
|
|
const encoder = new TextEncoder();
|
|
const bytes = encoder.encode(rawText);
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(bytes);
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
return {
|
|
from: overrides.from ?? "[email protected]",
|
|
to: overrides.to ?? `${VALID_FEED_ID}@${DOMAIN}`,
|
|
headers: new Headers(),
|
|
raw: stream,
|
|
rawSize: bytes.length,
|
|
forward: overrides.forward ?? (async () => {}),
|
|
reply: async () => {},
|
|
setReject: () => {},
|
|
} as unknown as ForwardableEmailMessage;
|
|
}
|
|
|
|
/** Records every message.forward() call so tests can assert on routing. */
|
|
function spyForward() {
|
|
const calls: string[] = [];
|
|
const forward = async (rcptTo: string) => {
|
|
calls.push(rcptTo);
|
|
};
|
|
return { calls, forward };
|
|
}
|
|
|
|
const FALLBACK = "[email protected]";
|
|
|
|
describe("handleCloudflareEmail", () => {
|
|
let env: ReturnType<typeof createMockEnv>;
|
|
|
|
beforeEach(async () => {
|
|
env = createMockEnv();
|
|
await seedInboundIndex(env, VALID_FEED_ID);
|
|
});
|
|
|
|
it("stores email in KV when feed exists", async () => {
|
|
await env.EMAIL_STORAGE.put(
|
|
`feed:${VALID_FEED_ID}:config`,
|
|
JSON.stringify({}),
|
|
);
|
|
|
|
await handleCloudflareEmail(
|
|
makeMessage(),
|
|
env as any,
|
|
{ waitUntil: () => {} } as any,
|
|
);
|
|
|
|
const metadata = await env.EMAIL_STORAGE.get(
|
|
`feed:${VALID_FEED_ID}:metadata`,
|
|
"json",
|
|
);
|
|
expect(metadata.emails).toHaveLength(1);
|
|
expect(metadata.emails[0].subject).toBe("Hello World");
|
|
});
|
|
|
|
it("does not throw when feed does not exist", async () => {
|
|
await expect(
|
|
handleCloudflareEmail(
|
|
makeMessage(),
|
|
env as any,
|
|
{ waitUntil: () => {} } as any,
|
|
),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("does not throw when email is malformed", async () => {
|
|
const msg = makeMessage({ rawText: "not a valid email" });
|
|
await expect(
|
|
handleCloudflareEmail(msg, env as any, { waitUntil: () => {} } as any),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("uses sender from message.from for allowlist check", async () => {
|
|
await env.EMAIL_STORAGE.put(
|
|
`feed:${VALID_FEED_ID}:config`,
|
|
JSON.stringify({ allowed_senders: ["[email protected]"] }),
|
|
);
|
|
|
|
await handleCloudflareEmail(
|
|
makeMessage(),
|
|
env as any,
|
|
{ waitUntil: () => {} } as any,
|
|
);
|
|
|
|
const metadata = await env.EMAIL_STORAGE.get(
|
|
`feed:${VALID_FEED_ID}:metadata`,
|
|
"json",
|
|
);
|
|
expect(metadata.emails).toHaveLength(1);
|
|
});
|
|
|
|
it("rejects email when sender is not in allowlist (stored nothing)", async () => {
|
|
await env.EMAIL_STORAGE.put(
|
|
`feed:${VALID_FEED_ID}:config`,
|
|
JSON.stringify({ allowed_senders: ["[email protected]"] }),
|
|
);
|
|
|
|
await handleCloudflareEmail(
|
|
makeMessage(),
|
|
env as any,
|
|
{ waitUntil: () => {} } as any,
|
|
);
|
|
|
|
const metadata = await env.EMAIL_STORAGE.get(
|
|
`feed:${VALID_FEED_ID}:metadata`,
|
|
"json",
|
|
);
|
|
expect(metadata).toBeNull();
|
|
});
|
|
|
|
describe("FALLBACK_FORWARD_ADDRESS catch-all fallback", () => {
|
|
it("forwards to the fallback when the feed does not exist", async () => {
|
|
const { calls, forward } = spyForward();
|
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
|
|
|
await handleCloudflareEmail(
|
|
makeMessage({ forward }),
|
|
env as any,
|
|
{ waitUntil: () => {} } as any,
|
|
);
|
|
|
|
expect(calls).toEqual([FALLBACK]);
|
|
});
|
|
|
|
it("forwards to the fallback when the address is not a feed", async () => {
|
|
const { calls, forward } = spyForward();
|
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
|
|
|
await handleCloudflareEmail(
|
|
makeMessage({ to: `not-a-feed@${DOMAIN}`, forward }),
|
|
env as any,
|
|
{ waitUntil: () => {} } as any,
|
|
);
|
|
|
|
expect(calls).toEqual([FALLBACK]);
|
|
});
|
|
|
|
it("does NOT forward an expired feed's mail (no newsletter leak)", async () => {
|
|
const { calls, forward } = spyForward();
|
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
|
await env.EMAIL_STORAGE.put(
|
|
`feed:${VALID_FEED_ID}:config`,
|
|
JSON.stringify({ expires_at: Date.now() - 1000 }),
|
|
);
|
|
|
|
await handleCloudflareEmail(
|
|
makeMessage({ forward }),
|
|
env as any,
|
|
{ waitUntil: () => {} } as any,
|
|
);
|
|
|
|
expect(calls).toEqual([]);
|
|
});
|
|
|
|
it("does NOT forward when the sender is blocked", async () => {
|
|
const { calls, forward } = spyForward();
|
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
|
await env.EMAIL_STORAGE.put(
|
|
`feed:${VALID_FEED_ID}:config`,
|
|
JSON.stringify({ allowed_senders: ["[email protected]"] }),
|
|
);
|
|
|
|
await handleCloudflareEmail(
|
|
makeMessage({ forward }),
|
|
env as any,
|
|
{ waitUntil: () => {} } as any,
|
|
);
|
|
|
|
expect(calls).toEqual([]);
|
|
});
|
|
|
|
it("does NOT forward when the email was ingested", async () => {
|
|
const { calls, forward } = spyForward();
|
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
|
await env.EMAIL_STORAGE.put(
|
|
`feed:${VALID_FEED_ID}:config`,
|
|
JSON.stringify({}),
|
|
);
|
|
|
|
await handleCloudflareEmail(
|
|
makeMessage({ forward }),
|
|
env as any,
|
|
{ waitUntil: () => {} } as any,
|
|
);
|
|
|
|
expect(calls).toEqual([]);
|
|
});
|
|
|
|
it("does NOT forward when the env var is unset (current drop behavior)", async () => {
|
|
const { calls, forward } = spyForward();
|
|
// env.FALLBACK_FORWARD_ADDRESS intentionally left unset.
|
|
|
|
await handleCloudflareEmail(
|
|
makeMessage({ forward }),
|
|
env as any,
|
|
{ waitUntil: () => {} } as any,
|
|
);
|
|
|
|
expect(calls).toEqual([]);
|
|
});
|
|
|
|
it("does not throw when the fallback forward fails (unverified address)", async () => {
|
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
|
const forward = async () => {
|
|
throw new Error("destination address not verified");
|
|
};
|
|
|
|
await expect(
|
|
handleCloudflareEmail(
|
|
makeMessage({ forward }),
|
|
env as any,
|
|
{ waitUntil: () => {} } as any,
|
|
),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("increments the emails_forwarded counter on a successful forward", async () => {
|
|
const { forward } = spyForward();
|
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
|
|
|
await handleCloudflareEmail(
|
|
makeMessage({ forward }),
|
|
env as any,
|
|
{ waitUntil: () => {} } as any,
|
|
);
|
|
|
|
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
|
expect(counters.emails_forwarded).toBe(1);
|
|
});
|
|
|
|
it("does not increment emails_forwarded when the forward fails", async () => {
|
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
|
const forward = async () => {
|
|
throw new Error("destination address not verified");
|
|
};
|
|
|
|
await handleCloudflareEmail(
|
|
makeMessage({ forward }),
|
|
env as any,
|
|
{ waitUntil: () => {} } as any,
|
|
);
|
|
|
|
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
|
expect(counters.emails_forwarded).toBe(0);
|
|
});
|
|
|
|
it("does not increment emails_forwarded for dropped reasons", async () => {
|
|
const { forward } = spyForward();
|
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
|
await env.EMAIL_STORAGE.put(
|
|
`feed:${VALID_FEED_ID}:config`,
|
|
JSON.stringify({ expires_at: Date.now() - 1000 }),
|
|
);
|
|
|
|
await handleCloudflareEmail(
|
|
makeMessage({ forward }),
|
|
env as any,
|
|
{ waitUntil: () => {} } as any,
|
|
);
|
|
|
|
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
|
expect(counters.emails_forwarded).toBe(0);
|
|
});
|
|
});
|
|
});
|