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"