mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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:
Generated
+7
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"feed": "^5.2.0",
|
"feed": "^5.2.0",
|
||||||
"hono": "^4.11.7",
|
"hono": "^4.11.7",
|
||||||
|
"postal-mime": "^2.7.4",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2658,6 +2659,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"feed": "^5.2.0",
|
"feed": "^5.2.0",
|
||||||
"hono": "^4.11.7",
|
"hono": "^4.11.7",
|
||||||
|
"postal-mime": "^2.7.4",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-55
@@ -1,17 +1,18 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from "hono";
|
||||||
import { handle as handleInbound } from './routes/inbound';
|
import { handle as handleInbound } from "./routes/inbound";
|
||||||
import { handle as handleRSS } from './routes/rss';
|
import { handle as handleRSS } from "./routes/rss";
|
||||||
import { handle as handleAdmin } from './routes/admin';
|
import { handle as handleAdmin } from "./routes/admin";
|
||||||
import { Env } from './types';
|
import { handleCloudflareEmail } from "./lib/cloudflare-email";
|
||||||
|
import { Env } from "./types";
|
||||||
|
|
||||||
// Define allowed origins for CORS
|
// 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
|
// Fallback ForwardEmail.net IP addresses in case API fetch fails
|
||||||
const FALLBACK_FORWARD_EMAIL_IPS = [
|
const FALLBACK_FORWARD_EMAIL_IPS = [
|
||||||
'138.197.213.185', // mx1.forwardemail.net
|
"138.197.213.185", // mx1.forwardemail.net
|
||||||
'121.127.44.56', // mx1.forwardemail.net (alternate)
|
"121.127.44.56", // mx1.forwardemail.net (alternate)
|
||||||
'104.248.224.170' // mx2.forwardemail.net
|
"104.248.224.170", // mx2.forwardemail.net
|
||||||
];
|
];
|
||||||
|
|
||||||
// Create the main Hono app
|
// Create the main Hono app
|
||||||
@@ -30,69 +31,70 @@ async function getForwardEmailIps(): Promise<string[]> {
|
|||||||
if (forwardEmailIpsCache && forwardEmailIpsCache.expiresAt > Date.now()) {
|
if (forwardEmailIpsCache && forwardEmailIpsCache.expiresAt > Date.now()) {
|
||||||
return forwardEmailIpsCache.ips;
|
return forwardEmailIpsCache.ips;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the latest IPs from ForwardEmail.net
|
// 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: {
|
headers: {
|
||||||
'User-Agent': 'Email-to-RSS/1.0',
|
"User-Agent": "Email-to-RSS/1.0",
|
||||||
},
|
},
|
||||||
cf: {
|
cf: {
|
||||||
cacheTtl: 3600, // Cache for 1 hour in Cloudflare's cache
|
cacheTtl: 3600, // Cache for 1 hour in Cloudflare's cache
|
||||||
cacheEverything: true,
|
cacheEverything: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch IPs: ${response.status}`);
|
throw new Error(`Failed to fetch IPs: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define the expected type for the API response
|
// Define the expected type for the API response
|
||||||
interface IpEntry {
|
interface IpEntry {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
ipv4: string[];
|
ipv4: string[];
|
||||||
updated: string;
|
updated: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as IpEntry[];
|
const data = (await response.json()) as IpEntry[];
|
||||||
|
|
||||||
// Extract IPs for mx1 and mx2 servers
|
// Extract IPs for mx1 and mx2 servers
|
||||||
const mxIps = data
|
const mxIps = data
|
||||||
.filter(entry =>
|
.filter(
|
||||||
entry.hostname === 'mx1.forwardemail.net' ||
|
(entry) =>
|
||||||
entry.hostname === 'mx2.forwardemail.net'
|
entry.hostname === "mx1.forwardemail.net" ||
|
||||||
|
entry.hostname === "mx2.forwardemail.net",
|
||||||
)
|
)
|
||||||
.flatMap(entry => entry.ipv4);
|
.flatMap((entry) => entry.ipv4);
|
||||||
|
|
||||||
// Store in cache for 24 hours
|
// Store in cache for 24 hours
|
||||||
forwardEmailIpsCache = {
|
forwardEmailIpsCache = {
|
||||||
ips: mxIps,
|
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;
|
return mxIps;
|
||||||
} catch (error) {
|
} 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 IPs if fetch fails
|
||||||
return FALLBACK_FORWARD_EMAIL_IPS;
|
return FALLBACK_FORWARD_EMAIL_IPS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CORS middleware
|
// CORS middleware
|
||||||
app.use('*', async (c, next) => {
|
app.use("*", async (c, next) => {
|
||||||
const origin = c.req.header('Origin');
|
const origin = c.req.header("Origin");
|
||||||
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
||||||
c.header('Access-Control-Allow-Origin', origin);
|
c.header("Access-Control-Allow-Origin", origin);
|
||||||
c.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
c.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||||||
c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
c.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||||
c.header('Access-Control-Max-Age', '86400');
|
c.header("Access-Control-Max-Age", "86400");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle preflight requests
|
// Handle preflight requests
|
||||||
if (c.req.method === 'OPTIONS') {
|
if (c.req.method === "OPTIONS") {
|
||||||
return c.text('', 204);
|
return c.body(null, 204);
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,45 +104,55 @@ const rss = new Hono();
|
|||||||
const admin = new Hono();
|
const admin = new Hono();
|
||||||
|
|
||||||
// Webhook security middleware for /inbound - verify ForwardEmail.net IP
|
// 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
|
// Get the client IP
|
||||||
const clientIP = c.req.header('CF-Connecting-IP') || // Cloudflare-specific header
|
const clientIP =
|
||||||
c.req.header('X-Forwarded-For')?.split(',')[0].trim() ||
|
c.req.header("CF-Connecting-IP") || // Cloudflare-specific header
|
||||||
c.req.raw.headers.get('x-real-ip') ||
|
c.req.header("X-Forwarded-For")?.split(",")[0].trim() ||
|
||||||
'0.0.0.0';
|
c.req.raw.headers.get("x-real-ip") ||
|
||||||
|
"0.0.0.0";
|
||||||
|
|
||||||
// Get the latest ForwardEmail.net IPs
|
// Get the latest ForwardEmail.net IPs
|
||||||
const allowedIps = await getForwardEmailIps();
|
const allowedIps = await getForwardEmailIps();
|
||||||
|
|
||||||
// Check if the request is coming from ForwardEmail.net
|
// Check if the request is coming from ForwardEmail.net
|
||||||
if (!allowedIps.includes(clientIP)) {
|
if (!allowedIps.includes(clientIP)) {
|
||||||
console.error(`Unauthorized webhook request from IP: ${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})`);
|
console.log(`Authorized webhook request from ForwardEmail.net (${clientIP})`);
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// API routes (inbound webhook)
|
// API routes (inbound webhook)
|
||||||
api.post('/inbound', handleInbound);
|
api.post("/inbound", handleInbound);
|
||||||
|
|
||||||
// RSS feed routes (public)
|
// RSS feed routes (public)
|
||||||
rss.get('/:feedId', handleRSS);
|
rss.get("/:feedId", handleRSS);
|
||||||
|
|
||||||
// Admin routes (protected)
|
// Admin routes (protected)
|
||||||
admin.route('/', handleAdmin);
|
admin.route("/", handleAdmin);
|
||||||
|
|
||||||
// Mount the route groups
|
// Mount the route groups
|
||||||
app.route('/api', api);
|
app.route("/api", api);
|
||||||
app.route('/rss', rss);
|
app.route("/rss", rss);
|
||||||
app.route('/admin', admin);
|
app.route("/admin", admin);
|
||||||
|
|
||||||
// Root path redirects to admin dashboard
|
// Root path redirects to admin dashboard
|
||||||
app.get('/', (c) => c.redirect('/admin'));
|
app.get("/", (c) => c.redirect("/admin"));
|
||||||
|
|
||||||
// Catch-all for 404s
|
// 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 both the HTTP fetch handler and the Cloudflare Email handler
|
||||||
export default app;
|
export default {
|
||||||
|
fetch: app.fetch.bind(app),
|
||||||
|
async email(
|
||||||
|
message: ForwardableEmailMessage,
|
||||||
|
env: Env,
|
||||||
|
ctx: ExecutionContext,
|
||||||
|
) {
|
||||||
|
await handleCloudflareEmail(message, env, ctx);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 <sender@example.com>",
|
||||||
|
`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 ?? "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<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: ["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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 <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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
+16
-112
@@ -1,8 +1,8 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
import { EmailParser } from "../utils/email-parser";
|
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 {
|
interface ForwardEmailPayload {
|
||||||
recipients?: string[];
|
recipients?: string[];
|
||||||
from?: {
|
from?: {
|
||||||
@@ -21,64 +21,30 @@ interface ForwardEmailPayload {
|
|||||||
attachments?: Array<any>;
|
attachments?: Array<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEmail(value: string): string {
|
|
||||||
return value.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractIncomingSenderAddresses(
|
function extractIncomingSenderAddresses(
|
||||||
payload: ForwardEmailPayload,
|
payload: ForwardEmailPayload,
|
||||||
): string[] {
|
): string[] {
|
||||||
const valueEntries = payload.from?.value || [];
|
const valueEntries = payload.from?.value || [];
|
||||||
const structuredAddresses = valueEntries
|
const structuredAddresses = valueEntries
|
||||||
.map((entry) => entry.address || "")
|
.map((entry) => entry.address || "")
|
||||||
.map(normalizeEmail)
|
.map((v) => v.trim().toLowerCase())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (structuredAddresses.length > 0) {
|
if (structuredAddresses.length > 0) {
|
||||||
return Array.from(new Set(structuredAddresses));
|
return Array.from(new Set(structuredAddresses));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback parser for plain text like "Name <sender@example.com>"
|
|
||||||
const fromText = payload.from?.text || "";
|
const fromText = payload.from?.text || "";
|
||||||
const matches =
|
const matches =
|
||||||
fromText.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) || [];
|
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> {
|
export async function handle(c: Context): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
// Type assertion for environment variables
|
|
||||||
const env = c.env as unknown as Env;
|
const env = c.env as unknown as Env;
|
||||||
|
|
||||||
// Parse the incoming JSON payload
|
|
||||||
const payload: ForwardEmailPayload = await c.req.json();
|
const payload: ForwardEmailPayload = await c.req.json();
|
||||||
|
|
||||||
// Log basic information about the incoming email
|
|
||||||
console.log("Received email:", {
|
console.log("Received email:", {
|
||||||
to: payload.recipients?.[0],
|
to: payload.recipients?.[0],
|
||||||
from: payload.from?.text || "Unknown",
|
from: payload.from?.text || "Unknown",
|
||||||
@@ -86,82 +52,20 @@ export async function handle(c: Context): Promise<Response> {
|
|||||||
contentType: payload.html ? "HTML" : "Text",
|
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);
|
const emailData = EmailParser.parseForwardEmailPayload(payload);
|
||||||
|
|
||||||
// Generate a unique key for this email in KV storage
|
return processEmail(
|
||||||
const emailKey = `feed:${feedId}:${Date.now()}`;
|
{
|
||||||
|
toAddress: payload.recipients?.[0] || "",
|
||||||
// Store the email data in KV
|
from: emailData.from,
|
||||||
await env.EMAIL_STORAGE.put(emailKey, JSON.stringify(emailData));
|
senders: extractIncomingSenderAddresses(payload),
|
||||||
|
subject: emailData.subject,
|
||||||
// Get existing feed metadata
|
content: emailData.content,
|
||||||
const feedMetadataKey = `feed:${feedId}:metadata`;
|
receivedAt: emailData.receivedAt,
|
||||||
const feedMetadata = ((await env.EMAIL_STORAGE.get(
|
headers: emailData.headers,
|
||||||
feedMetadataKey,
|
},
|
||||||
"json",
|
env,
|
||||||
)) || { 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 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing email:", error);
|
console.error("Error processing email:", error);
|
||||||
return new Response("Error processing email", { status: 500 });
|
return new Response("Error processing email", { status: 500 });
|
||||||
|
|||||||
Reference in New Issue
Block a user