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]>
106 lines
2.9 KiB
TypeScript
106 lines
2.9 KiB
TypeScript
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 });
|
|
}
|