mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = "fallback@personal.example";
|
||||
|
||||
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: ["other@example.com"] }),
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import PostalMime from "postal-mime";
|
||||
import { Env } from "../types";
|
||||
import { processEmail, RawAttachment } from "../application/email-processor";
|
||||
import {
|
||||
processEmail,
|
||||
RawAttachment,
|
||||
IngestRejectionReason,
|
||||
} from "../application/email-processor";
|
||||
import { normalizeCid } from "../infrastructure/html-processor";
|
||||
import { logger } from "./logger";
|
||||
|
||||
@@ -51,8 +55,36 @@ export async function handleCloudflareEmail(
|
||||
to: message.to,
|
||||
reason: result.reason,
|
||||
});
|
||||
await maybeForwardFallback(message, env, result.reason);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing Cloudflare email:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reasons safe to forward to the catch-all fallback: the mail was never a feed's
|
||||
// (wrong address shape, or no such feed). Expired feeds and blocked senders are
|
||||
// dropped so a real newsletter never leaks into the fallback inbox.
|
||||
const FORWARDABLE_REASONS = new Set<IngestRejectionReason>([
|
||||
"invalid_address",
|
||||
"feed_not_found",
|
||||
]);
|
||||
|
||||
async function maybeForwardFallback(
|
||||
message: ForwardableEmailMessage,
|
||||
env: Env,
|
||||
reason: IngestRejectionReason,
|
||||
): Promise<void> {
|
||||
const fallback = env.FALLBACK_FORWARD_ADDRESS;
|
||||
if (!fallback || !FORWARDABLE_REASONS.has(reason)) return;
|
||||
|
||||
try {
|
||||
await message.forward(fallback);
|
||||
} catch (error) {
|
||||
logger.warn("Fallback forward failed", {
|
||||
to: message.to,
|
||||
fallback,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +258,7 @@ export const createMockEnv = (options: { withR2?: boolean } = {}) => ({
|
||||
EMAIL_STORAGE: new MockKV(),
|
||||
DOMAIN: "test.getmynews.app",
|
||||
ADMIN_PASSWORD: "test-password",
|
||||
FALLBACK_FORWARD_ADDRESS: undefined as string | undefined,
|
||||
...(options.withR2
|
||||
? { ATTACHMENT_BUCKET: new MockR2() as unknown as R2Bucket }
|
||||
: {}),
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface Env {
|
||||
PROXY_TRUSTED_IPS?: string;
|
||||
PROXY_AUTH_SECRET?: string;
|
||||
FEED_TTL_HOURS?: string;
|
||||
// Optional catch-all fallback: non-feed inbound mail is forwarded here instead
|
||||
// of being dropped. Must be a *verified* Cloudflare Email Routing destination.
|
||||
FALLBACK_FORWARD_ADDRESS?: string;
|
||||
}
|
||||
|
||||
// Stored attachment metadata (bytes live in R2, keyed by id)
|
||||
|
||||
Reference in New Issue
Block a user