From 2c450817df337c223f9c9aaea2b858e993cd1c0e Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sun, 24 May 2026 17:14:04 +0200 Subject: [PATCH] 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 --- INSTALL.md | 23 ++++ TODO.md | 2 +- src/infrastructure/cloudflare-email.test.ts | 127 +++++++++++++++++++- src/infrastructure/cloudflare-email.ts | 34 +++++- src/test/setup.ts | 1 + src/types/index.ts | 3 + wrangler-example.toml | 6 + 7 files changed, 192 insertions(+), 4 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index e6e489f..b95cdc3 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -114,6 +114,29 @@ Scope the token to the relevant **account** and, for custom domains, the relevan - Keep `compatibility_date` fresh when doing runtime upgrades. - `ADMIN_PASSWORD` is a Cloudflare Worker secret, not a plain env var in config. +### Catch-all fallback forwarding + +By default, inbound mail that doesn't match a feed is dropped (logged, then discarded). If you want to point a domain's **catch-all** at this worker without losing your personal mail, set an optional fallback address — non-feed mail is forwarded there instead of dropped: + +```toml +[vars] +FALLBACK_FORWARD_ADDRESS = "you@example.com" +``` + +**Prerequisite:** the address must be a **verified destination** in _Email → Email Routing → Destination addresses_ (Cloudflare won't forward to an unverified address — `message.forward()` fails, and the worker just logs a warning). This only applies to the Cloudflare Email Workers path (Option A). + +What gets forwarded vs dropped: + +| Situation | Action | +| -------------------------------------------------- | --------------------- | +| Address isn't a feed (e.g. `you@`, typo) | forward | +| Well-formed feed address but no such feed | forward | +| Feed exists but is **expired** | drop | +| Feed exists but the sender is **blocked/filtered** | drop | +| Delivered to a live feed | ingested (no forward) | + +Expired feeds and blocked senders are dropped on purpose, so a real newsletter never leaks into your fallback inbox. Leave the variable unset to keep the original drop-and-log behavior. + ### Feed size limit By default the worker keeps emails until the feed's stored data exceeds **512 KB**, then drops the oldest entries (and their KV records) to stay under the limit. This is more robust than a fixed entry count for HTML-heavy newsletters. diff --git a/TODO.md b/TODO.md index 11a0744..734f4ee 100644 --- a/TODO.md +++ b/TODO.md @@ -116,7 +116,7 @@ Verified-missing in our code, deduplicated against the sections above. From a co - [ ] `P3·M` **Calendar (.ics) invite extraction** **[differentiating, novel]** — no email→feed tool does this. Detect `text/calendar` parts, parse the event, and surface it in the entry (summary + an `.ics` enclosure / add-to-calendar link). Useful for event/booking newsletters. — _origin: internal (novel; no external requester)_ -- [ ] `P2·S` **`FALLBACK_FORWARD_ADDRESS` — catch-all fallback forwarding** **[differentiating for self-hosters]** — today `handleCloudflareEmail` silently drops (just `logger.warn`) any address that isn't a feed, so you can't point a domain's _catch-all_ at KTN without swallowing your personal mail. Add an optional `FALLBACK_FORWARD_ADDRESS` env var: after `processEmail`, forward non-feed mail to it based on `result.reason` — **forward** on `invalid_address` (not a `noun.noun.NN` address) and `feed_not_found` (well-formed but no such feed); **drop** on `feed_expired` and `sender_blocked` (don't leak a newsletter to the fallback box); nothing on `ok`. Unset env → current drop+log behavior unchanged. The destination must be a _verified_ Cloudflare Email Routing address or `message.forward()` fails; `await` it in a `try/catch` (`logger.warn` on failure), forward at most once. Touch: `Env` (`src/types/index.ts`), `src/infrastructure/cloudflare-email.ts` (`result.reason` already available), `cloudflare-email.test.ts` (forwarded for `feed_not_found`/`invalid_address` when set; not for `feed_expired`/`sender_blocked`; not when unset), `wrangler-example.toml` (commented `# FALLBACK_FORWARD_ADDRESS` under `[vars]`), `INSTALL.md` ("Catch-all fallback forwarding" section: verified-destination prerequisite + use case). — _origin: internal (juherr — self-host on juherr.dev catch-all); generic "use KTN as my domain's catch-all"_ +- [x] `P2·S` **`FALLBACK_FORWARD_ADDRESS` — catch-all fallback forwarding** **[differentiating for self-hosters]** — today `handleCloudflareEmail` silently drops (just `logger.warn`) any address that isn't a feed, so you can't point a domain's _catch-all_ at KTN without swallowing your personal mail. Add an optional `FALLBACK_FORWARD_ADDRESS` env var: after `processEmail`, forward non-feed mail to it based on `result.reason` — **forward** on `invalid_address` (not a `noun.noun.NN` address) and `feed_not_found` (well-formed but no such feed); **drop** on `feed_expired` and `sender_blocked` (don't leak a newsletter to the fallback box); nothing on `ok`. Unset env → current drop+log behavior unchanged. The destination must be a _verified_ Cloudflare Email Routing address or `message.forward()` fails; `await` it in a `try/catch` (`logger.warn` on failure), forward at most once. Touch: `Env` (`src/types/index.ts`), `src/infrastructure/cloudflare-email.ts` (`result.reason` already available), `cloudflare-email.test.ts` (forwarded for `feed_not_found`/`invalid_address` when set; not for `feed_expired`/`sender_blocked`; not when unset), `wrangler-example.toml` (commented `# FALLBACK_FORWARD_ADDRESS` under `[vars]`), `INSTALL.md` ("Catch-all fallback forwarding" section: verified-destination prerequisite + use case). — _origin: internal (juherr — self-host on juherr.dev catch-all); generic "use KTN as my domain's catch-all"_ ### Auth & privacy diff --git a/src/infrastructure/cloudflare-email.test.ts b/src/infrastructure/cloudflare-email.test.ts index 629f6cf..35b7571 100644 --- a/src/infrastructure/cloudflare-email.test.ts +++ b/src/infrastructure/cloudflare-email.test.ts @@ -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; + }> = {}, ): 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; @@ -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(); + }); + }); }); diff --git a/src/infrastructure/cloudflare-email.ts b/src/infrastructure/cloudflare-email.ts index 6f814a4..57528c0 100644 --- a/src/infrastructure/cloudflare-email.ts +++ b/src/infrastructure/cloudflare-email.ts @@ -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([ + "invalid_address", + "feed_not_found", +]); + +async function maybeForwardFallback( + message: ForwardableEmailMessage, + env: Env, + reason: IngestRejectionReason, +): Promise { + 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), + }); + } +} diff --git a/src/test/setup.ts b/src/test/setup.ts index ee52ccb..2a9a559 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -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 } : {}), diff --git a/src/types/index.ts b/src/types/index.ts index 7fbbc5a..09ae381 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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) diff --git a/wrangler-example.toml b/wrangler-example.toml index e4453d5..8bc26f1 100644 --- a/wrangler-example.toml +++ b/wrangler-example.toml @@ -47,6 +47,12 @@ DOMAIN = "REPLACE_WITH_YOUR_DOMAIN" # Web domain (used for feed URLs and admin U # the admin UI is pre-filled and read-only. Remove to allow per-feed configuration. # FEED_TTL_HOURS = "24" +# Optional: catch-all fallback forwarding. Inbound mail that isn't a feed (bad +# address or unknown feed) is forwarded here instead of dropped — lets you point +# a domain's catch-all at this worker without losing personal mail. The address +# MUST be a *verified* destination in Cloudflare Email Routing or forwarding fails. +# FALLBACK_FORWARD_ADDRESS = "you@example.com" + # Optional: external proxy auth (Authelia/Authentik) # Comma-separated IPs of trusted reverse proxies # PROXY_TRUSTED_IPS = "10.0.0.1"