mirror of
https://github.com/kennyparsons/cf-emailrouter.git
synced 2025-09-01 18:20:03 +00:00
refactored routes, organized, console logging
This commit is contained in:
parent
679b565acf
commit
acb20e82a1
4 changed files with 243 additions and 214 deletions
14
src/auth.js
Normal file
14
src/auth.js
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
// src/auth.js
|
||||||
|
export async function isAuthorized(request, env) {
|
||||||
|
const auth = request.headers.get("Authorization") || "";
|
||||||
|
//console.log("Authorization header received:", auth);
|
||||||
|
|
||||||
|
const expected = await env.API_AUTH.get(env.WORKER_NAME);
|
||||||
|
if (!expected) {
|
||||||
|
console.warn("No API key set for worker " + env.WORKER_NAME);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = auth === `Bearer ${expected}`;
|
||||||
|
return isValid;
|
||||||
|
}
|
136
src/email/main.js
Normal file
136
src/email/main.js
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
import { applyDefaults } from "../schema.js";
|
||||||
|
|
||||||
|
// Global logging level: set to 4 (debug) to log everything.
|
||||||
|
// Logging level definitions:
|
||||||
|
// 0: No logging, 1: error, 2: info, 3: warn, 4: debug.
|
||||||
|
const currentLoggingLevel = 4;
|
||||||
|
const loggingLevels = {
|
||||||
|
error: 1,
|
||||||
|
info: 2,
|
||||||
|
warn: 3,
|
||||||
|
debug: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
function log(level, message, ...optionalParams) {
|
||||||
|
if (loggingLevels[level] <= currentLoggingLevel && currentLoggingLevel > 0) {
|
||||||
|
switch (level) {
|
||||||
|
case 'debug':
|
||||||
|
console.debug(message, ...optionalParams);
|
||||||
|
break;
|
||||||
|
case 'info':
|
||||||
|
console.info(message, ...optionalParams);
|
||||||
|
break;
|
||||||
|
case 'warn':
|
||||||
|
console.warn(message, ...optionalParams);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
console.error(message, ...optionalParams);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(message, ...optionalParams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleEmail(message, env, ctx) {
|
||||||
|
// Log initial message details.
|
||||||
|
log('debug', `Received email: from=${message.from}, to=${message.to}, subject=${message.subject}`);
|
||||||
|
|
||||||
|
// Retrieve configuration for the recipient email address from KV.
|
||||||
|
log('debug', `Fetching configuration for recipient: ${message.to}`);
|
||||||
|
const configStr = await env.EMAIL_KV.get(message.to);
|
||||||
|
if (!configStr) {
|
||||||
|
log('warn', `No configuration found for recipient ${message.to}`);
|
||||||
|
message.setReject("No route defined");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let config;
|
||||||
|
try {
|
||||||
|
config = JSON.parse(configStr);
|
||||||
|
// Apply default values for any missing configuration fields
|
||||||
|
config = applyDefaults(config);
|
||||||
|
log('debug', `Configuration for ${message.to} after applying defaults:`, config);
|
||||||
|
} catch (e) {
|
||||||
|
log('error', "Invalid JSON configuration:", e);
|
||||||
|
message.setReject("Invalid routing config");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the configuration is enabled.
|
||||||
|
if (!config.enabled) {
|
||||||
|
log('info', `Configuration for ${message.to} is disabled.`);
|
||||||
|
message.setReject("Service disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract sender details.
|
||||||
|
const sender = message.from;
|
||||||
|
const senderDomain = sender.split("@")[1].toLowerCase();
|
||||||
|
log('debug', `Parsed sender details: sender=${sender}, domain=${senderDomain}`);
|
||||||
|
|
||||||
|
// Allow list: if defined, the sender must match an allowed domain or email.
|
||||||
|
if (config.allow) {
|
||||||
|
let allowed = false;
|
||||||
|
if (config.allow.domains && config.allow.domains.includes(senderDomain)) {
|
||||||
|
allowed = true;
|
||||||
|
log('debug', `Sender domain ${senderDomain} is allowed.`);
|
||||||
|
}
|
||||||
|
if (config.allow.emails && config.allow.emails.includes(sender)) {
|
||||||
|
allowed = true;
|
||||||
|
log('debug', `Sender email ${sender} is allowed.`);
|
||||||
|
}
|
||||||
|
if (!allowed) {
|
||||||
|
log('warn', `Sender ${sender} is not allowed for ${message.to}`);
|
||||||
|
// if (config.logging && config.logging.log_sender_domain) {
|
||||||
|
// log('warn', `Sender domain ${senderDomain} not allowed for ${message.to}`);
|
||||||
|
// }
|
||||||
|
message.setReject("Sender not allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deny list: if defined, immediately reject if the sender is explicitly denied.
|
||||||
|
if (config.deny) {
|
||||||
|
if (
|
||||||
|
(config.deny.domains && config.deny.domains.includes(senderDomain)) ||
|
||||||
|
(config.deny.emails && config.deny.emails.includes(sender))
|
||||||
|
) {
|
||||||
|
log('warn', `Sender ${sender} is explicitly denied for ${message.to}`);
|
||||||
|
message.setReject("Sender denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtering: iterate through any filtering rules.
|
||||||
|
if (config.filtering && Array.isArray(config.filtering)) {
|
||||||
|
for (const rule of config.filtering) {
|
||||||
|
const subject = message.subject || "";
|
||||||
|
if (subject.toLowerCase().includes(rule.pattern.toLowerCase())) {
|
||||||
|
log('info', `Email subject matches filter rule "${rule.pattern}"`);
|
||||||
|
if (rule.action === "reject") {
|
||||||
|
log('warn', `Filter action 'reject' triggered by pattern "${rule.pattern}". Rejecting email.`);
|
||||||
|
message.setReject("Filtered email");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Additional filtering actions can be added here as needed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward the email to the destination(s) defined in the configuration.
|
||||||
|
if (config.forward_to) {
|
||||||
|
const forwardingAddresses = Array.isArray(config.forward_to)
|
||||||
|
? config.forward_to
|
||||||
|
: [config.forward_to];
|
||||||
|
|
||||||
|
log('debug', `Forwarding email for ${message.to} to: ${forwardingAddresses.join(", ")}`);
|
||||||
|
for (const addr of forwardingAddresses) {
|
||||||
|
await message.forward(addr);
|
||||||
|
log('debug', `Email forwarded to ${addr}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log('warn', `No forwarding address configured for ${message.to}`);
|
||||||
|
message.setReject("No forwarding address configured");
|
||||||
|
}
|
||||||
|
}
|
233
src/index.js
233
src/index.js
|
@ -1,224 +1,31 @@
|
||||||
import { applyDefaults } from "./schema.js";
|
import * as API from "./routes/api.js";
|
||||||
|
import { handleEmail } from "./email/main.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request, env, ctx) {
|
async fetch(request, env, ctx) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const pathname = url.pathname;
|
const path = url.pathname;
|
||||||
|
|
||||||
// Handle API routes under /api/email
|
// Visually define our expected routes using a switch on conditions
|
||||||
if (pathname.startsWith("/api/email")) {
|
switch (true) {
|
||||||
return handleApiRequest(request, env);
|
// API route for listing all email configurations
|
||||||
|
case path === "/api/email/list":
|
||||||
|
return API.handleEmailList(request, env, ctx);
|
||||||
|
|
||||||
|
// API route for handling a specific email configuration
|
||||||
|
case /^\/api\/email\/([^\/]+)\/?$/.test(path): {
|
||||||
|
const match = path.match(/^\/api\/email\/([^\/]+)\/?$/);
|
||||||
|
const emailParam = decodeURIComponent(match[1]);
|
||||||
|
return API.handleEmailConfig(request, env, ctx, emailParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: no matching route, return 404
|
||||||
|
default:
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: serve UI or return 404 if no matching route
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async email(message, env, ctx) {
|
async email(message, env, ctx) {
|
||||||
await handleEmail(message, env, ctx);
|
return handleEmail(message, env, ctx);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleEmail(message, env, ctx) {
|
|
||||||
// Retrieve configuration for the recipient email address from KV.
|
|
||||||
const configStr = await env.EMAIL_KV.get(message.to);
|
|
||||||
if (!configStr) {
|
|
||||||
console.warn(`No configuration for recipient ${message.to}`);
|
|
||||||
message.setReject("No route defined");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let config;
|
|
||||||
try {
|
|
||||||
config = JSON.parse(configStr);
|
|
||||||
// Apply default values for any missing configuration fields
|
|
||||||
config = applyDefaults(config);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Invalid JSON configuration:", e);
|
|
||||||
message.setReject("Invalid routing config");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defensive check for new field 'site_origin'
|
|
||||||
const siteOrigin = config.site_origin || "";
|
|
||||||
if (siteOrigin) {
|
|
||||||
// Ensure the 'allow' object exists and has a 'domains' array
|
|
||||||
if (!config.allow) {
|
|
||||||
config.allow = { domains: [], emails: [] };
|
|
||||||
} else if (!config.allow.domains) {
|
|
||||||
config.allow.domains = [];
|
|
||||||
}
|
|
||||||
// Add site_origin to allowed domains if not already present
|
|
||||||
if (!config.allow.domains.includes(siteOrigin)) {
|
|
||||||
console.log(`Adding site_origin '${siteOrigin}' to allowed domains for ${message.to}`);
|
|
||||||
config.allow.domains.push(siteOrigin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the configuration is enabled.
|
|
||||||
if (!config.enabled) {
|
|
||||||
console.log(`Configuration for ${message.to} is disabled.`);
|
|
||||||
message.setReject("Service disabled");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract sender details.
|
|
||||||
const sender = message.from;
|
|
||||||
const senderDomain = sender.split("@")[1].toLowerCase();
|
|
||||||
|
|
||||||
// Allow list: if defined, the sender must match an allowed domain or email.
|
|
||||||
if (config.allow) {
|
|
||||||
let allowed = false;
|
|
||||||
if (config.allow.domains && config.allow.domains.includes(senderDomain)) {
|
|
||||||
allowed = true;
|
|
||||||
}
|
|
||||||
if (config.allow.emails && config.allow.emails.includes(sender)) {
|
|
||||||
allowed = true;
|
|
||||||
}
|
|
||||||
if (!allowed) {
|
|
||||||
if (config.logging && config.logging.log_sender_domain) {
|
|
||||||
console.warn(`Sender domain ${senderDomain} not allowed for ${message.to}`);
|
|
||||||
}
|
|
||||||
message.setReject("Sender not allowed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deny list: if defined, immediately reject if the sender is explicitly denied.
|
|
||||||
if (config.deny) {
|
|
||||||
if (
|
|
||||||
(config.deny.domains && config.deny.domains.includes(senderDomain)) ||
|
|
||||||
(config.deny.emails && config.deny.emails.includes(sender))
|
|
||||||
) {
|
|
||||||
console.warn(`Sender ${sender} is explicitly denied for ${message.to}`);
|
|
||||||
message.setReject("Sender denied");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtering: iterate through any filtering rules (e.g., reject if subject matches a pattern).
|
|
||||||
if (config.filtering && Array.isArray(config.filtering)) {
|
|
||||||
for (const rule of config.filtering) {
|
|
||||||
const subject = message.subject || "";
|
|
||||||
if (subject.toLowerCase().includes(rule.pattern.toLowerCase())) {
|
|
||||||
if (rule.action === "reject") {
|
|
||||||
console.warn(`Email subject matches filter rule "${rule.pattern}". Rejecting email.`);
|
|
||||||
message.setReject("Filtered email");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Additional filtering actions can be added here as needed.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward the email to the destination(s) defined in the configuration.
|
|
||||||
if (config.forward_to) {
|
|
||||||
const forwardingAddresses = Array.isArray(config.forward_to)
|
|
||||||
? config.forward_to
|
|
||||||
: [config.forward_to];
|
|
||||||
|
|
||||||
for (const addr of forwardingAddresses) {
|
|
||||||
await message.forward(addr);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(`No forwarding address configured for ${message.to}`);
|
|
||||||
message.setReject("No forwarding address configured");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleApiRequest(request, env) {
|
|
||||||
// Ensure API requests are authorized via our central API_AUTH KV store
|
|
||||||
if (!(await isAuthorized(request, env))) {
|
|
||||||
console.log("Authorization failed");
|
|
||||||
return new Response("Unauthorized", { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const parts = url.pathname.split("/").filter(Boolean); // e.g., ["api", "email", "list"] or ["api", "email", "<email>"]
|
|
||||||
|
|
||||||
// Validate that the URL follows our expected pattern: /api/email/...
|
|
||||||
if (parts.length < 2 || parts[0] !== "api" || parts[1] !== "email") {
|
|
||||||
return new Response("Bad request", { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route: /api/email/list
|
|
||||||
if (parts[2] === "list") {
|
|
||||||
if (request.method === "GET") {
|
|
||||||
const list = await env.EMAIL_KV.list();
|
|
||||||
const emails = await Promise.all(
|
|
||||||
list.keys.map(async (entry) => {
|
|
||||||
const val = await env.EMAIL_KV.get(entry.name);
|
|
||||||
let config;
|
|
||||||
try {
|
|
||||||
config = JSON.parse(val);
|
|
||||||
config = applyDefaults(config);
|
|
||||||
} catch (e) {
|
|
||||||
// In case the stored value isn't valid JSON, return it as-is.
|
|
||||||
config = val;
|
|
||||||
}
|
|
||||||
return { email: entry.name, config };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return new Response(JSON.stringify(emails), {
|
|
||||||
headers: { "Content-Type": "application/json" }
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return new Response("Method Not Allowed", { status: 405 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route: /api/email/:email
|
|
||||||
const emailKey = decodeURIComponent(parts[2]);
|
|
||||||
switch (request.method) {
|
|
||||||
case "GET": {
|
|
||||||
const val = await env.EMAIL_KV.get(emailKey);
|
|
||||||
if (val) {
|
|
||||||
let config;
|
|
||||||
try {
|
|
||||||
config = JSON.parse(val);
|
|
||||||
config = applyDefaults(config);
|
|
||||||
} catch (e) {
|
|
||||||
config = val;
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify(config), {
|
|
||||||
headers: { "Content-Type": "application/json" }
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "PUT": {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
await env.EMAIL_KV.put(emailKey, JSON.stringify(body));
|
|
||||||
return new Response("Saved", { status: 200 });
|
|
||||||
} catch (error) {
|
|
||||||
return new Response("Invalid JSON", { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "DELETE": {
|
|
||||||
await env.EMAIL_KV.delete(emailKey);
|
|
||||||
return new Response("Deleted", { status: 200 });
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return new Response("Bad request", { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isAuthorized(request, env) {
|
|
||||||
const auth = request.headers.get("Authorization") || "";
|
|
||||||
console.log("Authorization header received:", auth);
|
|
||||||
|
|
||||||
const expected = await env.API_AUTH.get(env.WORKER_NAME);
|
|
||||||
// console.log("Expected API key from KV for worker", env.WORKER_NAME, ":", expected);
|
|
||||||
|
|
||||||
if (!expected) {
|
|
||||||
console.warn("No API key set for worker " + env.WORKER_NAME);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = auth === `Bearer ${expected}`;
|
|
||||||
// console.log("Authorization", isValid);
|
|
||||||
return isValid;
|
|
||||||
}
|
|
72
src/routes/api.js
Normal file
72
src/routes/api.js
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import { applyDefaults } from "../schema.js";
|
||||||
|
import { isAuthorized } from "../auth.js";
|
||||||
|
|
||||||
|
export async function handleEmailList(request, env, ctx) {
|
||||||
|
if (!(await isAuthorized(request, env))) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (request.method) {
|
||||||
|
case "GET": {
|
||||||
|
const list = await env.EMAIL_KV.list();
|
||||||
|
const emails = await Promise.all(
|
||||||
|
list.keys.map(async (entry) => {
|
||||||
|
const val = await env.EMAIL_KV.get(entry.name);
|
||||||
|
let config;
|
||||||
|
try {
|
||||||
|
config = JSON.parse(val);
|
||||||
|
config = applyDefaults(config);
|
||||||
|
} catch (e) {
|
||||||
|
config = val;
|
||||||
|
}
|
||||||
|
return { email: entry.name, config };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return new Response(JSON.stringify(emails), {
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return new Response("Method Not Allowed", { status: 405 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleEmailConfig(request, env, ctx, emailKey) {
|
||||||
|
if (!(await isAuthorized(request, env))) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
switch (request.method) {
|
||||||
|
case "GET": {
|
||||||
|
const val = await env.EMAIL_KV.get(emailKey);
|
||||||
|
if (val) {
|
||||||
|
let config;
|
||||||
|
try {
|
||||||
|
config = JSON.parse(val);
|
||||||
|
config = applyDefaults(config);
|
||||||
|
} catch (e) {
|
||||||
|
config = val;
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(config), {
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "PUT": {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
await env.EMAIL_KV.put(emailKey, JSON.stringify(body));
|
||||||
|
return new Response("Saved", { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
return new Response("Invalid JSON", { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "DELETE": {
|
||||||
|
await env.EMAIL_KV.delete(emailKey);
|
||||||
|
return new Response("Deleted", { status: 200 });
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return new Response("Method Not Allowed", { status: 405 });
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue