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
+16 -112
View File
@@ -1,8 +1,8 @@
import { Context } from "hono";
import { EmailParser } from "../utils/email-parser";
import { Env, FeedConfig, FeedMetadata } from "../types";
import { Env } from "../types";
import { processEmail } from "../lib/email-processor";
// Interface for ForwardEmail.net webhook payload
interface ForwardEmailPayload {
recipients?: string[];
from?: {
@@ -21,64 +21,30 @@ interface ForwardEmailPayload {
attachments?: Array<any>;
}
function normalizeEmail(value: string): string {
return value.trim().toLowerCase();
}
function extractIncomingSenderAddresses(
payload: ForwardEmailPayload,
): string[] {
const valueEntries = payload.from?.value || [];
const structuredAddresses = valueEntries
.map((entry) => entry.address || "")
.map(normalizeEmail)
.map((v) => v.trim().toLowerCase())
.filter(Boolean);
if (structuredAddresses.length > 0) {
return Array.from(new Set(structuredAddresses));
}
// Fallback parser for plain text like "Name <sender@example.com>"
const fromText = payload.from?.text || "";
const matches =
fromText.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) || [];
return Array.from(new Set(matches.map(normalizeEmail)));
return Array.from(new Set(matches.map((v) => v.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;
}
/**
* Handle incoming emails from ForwardEmail.net webhook
*/
export async function handle(c: Context): Promise<Response> {
try {
// Type assertion for environment variables
const env = c.env as unknown as Env;
// Parse the incoming JSON payload
const payload: ForwardEmailPayload = await c.req.json();
// Log basic information about the incoming email
console.log("Received email:", {
to: payload.recipients?.[0],
from: payload.from?.text || "Unknown",
@@ -86,82 +52,20 @@ export async function handle(c: Context): Promise<Response> {
contentType: payload.html ? "HTML" : "Text",
});
// Extract feed ID from email address (e.g., apple.mountain.42@domain.com -> apple.mountain.42)
const toAddress = payload.recipients?.[0] || "";
const feedId = EmailParser.extractFeedId(toAddress);
if (!feedId) {
console.error(`Invalid email address format: ${toAddress}`);
return new Response("Invalid email address format", { status: 400 });
}
// Check if the feed exists by looking up the feed configuration
const feedConfigKey = `feed:${feedId}:config`;
const feedConfig = (await env.EMAIL_STORAGE.get(
feedConfigKey,
"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 incomingSenders = extractIncomingSenderAddresses(payload);
const senderAllowed = incomingSenders.some((sender) =>
allowedSenders.some((allowedSender) =>
senderMatchesAllowlist(sender, allowedSender),
),
);
if (!senderAllowed) {
console.warn(
`Rejected email for feed ${feedId}; sender not in allowlist`,
{
incomingSenders,
allowedSenders,
},
);
return new Response("Sender not allowed for this feed", {
status: 403,
});
}
}
// Parse the email using our simplified parser
const emailData = EmailParser.parseForwardEmailPayload(payload);
// Generate a unique key for this email in KV storage
const emailKey = `feed:${feedId}:${Date.now()}`;
// Store the email data in KV
await env.EMAIL_STORAGE.put(emailKey, JSON.stringify(emailData));
// Get existing feed metadata
const feedMetadataKey = `feed:${feedId}:metadata`;
const feedMetadata = ((await env.EMAIL_STORAGE.get(
feedMetadataKey,
"json",
)) || { emails: [] }) as FeedMetadata;
// Add this email to the feed metadata
feedMetadata.emails.unshift({
key: emailKey,
subject: emailData.subject,
receivedAt: emailData.receivedAt,
});
// Store updated feed metadata
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 });
return processEmail(
{
toAddress: payload.recipients?.[0] || "",
from: emailData.from,
senders: extractIncomingSenderAddresses(payload),
subject: emailData.subject,
content: emailData.content,
receivedAt: emailData.receivedAt,
headers: emailData.headers,
},
env,
);
} catch (error) {
console.error("Error processing email:", error);
return new Response("Error processing email", { status: 500 });