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:
1 parent
29446a2aac
commit
093efe7fc9
8 files changed
+477
-167
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user