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:
Julien Herr
2026-05-24 17:14:04 +02:00
parent 6cb036fe2c
commit 2c450817df
7 changed files with 192 additions and 4 deletions
+23
View File
@@ -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. - Keep `compatibility_date` fresh when doing runtime upgrades.
- `ADMIN_PASSWORD` is a Cloudflare Worker secret, not a plain env var in config. - `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 ### 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. 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.
+1 -1
View File
@@ -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)_ - [ ] `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 ### Auth & privacy
+125 -2
View File
@@ -18,7 +18,12 @@ const RAW_EMAIL = [
].join("\r\n"); ].join("\r\n");
function makeMessage( 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 { ): ForwardableEmailMessage {
const rawText = overrides.rawText ?? RAW_EMAIL; const rawText = overrides.rawText ?? RAW_EMAIL;
const encoder = new TextEncoder(); const encoder = new TextEncoder();
@@ -36,12 +41,23 @@ function makeMessage(
headers: new Headers(), headers: new Headers(),
raw: stream, raw: stream,
rawSize: bytes.length, rawSize: bytes.length,
forward: async () => {}, forward: overrides.forward ?? (async () => {}),
reply: async () => {}, reply: async () => {},
setReject: () => {}, setReject: () => {},
} as unknown as ForwardableEmailMessage; } 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", () => { describe("handleCloudflareEmail", () => {
let env: ReturnType<typeof createMockEnv>; let env: ReturnType<typeof createMockEnv>;
@@ -123,4 +139,111 @@ describe("handleCloudflareEmail", () => {
); );
expect(metadata).toBeNull(); 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();
});
});
}); });
+33 -1
View File
@@ -1,6 +1,10 @@
import PostalMime from "postal-mime"; import PostalMime from "postal-mime";
import { Env } from "../types"; 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 { normalizeCid } from "../infrastructure/html-processor";
import { logger } from "./logger"; import { logger } from "./logger";
@@ -51,8 +55,36 @@ export async function handleCloudflareEmail(
to: message.to, to: message.to,
reason: result.reason, reason: result.reason,
}); });
await maybeForwardFallback(message, env, result.reason);
} }
} catch (error) { } catch (error) {
console.error("Error processing Cloudflare email:", 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),
});
}
}
+1
View File
@@ -258,6 +258,7 @@ export const createMockEnv = (options: { withR2?: boolean } = {}) => ({
EMAIL_STORAGE: new MockKV(), EMAIL_STORAGE: new MockKV(),
DOMAIN: "test.getmynews.app", DOMAIN: "test.getmynews.app",
ADMIN_PASSWORD: "test-password", ADMIN_PASSWORD: "test-password",
FALLBACK_FORWARD_ADDRESS: undefined as string | undefined,
...(options.withR2 ...(options.withR2
? { ATTACHMENT_BUCKET: new MockR2() as unknown as R2Bucket } ? { ATTACHMENT_BUCKET: new MockR2() as unknown as R2Bucket }
: {}), : {}),
+3
View File
@@ -10,6 +10,9 @@ export interface Env {
PROXY_TRUSTED_IPS?: string; PROXY_TRUSTED_IPS?: string;
PROXY_AUTH_SECRET?: string; PROXY_AUTH_SECRET?: string;
FEED_TTL_HOURS?: 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) // Stored attachment metadata (bytes live in R2, keyed by id)
+6
View File
@@ -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. # the admin UI is pre-filled and read-only. Remove to allow per-feed configuration.
# FEED_TTL_HOURS = "24" # 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) # Optional: external proxy auth (Authelia/Authentik)
# Comma-separated IPs of trusted reverse proxies # Comma-separated IPs of trusted reverse proxies
# PROXY_TRUSTED_IPS = "10.0.0.1" # PROXY_TRUSTED_IPS = "10.0.0.1"