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:
+67
-55
@@ -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<string[]> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user