From 3268ba48acbc91c9720fe31a8f6b9ca97adcade5 Mon Sep 17 00:00:00 2001 From: mohammadmk47 Date: Wed, 14 Oct 2026 06:52:25 +0330 Subject: [PATCH 1/8] Add VoidOne email gateway package --- email-worker/package.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 email-worker/package.json 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" + } +} From 25ff6703e492f62aaf2708afea0a54a415e7564c Mon Sep 17 00:00:00 2001 From: mohammadmk47 Date: Wed, 14 Oct 2026 06:52:27 +0330 Subject: [PATCH 2/8] Configure Cloudflare Email Worker deployment --- email-worker/wrangler.jsonc | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 email-worker/wrangler.jsonc 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 + } +} From 81d33deb8cf9bbf74c3f513acfd6755141bca7a1 Mon Sep 17 00:00:00 2001 From: mohammadmk47 Date: Wed, 14 Oct 2026 06:52:30 +0330 Subject: [PATCH 3/8] Implement VoidOne email gateway worker --- email-worker/src/index.js | 333 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 email-worker/src/index.js 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" + ); + } + } +}; From 8badef506613109067a662abadb51d81607ef87f Mon Sep 17 00:00:00 2001 From: mohammadmk47 Date: Wed, 14 Oct 2026 06:52:32 +0330 Subject: [PATCH 4/8] Add email gateway policy tests --- email-worker/test/policy.test.js | 76 ++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 email-worker/test/policy.test.js 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 + ); +}); From e7492c1927eb12a06174ba8ffe04aeddaf1176cf Mon Sep 17 00:00:00 2001 From: mohammadmk47 Date: Wed, 14 Oct 2026 06:52:34 +0330 Subject: [PATCH 5/8] Document VoidOne email gateway deployment --- email-worker/README.md | 76 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 email-worker/README.md 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. From ecb8fcb43dbea5b278ed3ce7e074970d3db480cf Mon Sep 17 00:00:00 2001 From: mohammadmk47 Date: Wed, 14 Oct 2026 06:52:36 +0330 Subject: [PATCH 6/8] Add Cloudflare Email Worker deployment workflow --- .github/workflows/deploy-email-worker.yml | 75 +++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/deploy-email-worker.yml diff --git a/.github/workflows/deploy-email-worker.yml b/.github/workflows/deploy-email-worker.yml new file mode 100644 index 0000000..2047698 --- /dev/null +++ b/.github/workflows/deploy-email-worker.yml @@ -0,0 +1,75 @@ +name: Deploy VoidOne Email Worker + +on: + 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" + cache: npm + cache-dependency-path: email-worker/package-lock.json + - name: Install dependencies + run: npm install + - name: Run policy tests + run: npm test + + deploy: + name: Deploy to Cloudflare Workers + needs: test + if: ${{ secrets.CLOUDFLARE_API_TOKEN != '' && secrets.CLOUDFLARE_ACCOUNT_ID != '' }} + 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" + cache: npm + cache-dependency-path: email-worker/package-lock.json + - name: Install dependencies + run: npm install + - name: Deploy Worker (creates it if needed) + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: email-worker + command: deploy --config wrangler.jsonc + + missing-cloudflare-secrets: + name: Explain skipped deployment + needs: test + if: ${{ always() && needs.test.result == 'success' && (secrets.CLOUDFLARE_API_TOKEN == '' || secrets.CLOUDFLARE_ACCOUNT_ID == '') }} + runs-on: ubuntu-latest + steps: + - name: Explain setup + run: | + echo "Tests passed, but Cloudflare deployment was skipped." + echo "Add CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID as repository Actions secrets, then run this workflow again." From 97ab51118471341b09446124dc22e4c6a8459e36 Mon Sep 17 00:00:00 2001 From: mohammadmk47 Date: Wed, 14 Oct 2026 06:52:56 +0330 Subject: [PATCH 7/8] Fix Cloudflare deployment workflow credential gating --- .github/workflows/deploy-email-worker.yml | 29 ++++++++++------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/.github/workflows/deploy-email-worker.yml b/.github/workflows/deploy-email-worker.yml index 2047698..529f847 100644 --- a/.github/workflows/deploy-email-worker.yml +++ b/.github/workflows/deploy-email-worker.yml @@ -29,8 +29,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: "22" - cache: npm - cache-dependency-path: email-worker/package-lock.json - name: Install dependencies run: npm install - name: Run policy tests @@ -39,37 +37,36 @@ jobs: deploy: name: Deploy to Cloudflare Workers needs: test - if: ${{ secrets.CLOUDFLARE_API_TOKEN != '' && secrets.CLOUDFLARE_ACCOUNT_ID != '' }} 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: ${{ 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: ${{ env.CF_API_TOKEN != '' && env.CF_ACCOUNT_ID != '' }} uses: actions/checkout@v4 - name: Set up Node.js + if: ${{ env.CF_API_TOKEN != '' && env.CF_ACCOUNT_ID != '' }} uses: actions/setup-node@v4 with: node-version: "22" - cache: npm - cache-dependency-path: email-worker/package-lock.json - name: Install dependencies + if: ${{ env.CF_API_TOKEN != '' && env.CF_ACCOUNT_ID != '' }} run: npm install - name: Deploy Worker (creates it if needed) + if: ${{ 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 - - missing-cloudflare-secrets: - name: Explain skipped deployment - needs: test - if: ${{ always() && needs.test.result == 'success' && (secrets.CLOUDFLARE_API_TOKEN == '' || secrets.CLOUDFLARE_ACCOUNT_ID == '') }} - runs-on: ubuntu-latest - steps: - - name: Explain setup - run: | - echo "Tests passed, but Cloudflare deployment was skipped." - echo "Add CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID as repository Actions secrets, then run this workflow again." From 9e2cefea558608faccd031857c5db8d0e1761336 Mon Sep 17 00:00:00 2001 From: mohammadmk47 Date: Wed, 14 Oct 2026 06:53:19 +0330 Subject: [PATCH 8/8] Run tests on pull requests without deploying unmerged code --- .github/workflows/deploy-email-worker.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-email-worker.yml b/.github/workflows/deploy-email-worker.yml index 529f847..902b15c 100644 --- a/.github/workflows/deploy-email-worker.yml +++ b/.github/workflows/deploy-email-worker.yml @@ -1,6 +1,10 @@ name: Deploy VoidOne Email Worker on: + pull_request: + paths: + - "email-worker/**" + - ".github/workflows/deploy-email-worker.yml" push: branches: [main] paths: @@ -46,24 +50,24 @@ jobs: working-directory: email-worker steps: - name: Explain skipped deployment - if: ${{ env.CF_API_TOKEN == '' || env.CF_ACCOUNT_ID == '' }} + 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: ${{ env.CF_API_TOKEN != '' && env.CF_ACCOUNT_ID != '' }} + 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: ${{ env.CF_API_TOKEN != '' && env.CF_ACCOUNT_ID != '' }} + 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: ${{ env.CF_API_TOKEN != '' && env.CF_ACCOUNT_ID != '' }} + 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: ${{ env.CF_API_TOKEN != '' && env.CF_ACCOUNT_ID != '' }} + 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 }}