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 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-20 22:54:46 +02:00
parent 29446a2aac
commit 093efe7fc9
8 changed files with 477 additions and 167 deletions
+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 <sender@example.com>",
senders: ["sender@example.com"],
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: "invalid@domain.com" }),
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: ["allowed@example.com"] }),
);
const res = await processEmail(
makeInput({ senders: ["other@example.com"] }),
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: ["sender@example.com"] }),
);
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: ["anyone@example.com"] }),
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 <sender@example.com>");
});
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");
});
});