diff --git a/.github/workflows/deploy-email-worker.yml b/.github/workflows/deploy-email-worker.yml new file mode 100644 index 0000000..902b15c --- /dev/null +++ b/.github/workflows/deploy-email-worker.yml @@ -0,0 +1,76 @@ +name: Deploy VoidOne Email Worker + +on: + pull_request: + paths: + - "email-worker/**" + - ".github/workflows/deploy-email-worker.yml" + push: + branches: [main] + paths: + - "email-worker/**" + - ".github/workflows/deploy-email-worker.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: voidone-email-worker-production + cancel-in-progress: false + +jobs: + test: + name: Test email gateway + runs-on: ubuntu-latest + defaults: + run: + working-directory: email-worker + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install dependencies + run: npm install + - name: Run policy tests + run: npm test + + deploy: + name: Deploy to Cloudflare Workers + needs: test + runs-on: ubuntu-latest + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + defaults: + run: + working-directory: email-worker + steps: + - name: Explain skipped deployment + if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && (env.CF_API_TOKEN == '' || env.CF_ACCOUNT_ID == '') }} + run: | + echo "Tests passed, but Cloudflare deployment was skipped." + echo "Add CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID as repository Actions secrets, then rerun this workflow." + + - name: Checkout + if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && env.CF_API_TOKEN != '' && env.CF_ACCOUNT_ID != '' }} + uses: actions/checkout@v4 + - name: Set up Node.js + if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && env.CF_API_TOKEN != '' && env.CF_ACCOUNT_ID != '' }} + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install dependencies + if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && env.CF_API_TOKEN != '' && env.CF_ACCOUNT_ID != '' }} + run: npm install + - name: Deploy Worker (creates it if needed) + if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && env.CF_API_TOKEN != '' && env.CF_ACCOUNT_ID != '' }} + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: email-worker + command: deploy --config wrangler.jsonc diff --git a/email-worker/README.md b/email-worker/README.md new file mode 100644 index 0000000..2e92b0a --- /dev/null +++ b/email-worker/README.md @@ -0,0 +1,76 @@ +# VoidOne Email Gateway + +Cloudflare Email Worker for routing inbound mail for `voidone.dpdns.org`. + +## Features + +- Routes `support`, `bugs`, `contact`, `info`, and `security`. +- Optional exact-sender and sender-domain blocklists. +- Message-size, attachment-count, attachment-size, extension, and MIME-type policy checks. +- Structured logs with timestamp, sender, recipient, route, result, and reason. Message bodies, subjects, and attachment contents are not logged. +- Optional Slack notifications for rejected/error events. +- Optional KV audit binding; if configured, events expire after 90 days. +- Optional strict MIME mode: set `STRICT_MIME_SCAN=true` to reject messages when MIME parsing fails. + +**This is not antivirus or a full spam classifier.** It does not unpack archives or scan file contents. MIME types and extensions can be spoofed. Do not treat a successful check as proof that an attachment is safe. + +## GitHub Actions deployment + +The workflow at `.github/workflows/deploy-email-worker.yml` deploys this directory to Cloudflare after changes reach `main`, or when manually dispatched. + +Add these repository Actions secrets: + +- `CLOUDFLARE_API_TOKEN`: scoped to the intended Cloudflare account with permission to edit/deploy Workers. +- `CLOUDFLARE_ACCOUNT_ID`: the Cloudflare account ID. + +The workflow can create the Worker via Wrangler, but it cannot configure your private account without those credentials. Never commit tokens or webhook URLs. + +## Required Worker settings + +After the first successful deployment, open Cloudflare Dashboard → Workers & Pages → `voidone-email-gateway` → Settings → Variables and Secrets. Add: + +- `GMAIL_INBOX`: your verified forwarding destination. +- Optional `SUPPORT_INBOX`, `BUGS_INBOX`, `CONTACT_INBOX`, `INFO_INBOX`, `SECURITY_INBOX`: separate verified destinations. If omitted, the main inbox is used. +- Optional `BLOCKED_SENDERS`: comma-separated exact email addresses. +- Optional `BLOCKED_DOMAINS`: comma-separated domains. +- Optional `MAX_MESSAGE_BYTES`: default 15728640 (15 MiB). +- Optional `MAX_ATTACHMENTS`: default 10. +- Optional `MAX_TOTAL_ATTACHMENT_BYTES`: default 10485760 (10 MiB). +- Optional `STRICT_MIME_SCAN`: set to `true` to reject mail if MIME parsing fails. +- Optional secret `SLACK_WEBHOOK_URL`. +- Optional `SLACK_NOTIFY_ALL`: set to `true` to notify on successful forwards too. + +Variables are kept across Wrangler deployments. Secrets should be added using Cloudflare's secret manager. + +## Optional persistent audit log (KV) + +Without KV, structured logs are available through Workers Observability and are subject to Cloudflare's log availability/retention settings. + +For a separate 90-day audit store: + +1. Create a Workers KV namespace named `VOIDONE_MAIL_AUDIT`. +2. Add a KV binding named `AUDIT_LOGS` to this Worker, targeting that namespace. +3. Confirm the binding remains configured when updating deployment settings. + +The Worker writes only metadata to KV and sets a 90-day TTL. Restrict access to audit records because sender and recipient addresses are personal data. + +## Email Routing setup + +1. Deploy the Worker and configure `GMAIL_INBOX` first. +2. In Cloudflare Email Routing, create a rule for each supported address and select **Send to a Worker** → `voidone-email-gateway`. +3. Verify the destination address. +4. Ensure Cloudflare Email Routing's MX records are active. Do not leave conflicting InstantNode and Cloudflare Email Routing MX configurations in place. +5. Test one address at a time before adding the other four. + +Do not change website DNS records as part of this Worker setup. Email Workers handle inbound processing and forwarding; they do not provide outbound SMTP or make Gmail accept outgoing mail from the custom domain. + +## Local tests + +From this directory: + +```sh +npm install +npm test +``` + +Tests cover routing and policy helpers, not Cloudflare's live email infrastructure. Complete a real end-to-end test only after configuring the Worker and Email Routing. diff --git a/email-worker/package.json b/email-worker/package.json new file mode 100644 index 0000000..476df12 --- /dev/null +++ b/email-worker/package.json @@ -0,0 +1,17 @@ +{ + "name": "voidone-email-gateway", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test", + "deploy": "wrangler deploy --config wrangler.jsonc", + "dev": "wrangler dev --config wrangler.jsonc" + }, + "dependencies": { + "postal-mime": "2.4.3" + }, + "devDependencies": { + "wrangler": "4.132.0" + } +} diff --git a/email-worker/src/index.js b/email-worker/src/index.js new file mode 100644 index 0000000..964515e --- /dev/null +++ b/email-worker/src/index.js @@ -0,0 +1,333 @@ +import PostalMime from "postal-mime"; + +const DOMAIN = "voidone.dpdns.org"; +const AUDIT_TTL_SECONDS = 90 * 24 * 60 * 60; + +const ROUTES = { + support: "SUPPORT_INBOX", + bugs: "BUGS_INBOX", + contact: "CONTACT_INBOX", + info: "INFO_INBOX", + security: "SECURITY_INBOX" +}; + +const BLOCKED_EXTENSIONS = new Set([ + "exe", "dll", "com", "scr", "msi", "msp", "bat", "cmd", + "ps1", "vbs", "vbe", "hta", "lnk", "iso", "dmg", "apk", + "jar", "sh", "reg", "docm", "xlsm", "pptm" +]); + +const BLOCKED_MIME_TYPES = new Set([ + "application/x-msdownload", + "application/x-dosexec", + "application/x-executable", + "application/x-sh", + "application/vnd.microsoft.portable-executable" +]); + +function safeText(value, max = 320) { + return String(value ?? "") + .replace(/[\r\n\t\0]/g, " ") + .slice(0, max); +} + +export function csvSet(value = "") { + return new Set( + String(value) + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter(Boolean) + ); +} + +export function getLocalPart(address = "") { + const normalized = String(address).trim().toLowerCase(); + const at = normalized.lastIndexOf("@"); + + if (at < 1 || at === normalized.length - 1) return null; + if (normalized.slice(at + 1) !== DOMAIN) return null; + + const localPart = normalized.slice(0, at); + return Object.hasOwn(ROUTES, localPart) ? localPart : null; +} + +function getSenderDomain(sender = "") { + const normalized = String(sender).trim().toLowerCase(); + const at = normalized.lastIndexOf("@"); + return at > 0 && at < normalized.length - 1 + ? normalized.slice(at + 1) + : ""; +} + +export function isBlockedSender(sender, env = {}) { + const normalized = String(sender).trim().toLowerCase(); + const exact = csvSet(env.BLOCKED_SENDERS); + const domains = csvSet(env.BLOCKED_DOMAINS); + return exact.has(normalized) || domains.has(getSenderDomain(normalized)); +} + +function getLimit(env, key, fallback) { + const value = Number(env[key]); + return Number.isFinite(value) && value > 0 + ? Math.floor(value) + : fallback; +} + +function getRoute(localPart, env) { + const bindingName = ROUTES[localPart]; + if (!bindingName) return null; + return env[bindingName] || env.GMAIL_INBOX || null; +} + +export function attachmentProblem(attachments, env = {}) { + const maxCount = getLimit(env, "MAX_ATTACHMENTS", 10); + const maxTotal = getLimit( + env, "MAX_TOTAL_ATTACHMENT_BYTES", 10 * 1024 * 1024 + ); + + if (attachments.length > maxCount) return "attachment_count_limit"; + + let totalBytes = 0; + + for (const attachment of attachments) { + const filename = safeText(attachment.filename || "").toLowerCase(); + const mime = safeText( + attachment.mimeType || attachment.contentType || "" + ).split(";")[0].trim().toLowerCase(); + + const extension = filename.includes(".") + ? filename.split(".").pop() + : ""; + + const content = attachment.content; + const size = Number(content?.byteLength ?? content?.length ?? 0); + totalBytes += Number.isFinite(size) && size > 0 ? size : 0; + + if (BLOCKED_EXTENSIONS.has(extension)) { + return "blocked_attachment_extension"; + } + if (BLOCKED_MIME_TYPES.has(mime)) { + return "blocked_attachment_mime"; + } + } + + if (totalBytes > maxTotal) return "attachment_size_limit"; + return null; +} + +function audit(ctx, env, event) { + const entry = { + timestamp: new Date().toISOString(), + from: safeText(event.from), + to: safeText(event.to), + result: safeText(event.result, 80), + route: safeText(event.route, 80), + reason: safeText(event.reason, 120) + }; + + // Do not log message bodies, subjects, attachment names, or raw MIME data. + console.log(JSON.stringify({ event: "voidone_mail_audit", ...entry })); + + ctx.waitUntil((async () => { + try { + if (env.AUDIT_LOGS) { + const key = `${Date.now()}-${crypto.randomUUID()}`; + await env.AUDIT_LOGS.put(key, JSON.stringify(entry), { + expirationTtl: AUDIT_TTL_SECONDS + }); + } + } catch (error) { + console.error(JSON.stringify({ + event: "audit_storage_failed", + message: safeText(error) + })); + } + + if (!env.SLACK_WEBHOOK_URL) return; + if (env.SLACK_NOTIFY_ALL !== "true" && entry.result === "forwarded") return; + + try { + const response = await fetch(env.SLACK_WEBHOOK_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + text: [ + "VoidOne Mail Gateway", + `Result: ${entry.result}`, + `From: ${entry.from || "(empty)"}`, + `To: ${entry.to || "(unknown)"}`, + `Route: ${entry.route || "(none)"}`, + `Reason: ${entry.reason || "(none)"}`, + `Time: ${entry.timestamp}` + ].join("\n") + }) + }); + + if (!response.ok) { + console.error(JSON.stringify({ + event: "slack_notification_failed", + status: response.status + })); + } + } catch (error) { + console.error(JSON.stringify({ + event: "slack_notification_error", + message: safeText(error) + })); + } + })()); +} + +function reject(message, ctx, env, event, reason) { + message.setReject(reason); + audit(ctx, env, { ...event, result: "rejected", reason }); +} + +function headersFor(localPart, scanStatus) { + const headers = new Headers(); + headers.set("X-VoidOne-Processed-By", "VoidOne-Mail-Gateway"); + headers.set("X-VoidOne-Route", localPart); + headers.set("X-VoidOne-Scan", scanStatus); + headers.set("X-VoidOne-Processed-At", new Date().toISOString()); + return headers; +} + +export default { + async email(message, env, ctx) { + const base = { + from: safeText(message.from), + to: safeText(message.to) + }; + + const localPart = getLocalPart(message.to || ""); + const destination = localPart ? getRoute(localPart, env) : null; + + if (!localPart) { + return reject(message, ctx, env, base, "unknown_recipient"); + } + + if (!destination) { + return reject( + message, ctx, env, + { ...base, route: localPart }, + "destination_not_configured" + ); + } + + if (!message.canBeForwarded) { + return reject( + message, ctx, env, + { ...base, route: localPart }, + "message_cannot_be_forwarded" + ); + } + + const maxMessageBytes = getLimit( + env, "MAX_MESSAGE_BYTES", 15 * 1024 * 1024 + ); + + if (Number(message.rawSize) > maxMessageBytes) { + return reject( + message, ctx, env, + { ...base, route: localPart }, + "message_size_limit" + ); + } + + if (isBlockedSender(message.from || "", env)) { + return reject( + message, ctx, env, + { ...base, route: localPart }, + "blocked_sender" + ); + } + + let scanStatus = "mime-checked"; + + try { + const parsed = await PostalMime.parse(message.raw); + const attachments = Array.isArray(parsed.attachments) + ? parsed.attachments + : []; + const problem = attachmentProblem(attachments, env); + + if (problem) { + return reject( + message, ctx, env, + { ...base, route: localPart }, + problem + ); + } + } catch (error) { + // Availability-first policy: preserve support mail, but mark scan failure. + // Set STRICT_MIME_SCAN=true to reject messages that cannot be parsed. + if (env.STRICT_MIME_SCAN === "true") { + return reject( + message, ctx, env, + { ...base, route: localPart }, + "mime_scan_failed" + ); + } + + scanStatus = "scan-error-forwarded"; + console.error(JSON.stringify({ + event: "mime_scan_failed", + from: base.from, + to: base.to, + message: safeText(error) + })); + } + + try { + await message.forward(destination, headersFor(localPart, scanStatus)); + + audit(ctx, env, { + ...base, + route: localPart, + result: scanStatus === "mime-checked" + ? "forwarded" + : "forwarded_scan_error", + reason: scanStatus === "mime-checked" ? "" : "mime_parser_failed" + }); + } catch (error) { + console.error(JSON.stringify({ + event: "forward_failed", + from: base.from, + to: base.to, + route: localPart, + message: safeText(error) + })); + + if (env.GMAIL_INBOX && destination !== env.GMAIL_INBOX) { + try { + await message.forward( + env.GMAIL_INBOX, + headersFor(localPart, "fallback") + ); + + audit(ctx, env, { + ...base, + route: localPart, + result: "forwarded_fallback", + reason: "primary_destination_failed" + }); + return; + } catch (fallbackError) { + console.error(JSON.stringify({ + event: "fallback_forward_failed", + from: base.from, + to: base.to, + route: localPart, + message: safeText(fallbackError) + })); + } + } + + reject( + message, ctx, env, + { ...base, route: localPart }, + "forward_failed" + ); + } + } +}; diff --git a/email-worker/test/policy.test.js b/email-worker/test/policy.test.js new file mode 100644 index 0000000..bc052ca --- /dev/null +++ b/email-worker/test/policy.test.js @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + attachmentProblem, + csvSet, + getLocalPart, + isBlockedSender +} from "../src/index.js"; + +test("routes only known VoidOne addresses on the exact domain", () => { + assert.equal(getLocalPart("support@voidone.dpdns.org"), "support"); + assert.equal(getLocalPart("SECURITY@voidone.dpdns.org"), "security"); + assert.equal(getLocalPart("admin@voidone.dpdns.org"), null); + assert.equal(getLocalPart("support@sub.voidone.dpdns.org"), null); + assert.equal(getLocalPart("support@example.com"), null); +}); + +test("normalizes comma-separated blocklists", () => { + assert.deepEqual( + [...csvSet(" spam@example.com,EXAMPLE.NET ,, ")], + ["spam@example.com", "example.net"] + ); +}); + +test("blocks exact senders and configured domains", () => { + const env = { + BLOCKED_SENDERS: "spam@example.com", + BLOCKED_DOMAINS: "bad.example" + }; + assert.equal(isBlockedSender("SPAM@example.com", env), true); + assert.equal(isBlockedSender("person@bad.example", env), true); + assert.equal(isBlockedSender("person@good.example", env), false); +}); + +test("rejects executable-style attachment extensions", () => { + assert.equal( + attachmentProblem([{ + filename: "invoice.exe", + mimeType: "application/octet-stream", + content: new Uint8Array([1, 2, 3]) + }]), + "blocked_attachment_extension" + ); +}); + +test("rejects attachment count and total size limits", () => { + assert.equal( + attachmentProblem( + [{ filename: "a.txt" }, { filename: "b.txt" }], + { MAX_ATTACHMENTS: "1" } + ), + "attachment_count_limit" + ); + + assert.equal( + attachmentProblem( + [{ + filename: "large.txt", + content: new Uint8Array(16) + }], + { MAX_TOTAL_ATTACHMENT_BYTES: "10" } + ), + "attachment_size_limit" + ); +}); + +test("allows ordinary small attachments", () => { + assert.equal( + attachmentProblem([{ + filename: "readme.txt", + mimeType: "text/plain", + content: new Uint8Array([1, 2, 3]) + }]), + null + ); +}); diff --git a/email-worker/wrangler.jsonc b/email-worker/wrangler.jsonc new file mode 100644 index 0000000..8fe2dc8 --- /dev/null +++ b/email-worker/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "voidone-email-gateway", + "main": "src/index.js", + "compatibility_date": "2026-10-01", + "keep_vars": true, + "observability": { + "enabled": true, + "head_sampling_rate": 1 + } +}