diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..ab9578f --- /dev/null +++ b/src/auth.js @@ -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; + } \ No newline at end of file diff --git a/src/email/main.js b/src/email/main.js new file mode 100644 index 0000000..de6a31e --- /dev/null +++ b/src/email/main.js @@ -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"); + } +} \ No newline at end of file diff --git a/src/index.js b/src/index.js index 98b33a1..4e596fa 100644 --- a/src/index.js +++ b/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 { async fetch(request, env, ctx) { const url = new URL(request.url); - const pathname = url.pathname; + const path = url.pathname; - // Handle API routes under /api/email - if (pathname.startsWith("/api/email")) { - return handleApiRequest(request, env); + // Visually define our expected routes using a switch on conditions + switch (true) { + // 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) { - 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", ""] - - // 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; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/src/routes/api.js b/src/routes/api.js new file mode 100644 index 0000000..80f2a05 --- /dev/null +++ b/src/routes/api.js @@ -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 }); + } +}