From 093efe7fc981320f6d2c43c291cae00df85dee2c Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Wed, 20 May 2026 22:54:46 +0200 Subject: [PATCH] feat: add Cloudflare Email Workers support alongside ForwardEmail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package-lock.json | 7 ++ package.json | 1 + src/index.ts | 122 +++++++++++++++------------- src/lib/cloudflare-email.test.ts | 110 ++++++++++++++++++++++++++ src/lib/cloudflare-email.ts | 39 +++++++++ src/lib/email-processor.test.ts | 132 +++++++++++++++++++++++++++++++ src/lib/email-processor.ts | 105 ++++++++++++++++++++++++ src/routes/inbound.ts | 128 ++++-------------------------- 8 files changed, 477 insertions(+), 167 deletions(-) create mode 100644 src/lib/cloudflare-email.test.ts create mode 100644 src/lib/cloudflare-email.ts create mode 100644 src/lib/email-processor.test.ts create mode 100644 src/lib/email-processor.ts diff --git a/package-lock.json b/package-lock.json index 6ac19ca..5fbd295 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "feed": "^5.2.0", "hono": "^4.11.7", + "postal-mime": "^2.7.4", "zod": "^4.3.6" }, "devDependencies": { @@ -2658,6 +2659,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index a0d3401..86758de 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "dependencies": { "feed": "^5.2.0", "hono": "^4.11.7", + "postal-mime": "^2.7.4", "zod": "^4.3.6" } } diff --git a/src/index.ts b/src/index.ts index 24c0b6f..e079c79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,18 @@ -import { Hono } from 'hono'; -import { handle as handleInbound } from './routes/inbound'; -import { handle as handleRSS } from './routes/rss'; -import { handle as handleAdmin } from './routes/admin'; -import { Env } from './types'; +import { Hono } from "hono"; +import { handle as handleInbound } from "./routes/inbound"; +import { handle as handleRSS } from "./routes/rss"; +import { handle as handleAdmin } from "./routes/admin"; +import { handleCloudflareEmail } from "./lib/cloudflare-email"; +import { Env } from "./types"; // Define allowed origins for CORS -const ALLOWED_ORIGINS = ['https://getmynews.app', 'https://www.getmynews.app']; +const ALLOWED_ORIGINS = ["https://getmynews.app", "https://www.getmynews.app"]; // Fallback ForwardEmail.net IP addresses in case API fetch fails const FALLBACK_FORWARD_EMAIL_IPS = [ - '138.197.213.185', // mx1.forwardemail.net - '121.127.44.56', // mx1.forwardemail.net (alternate) - '104.248.224.170' // mx2.forwardemail.net + "138.197.213.185", // mx1.forwardemail.net + "121.127.44.56", // mx1.forwardemail.net (alternate) + "104.248.224.170", // mx2.forwardemail.net ]; // Create the main Hono app @@ -30,69 +31,70 @@ async function getForwardEmailIps(): Promise { if (forwardEmailIpsCache && forwardEmailIpsCache.expiresAt > Date.now()) { return forwardEmailIpsCache.ips; } - + // Fetch the latest IPs from ForwardEmail.net - const response = await fetch('https://forwardemail.net/ips/v4.json', { + const response = await fetch("https://forwardemail.net/ips/v4.json", { headers: { - 'User-Agent': 'Email-to-RSS/1.0', + "User-Agent": "Email-to-RSS/1.0", }, cf: { cacheTtl: 3600, // Cache for 1 hour in Cloudflare's cache cacheEverything: true, }, }); - + if (!response.ok) { throw new Error(`Failed to fetch IPs: ${response.status}`); } - + // Define the expected type for the API response interface IpEntry { hostname: string; ipv4: string[]; updated: string; } - - const data = await response.json() as IpEntry[]; - + + const data = (await response.json()) as IpEntry[]; + // Extract IPs for mx1 and mx2 servers const mxIps = data - .filter(entry => - entry.hostname === 'mx1.forwardemail.net' || - entry.hostname === 'mx2.forwardemail.net' + .filter( + (entry) => + entry.hostname === "mx1.forwardemail.net" || + entry.hostname === "mx2.forwardemail.net", ) - .flatMap(entry => entry.ipv4); - + .flatMap((entry) => entry.ipv4); + // Store in cache for 24 hours forwardEmailIpsCache = { ips: mxIps, - expiresAt: Date.now() + (24 * 60 * 60 * 1000) // 24 hours + expiresAt: Date.now() + 24 * 60 * 60 * 1000, // 24 hours }; - - console.log('Fetched ForwardEmail.net IPs:', mxIps); + + console.log("Fetched ForwardEmail.net IPs:", mxIps); return mxIps; } catch (error) { - console.error('Error fetching ForwardEmail.net IPs:', error); + console.error("Error fetching ForwardEmail.net IPs:", error); // Return fallback IPs if fetch fails return FALLBACK_FORWARD_EMAIL_IPS; } } // CORS middleware -app.use('*', async (c, next) => { - const origin = c.req.header('Origin'); +app.use("*", async (c, next) => { + const origin = c.req.header("Origin"); if (origin && ALLOWED_ORIGINS.includes(origin)) { - c.header('Access-Control-Allow-Origin', origin); - c.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - c.header('Access-Control-Max-Age', '86400'); + c.header("Access-Control-Allow-Origin", origin); + c.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + c.header("Access-Control-Allow-Headers", "Content-Type, Authorization"); + c.header("Access-Control-Max-Age", "86400"); } - + // Handle preflight requests - if (c.req.method === 'OPTIONS') { - return c.text('', 204); + if (c.req.method === "OPTIONS") { + return c.body(null, 204); } - + await next(); }); @@ -102,45 +104,55 @@ const rss = new Hono(); const admin = new Hono(); // Webhook security middleware for /inbound - verify ForwardEmail.net IP -api.use('/inbound', async (c, next) => { +api.use("/inbound", async (c, next) => { // Get the client IP - const clientIP = c.req.header('CF-Connecting-IP') || // Cloudflare-specific header - c.req.header('X-Forwarded-For')?.split(',')[0].trim() || - c.req.raw.headers.get('x-real-ip') || - '0.0.0.0'; - + const clientIP = + c.req.header("CF-Connecting-IP") || // Cloudflare-specific header + c.req.header("X-Forwarded-For")?.split(",")[0].trim() || + c.req.raw.headers.get("x-real-ip") || + "0.0.0.0"; + // Get the latest ForwardEmail.net IPs const allowedIps = await getForwardEmailIps(); - + // Check if the request is coming from ForwardEmail.net if (!allowedIps.includes(clientIP)) { console.error(`Unauthorized webhook request from IP: ${clientIP}`); - return c.text('Unauthorized', 401); + return c.text("Unauthorized", 401); } - + console.log(`Authorized webhook request from ForwardEmail.net (${clientIP})`); await next(); }); // API routes (inbound webhook) -api.post('/inbound', handleInbound); +api.post("/inbound", handleInbound); // RSS feed routes (public) -rss.get('/:feedId', handleRSS); +rss.get("/:feedId", handleRSS); // Admin routes (protected) -admin.route('/', handleAdmin); +admin.route("/", handleAdmin); // Mount the route groups -app.route('/api', api); -app.route('/rss', rss); -app.route('/admin', admin); +app.route("/api", api); +app.route("/rss", rss); +app.route("/admin", admin); // Root path redirects to admin dashboard -app.get('/', (c) => c.redirect('/admin')); +app.get("/", (c) => c.redirect("/admin")); // Catch-all for 404s -app.all('*', (c) => c.text('Not Found', 404)); +app.all("*", (c) => c.text("Not Found", 404)); -// Export the worker handler -export default app; +// Export both the HTTP fetch handler and the Cloudflare Email handler +export default { + fetch: app.fetch.bind(app), + async email( + message: ForwardableEmailMessage, + env: Env, + ctx: ExecutionContext, + ) { + await handleCloudflareEmail(message, env, ctx); + }, +}; diff --git a/src/lib/cloudflare-email.test.ts b/src/lib/cloudflare-email.test.ts new file mode 100644 index 0000000..f4426a9 --- /dev/null +++ b/src/lib/cloudflare-email.test.ts @@ -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 ", + `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({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); + + return { + from: overrides.from ?? "sender@example.com", + 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; + + 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: ["sender@example.com"] }), + ); + + 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: ["other@example.com"] }), + ); + + await handleCloudflareEmail(makeMessage(), env as any, {} as any); + + const metadata = await env.EMAIL_STORAGE.get( + `feed:${VALID_FEED_ID}:metadata`, + "json", + ); + expect(metadata).toBeNull(); + }); +}); diff --git a/src/lib/cloudflare-email.ts b/src/lib/cloudflare-email.ts new file mode 100644 index 0000000..3c17382 --- /dev/null +++ b/src/lib/cloudflare-email.ts @@ -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 { + 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 = {}; + 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); + } +} diff --git a/src/lib/email-processor.test.ts b/src/lib/email-processor.test.ts new file mode 100644 index 0000000..e9f1cbc --- /dev/null +++ b/src/lib/email-processor.test.ts @@ -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 { + return { + toAddress: VALID_TO, + from: "Sender ", + senders: ["sender@example.com"], + subject: "Test Subject", + content: "

Hello

", + receivedAt: 1700000000000, + ...overrides, + }; +} + +describe("processEmail", () => { + let env: ReturnType; + + 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: "body" }); + 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("body"); + expect(emailData.from).toBe("Sender "); + }); + + 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"); + }); +}); diff --git a/src/lib/email-processor.ts b/src/lib/email-processor.ts new file mode 100644 index 0000000..a75ca3c --- /dev/null +++ b/src/lib/email-processor.ts @@ -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; +} + +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 { + 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 }); +} diff --git a/src/routes/inbound.ts b/src/routes/inbound.ts index 7461799..7f2e3ba 100644 --- a/src/routes/inbound.ts +++ b/src/routes/inbound.ts @@ -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; } -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 " 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 { 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 { 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 });