Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions email-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The workflow can create the Worker via Wrangler, but it cannot configure your pr

After the first successful deployment, open Cloudflare Dashboard → Workers & Pages → `voidone-email-gateway` → Settings → Variables and Secrets. Add:

- `GMAIL_INBOX`: your verified forwarding destination.
- `GMAIL_INBOX`: your verified forwarding destination. This is required unless every alias has its own route-specific inbox configured.
- 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.
Expand All @@ -42,6 +42,12 @@ After the first successful deployment, open Cloudflare Dashboard → Workers & P

Variables are kept across Wrangler deployments. Secrets should be added using Cloudflare's secret manager.

## Troubleshooting

- **Activity log says `destination_not_configured`:** the Worker received the message, but it found no forwarding address. Add `GMAIL_INBOX` with the verified destination address, or configure the matching route-specific variable (for example, `SUPPORT_INBOX` for `support@voidone.dpdns.org`). Save the setting and retry with a new test email.
- **Activity log says `forward_failed`:** a destination was configured, but Cloudflare failed to forward to it. Check that the address is verified in Email Routing and inspect the Worker logs for the underlying error.
- **No Worker event appears:** confirm the Email Routing rule's action is **Send to a Worker** and the selected Worker is `voidone-email-gateway`.

## Optional persistent audit log (KV)

Without KV, structured logs are available through Workers Observability and are subject to Cloudflare's log availability/retention settings.
Expand All @@ -56,7 +62,7 @@ The Worker writes only metadata to KV and sets a 90-day TTL. Restrict access to

## Email Routing setup

1. Deploy the Worker and configure `GMAIL_INBOX` first.
1. Deploy the Worker and configure a forwarding destination 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.
Expand All @@ -73,4 +79,4 @@ 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.
Tests cover routing and policy helpers plus the handler's configured/missing-destination behavior. They do not validate Cloudflare's live email infrastructure. Complete a real end-to-end test only after configuring the Worker and Email Routing.
82 changes: 74 additions & 8 deletions email-worker/test/policy.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
import emailWorker, {
attachmentProblem,
csvSet,
getLocalPart,
Expand Down Expand Up @@ -53,13 +53,10 @@ test("rejects attachment count and total size limits", () => {
);

assert.equal(
attachmentProblem(
[{
filename: "large.txt",
content: new Uint8Array(16)
}],
{ MAX_TOTAL_ATTACHMENT_BYTES: "10" }
),
attachmentProblem([{
filename: "large.txt",
content: new Uint8Array(16)
}], { MAX_TOTAL_ATTACHMENT_BYTES: "10" }),
"attachment_size_limit"
);
});
Expand All @@ -74,3 +71,72 @@ test("allows ordinary small attachments", () => {
null
);
});

function mockMessage(overrides = {}) {
const state = { rejected: null, forwardedTo: null, headers: null };
const message = {
from: "sender@example.net",
to: "support@voidone.dpdns.org",
canBeForwarded: true,
rawSize: 100,
raw: "From: sender@example.net\r\nTo: support@voidone.dpdns.org\r\nSubject: Test\r\n\r\nHello",
setReject(reason) {
state.rejected = reason;
},
async forward(destination, headers) {
state.forwardedTo = destination;
state.headers = headers;
},
...overrides
};
return { message, state };
}

function mockContext() {
const tasks = [];
return {
tasks,
ctx: {
waitUntil(promise) {
tasks.push(promise);
}
}
};
}

test("rejects with destination_not_configured when no inbox is configured", async () => {
const { message, state } = mockMessage();
const { ctx, tasks } = mockContext();

await emailWorker.email(message, {}, ctx);
await Promise.all(tasks);

assert.equal(state.rejected, "destination_not_configured");
assert.equal(state.forwardedTo, null);
});

test("forwards to GMAIL_INBOX when a route-specific inbox is absent", async () => {
const { message, state } = mockMessage();
const { ctx, tasks } = mockContext();

await emailWorker.email(message, { GMAIL_INBOX: "team@example.net" }, ctx);
await Promise.all(tasks);

assert.equal(state.rejected, null);
assert.equal(state.forwardedTo, "team@example.net");
assert.equal(state.headers.get("X-VoidOne-Route"), "support");
});

test("prefers a route-specific inbox over GMAIL_INBOX", async () => {
const { message, state } = mockMessage();
const { ctx, tasks } = mockContext();

await emailWorker.email(message, {
GMAIL_INBOX: "general@example.net",
SUPPORT_INBOX: "support-team@example.net"
}, ctx);
await Promise.all(tasks);

assert.equal(state.rejected, null);
assert.equal(state.forwardedTo, "support-team@example.net");
});
Loading