feat: add Cloudflare Email Workers support alongside ForwardEmail

Both email providers now work in parallel on the same Worker:
- ForwardEmail: existing POST /api/inbound webhook (unchanged)
- Cloudflare Email Routing: native `email` handler using postal-mime

New files:
- src/lib/email-processor.ts  shared business logic (feed lookup,
  sender allowlist, KV storage) extracted from inbound.ts
- src/lib/cloudflare-email.ts  Cloudflare `email` handler; parses
  raw RFC 2822 email with postal-mime, delegates to processEmail()
- src/lib/email-processor.test.ts  9 unit tests
- src/lib/cloudflare-email.test.ts  5 integration tests

Also fixes pre-existing CORS 204 response: c.text("", 204) →
c.body(null, 204) to match Hono's EmptyStatusCode constraint.

To enable: configure Cloudflare Email Routing with a catch-all rule
`*@domain.com` pointing to this Worker.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
This commit is contained in:
Julien HerrandClaude Sonnet 4.5 committed 2026-05-20 22:54:46 +02:00
1 parent 29446a2aac
commit 093efe7fc9
8 files changed
+477 -167

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach } from "vitest";
import "../test/setup";
import { createMockEnv } from "../test/setup";
import { handleCloudflareEmail } from "./cloudflare-email";
const VALID_FEED_ID = "apple.mountain.42";
const DOMAIN = "test.getmynews.app";
const RAW_EMAIL = [
"From: Sender Name <[email protected]>",
`To: ${VALID_FEED_ID}@${DOMAIN}`,
"Subject: Hello World",
"Date: Thu, 01 Jan 2026 12:00:00 +0000",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"",
"This is the email body.",
].join("\r\n");
function makeMessage(
overrides: Partial<{ from: string; to: string; rawText: string }> = {},
): ForwardableEmailMessage {
const rawText = overrides.rawText ?? RAW_EMAIL;
const encoder = new TextEncoder();
const bytes = encoder.encode(rawText);
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
return {
from: overrides.from ?? "[email protected]",
to: overrides.to ?? `${VALID_FEED_ID}@${DOMAIN}`,
headers: new Headers(),
raw: stream,
rawSize: bytes.length,
forward: async () => {},
reply: async () => {},
setReject: () => {},
} as unknown as ForwardableEmailMessage;
}
describe("handleCloudflareEmail", () => {
let env: ReturnType<typeof createMockEnv>;
beforeEach(() => {
env = createMockEnv();
});
it("stores email in KV when feed exists", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
await handleCloudflareEmail(makeMessage(), env as any, {} as any);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
expect(metadata.emails).toHaveLength(1);
expect(metadata.emails[0].subject).toBe("Hello World");
});
it("does not throw when feed does not exist", async () => {
await expect(
handleCloudflareEmail(makeMessage(), env as any, {} as any),
).resolves.toBeUndefined();
});
it("does not throw when email is malformed", async () => {
const msg = makeMessage({ rawText: "not a valid email" });
await expect(
handleCloudflareEmail(msg, env as any, {} as any),
).resolves.toBeUndefined();
});
it("uses sender from message.from for allowlist check", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: ["[email protected]"] }),
);
await handleCloudflareEmail(makeMessage(), env as any, {} as any);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
expect(metadata.emails).toHaveLength(1);
});
it("rejects email when sender is not in allowlist (stored nothing)", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: ["[email protected]"] }),
);
await handleCloudflareEmail(makeMessage(), env as any, {} as any);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
expect(metadata).toBeNull();
});
});
+39
View File
@@ -0,0 +1,39 @@
import PostalMime from "postal-mime";
import { Env } from "../types";
import { processEmail } from "./email-processor";
export async function handleCloudflareEmail(
message: ForwardableEmailMessage,
env: Env,
ctx: ExecutionContext,
): Promise<void> {
try {
const email = await PostalMime.parse(message.raw);
const fromAddress = email.from?.address ?? message.from;
const from =
email.from?.name && email.from.address
? `${email.from.name} <${email.from.address}>`
: fromAddress;
const headers: Record<string, string> = {};
for (const h of email.headers) {
headers[h.key] = h.value;
}
await processEmail(
{
toAddress: message.to,
from,
senders: [message.from],
subject: email.subject ?? "(no subject)",
content: email.html ?? email.text ?? "",
receivedAt: email.date ? new Date(email.date).getTime() : Date.now(),
headers,
},
env,
);
} catch (error) {
console.error("Error processing Cloudflare email:", error);
}
}
+132
View File
@@ -0,0 +1,132 @@
import { describe, it, expect, beforeEach } from "vitest";
import "../test/setup";
import { createMockEnv } from "../test/setup";
import { processEmail, ProcessEmailInput } from "./email-processor";
const VALID_FEED_ID = "apple.mountain.42";
const VALID_TO = `${VALID_FEED_ID}@test.getmynews.app`;
function makeInput(
overrides: Partial<ProcessEmailInput> = {},
): ProcessEmailInput {
return {
toAddress: VALID_TO,
from: "Sender <[email protected]>",
senders: ["[email protected]"],
subject: "Test Subject",
content: "<p>Hello</p>",
receivedAt: 1700000000000,
...overrides,
};
}
describe("processEmail", () => {
let env: ReturnType<typeof createMockEnv>;
beforeEach(() => {
env = createMockEnv();
});
it("returns 400 when toAddress has no valid feedId", async () => {
const res = await processEmail(
makeInput({ toAddress: "[email protected]" }),
env as any,
);
expect(res.status).toBe(400);
});
it("returns 404 when feed does not exist", async () => {
const res = await processEmail(makeInput(), env as any);
expect(res.status).toBe(404);
});
it("returns 403 when sender is not in allowlist", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: ["[email protected]"] }),
);
const res = await processEmail(
makeInput({ senders: ["[email protected]"] }),
env as any,
);
expect(res.status).toBe(403);
});
it("returns 200 and stores email when sender is allowed by exact match", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: ["[email protected]"] }),
);
const res = await processEmail(makeInput(), env as any);
expect(res.status).toBe(200);
});
it("returns 200 and stores email when sender matches by domain", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: ["example.com"] }),
);
const res = await processEmail(
makeInput({ senders: ["[email protected]"] }),
env as any,
);
expect(res.status).toBe(200);
});
it("returns 200 when no allowlist is set", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: [] }),
);
const res = await processEmail(makeInput(), env as any);
expect(res.status).toBe(200);
});
it("stores email data and updates metadata in KV", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
const input = makeInput({ subject: "My Subject", content: "<b>body</b>" });
await processEmail(input, env as any);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
expect(metadata.emails).toHaveLength(1);
expect(metadata.emails[0].subject).toBe("My Subject");
const emailData = await env.EMAIL_STORAGE.get(
metadata.emails[0].key,
"json",
);
expect(emailData.subject).toBe("My Subject");
expect(emailData.content).toBe("<b>body</b>");
expect(emailData.from).toBe("Sender <[email protected]>");
});
it("prepends to existing metadata", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:metadata`,
JSON.stringify({
emails: [{ key: "old-key", subject: "Old", receivedAt: 1 }],
}),
);
await processEmail(makeInput({ subject: "New" }), env as any);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
expect(metadata.emails).toHaveLength(2);
expect(metadata.emails[0].subject).toBe("New");
expect(metadata.emails[1].subject).toBe("Old");
});
});
+105
View File
@@ -0,0 +1,105 @@
import { EmailParser } from "../utils/email-parser";
import { Env, FeedConfig, FeedMetadata } from "../types";
export interface ProcessEmailInput {
toAddress: string;
from: string;
senders: string[];
subject: string;
content: string;
receivedAt: number;
headers?: Record<string, string>;
}
function normalizeEmail(value: string): string {
return value.trim().toLowerCase();
}
function senderMatchesAllowlist(
sender: string,
allowedSender: string,
): boolean {
const normalizedSender = normalizeEmail(sender);
const normalizedAllowed = normalizeEmail(allowedSender);
if (!normalizedAllowed) return false;
if (normalizedAllowed.includes("@")) {
return normalizedSender === normalizedAllowed;
}
const senderDomain = normalizedSender.split("@")[1] || "";
const normalizedDomain = normalizedAllowed.startsWith("@")
? normalizedAllowed.slice(1)
: normalizedAllowed;
return senderDomain === normalizedDomain;
}
export async function processEmail(
input: ProcessEmailInput,
env: Env,
): Promise<Response> {
const feedId = EmailParser.extractFeedId(input.toAddress);
if (!feedId) {
console.error(`Invalid email address format: ${input.toAddress}`);
return new Response("Invalid email address format", { status: 400 });
}
const feedConfig = (await env.EMAIL_STORAGE.get(
`feed:${feedId}:config`,
"json",
)) as FeedConfig | null;
if (!feedConfig) {
console.error(`Feed with ID ${feedId} does not exist or has been deleted`);
return new Response("Feed does not exist", { status: 404 });
}
const allowedSenders = (feedConfig.allowed_senders || [])
.map(normalizeEmail)
.filter(Boolean);
if (allowedSenders.length > 0) {
const senderAllowed = input.senders.some((sender) =>
allowedSenders.some((allowedSender) =>
senderMatchesAllowlist(sender, allowedSender),
),
);
if (!senderAllowed) {
console.warn(
`Rejected email for feed ${feedId}; sender not in allowlist`,
{
senders: input.senders,
allowedSenders,
},
);
return new Response("Sender not allowed for this feed", { status: 403 });
}
}
const emailData = {
subject: input.subject,
from: input.from,
content: input.content,
receivedAt: input.receivedAt,
headers: input.headers ?? {},
};
const emailKey = `feed:${feedId}:${Date.now()}`;
await env.EMAIL_STORAGE.put(emailKey, JSON.stringify(emailData));
const feedMetadataKey = `feed:${feedId}:metadata`;
const feedMetadata = ((await env.EMAIL_STORAGE.get(
feedMetadataKey,
"json",
)) || {
emails: [],
}) as FeedMetadata;
feedMetadata.emails.unshift({
key: emailKey,
subject: emailData.subject,
receivedAt: emailData.receivedAt,
});
await env.EMAIL_STORAGE.put(feedMetadataKey, JSON.stringify(feedMetadata));
console.log(`Successfully processed email for feed ${feedId}`);
return new Response("Email processed successfully", { status: 200 });
}