feat(email): forward non-feed mail to FALLBACK_FORWARD_ADDRESS
Lets you point a domain's catch-all at the worker without losing personal mail: inbound mail that isn't a feed (invalid_address / feed_not_found) is forwarded to an optional verified destination instead of being dropped. Expired feeds and blocked senders are still dropped so newsletters never leak to the fallback inbox. Unset env keeps the original drop-and-log path. Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
6cb036fe2c
commit
2c450817df
7 files changed
+192
-4
No files matched your search
@@ -18,7 +18,12 @@ const RAW_EMAIL = [
|
||||
].join("\r\n");
|
||||
|
||||
function makeMessage(
|
||||
overrides: Partial<{ from: string; to: string; rawText: string }> = {},
|
||||
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();
|
||||
@@ -36,12 +41,23 @@ function makeMessage(
|
||||
headers: new Headers(),
|
||||
raw: stream,
|
||||
rawSize: bytes.length,
|
||||
forward: async () => {},
|
||||
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>;
|
||||
|
||||
@@ -123,4 +139,111 @@ describe("handleCloudflareEmail", () => {
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user