cloudflare-email 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -1
- data/README.md +265 -388
- data/SECURITY.md +51 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +62 -6
- data/docs/activerecord-tenanted.md +135 -0
- data/docs/architecture.md +95 -0
- data/docs/delivery-events.md +177 -0
- data/docs/features.md +107 -0
- data/docs/getting-started.md +298 -0
- data/docs/mailboxes.md +299 -0
- data/docs/outbox.md +175 -0
- data/docs/tenant-mailboxes-plan.md +149 -0
- data/docs/thread-correlation.md +27 -0
- data/docs/troubleshooting.md +103 -0
- data/docs/upgrading-0.2.md +96 -0
- data/docs/verification/2026-09-10-followup.md +49 -0
- data/docs/verification/2026-09-10-install-upgrade.md +86 -0
- data/docs/verification/2026-09-10-live.md +102 -0
- data/docs/verification/2026-09-10.md +175 -0
- data/docs/verification/2026-09-11-extraction.md +32 -0
- data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
- data/docs/verification/2026-09-11-security.md +76 -0
- data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
- data/examples/plain_ruby.rb +24 -0
- data/lib/cloudflare/email/active_record/base.rb +48 -0
- data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
- data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
- data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
- data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
- data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
- data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
- data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
- data/lib/cloudflare/email/active_record/outbox.rb +219 -0
- data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
- data/lib/cloudflare/email/active_record.rb +5 -0
- data/lib/cloudflare/email/client.rb +51 -11
- data/lib/cloudflare/email/consume_events_task.rb +22 -0
- data/lib/cloudflare/email/delivery_event.rb +70 -0
- data/lib/cloudflare/email/delivery_method.rb +3 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +3 -0
- data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
- data/lib/cloudflare/email/dev_tunnel.rb +31 -5
- data/lib/cloudflare/email/doctor.rb +16 -38
- data/lib/cloudflare/email/endpoint.rb +24 -0
- data/lib/cloudflare/email/engine.rb +28 -0
- data/lib/cloudflare/email/envelope.rb +58 -0
- data/lib/cloudflare/email/event_consumer.rb +86 -0
- data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
- data/lib/cloudflare/email/mailboxes/events.rb +112 -0
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
- data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
- data/lib/cloudflare/email/mailboxes/models.rb +148 -0
- data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
- data/lib/cloudflare/email/mailboxes/service.rb +288 -0
- data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
- data/lib/cloudflare/email/mailboxes.rb +11 -0
- data/lib/cloudflare/email/message_id.rb +12 -0
- data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
- data/lib/cloudflare/email/provision_route_task.rb +2 -1
- data/lib/cloudflare/email/replay_events_job.rb +18 -0
- data/lib/cloudflare/email/response.rb +21 -3
- data/lib/cloudflare/email/routing_provisioner.rb +71 -45
- data/lib/cloudflare/email/send_job.rb +41 -0
- data/lib/cloudflare/email/send_test.rb +5 -29
- data/lib/cloudflare/email/signing.rb +2 -2
- data/lib/cloudflare/email/tenancy.rb +78 -0
- data/lib/cloudflare/email/tenant_job_context.rb +90 -0
- data/lib/cloudflare/email/verification.rb +30 -8
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +5 -3
- data/lib/cloudflare-email.rb +4 -1
- data/lib/generators/cloudflare/email/install_generator.rb +48 -33
- data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +54 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
- data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
- data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
- data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
- data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
- data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
- data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
- data/lib/tasks/cloudflare_email.rake +41 -4
- data/templates/worker/README.md +61 -11
- data/templates/worker/package-lock.json +2627 -0
- data/templates/worker/package.json +8 -5
- data/templates/worker/scripts/wrangler.mjs +16 -0
- data/templates/worker/src/index.js +83 -4
- data/templates/worker/test/index.test.ts +117 -5
- data/templates/worker/wrangler.toml +13 -4
- metadata +107 -6
- data/lib/cloudflare/email/secure_message_id.rb +0 -89
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloudflare-email-ingress",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": { "node": "^22.12.0 || ^24.0.0 || >=26.0.0" },
|
|
5
7
|
"description": "Cloudflare Email Worker that forwards inbound mail to a Rails ActionMailbox ingress (HMAC-signed).",
|
|
6
8
|
"scripts": {
|
|
7
|
-
"deploy": "wrangler deploy",
|
|
8
|
-
"dev": "wrangler dev",
|
|
9
|
+
"deploy": "node scripts/wrangler.mjs deploy",
|
|
10
|
+
"dev": "node scripts/wrangler.mjs dev",
|
|
11
|
+
"check": "wrangler deploy --env development --dry-run",
|
|
9
12
|
"test": "vitest run"
|
|
10
13
|
},
|
|
11
14
|
"devDependencies": {
|
|
12
|
-
"vitest": "^
|
|
13
|
-
"wrangler": "^4.
|
|
15
|
+
"vitest": "^5.0.0",
|
|
16
|
+
"wrangler": "^4.131.0"
|
|
14
17
|
}
|
|
15
18
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
const [command, ...args] = process.argv.slice(2);
|
|
5
|
+
const envIndex = args.indexOf("--env");
|
|
6
|
+
const environment = args[envIndex + 1];
|
|
7
|
+
if (envIndex < 0 || !["development", "staging", "production"].includes(environment)) {
|
|
8
|
+
console.error("Specify --env development, --env staging, or --env production.");
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
const result = spawnSync(process.execPath, [
|
|
12
|
+
fileURLToPath(new URL("../node_modules/wrangler/bin/wrangler.js", import.meta.url)),
|
|
13
|
+
command, ...args,
|
|
14
|
+
], { stdio: "inherit" });
|
|
15
|
+
if (result.error) console.error(result.error.message);
|
|
16
|
+
process.exit(result.status ?? 1);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Cloudflare Email Worker → Rails ActionMailbox ingress.
|
|
3
3
|
*
|
|
4
4
|
* Receives mail via Cloudflare Email Routing, signs the raw RFC822 with
|
|
5
|
-
* HMAC-SHA256 over "{timestamp}.{raw_body}", and POSTs it to the Rails
|
|
5
|
+
* HMAC-SHA256 over "v2.{timestamp}.{encoded_envelope}.{raw_body}", and POSTs it to the Rails
|
|
6
6
|
* ingress controller shipped with the cloudflare-email gem.
|
|
7
7
|
*
|
|
8
8
|
* Required environment variables (set via `wrangler secret put` OR the
|
|
@@ -33,6 +33,48 @@ async function sign(secret, data) {
|
|
|
33
33
|
return toHex(sig);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
function validAddress(address, allowEmpty = false) {
|
|
37
|
+
if (typeof address !== "string" || address.length > 254 || /[^\x21-\x7E]/.test(address)) return false;
|
|
38
|
+
if (allowEmpty && address === "") return true;
|
|
39
|
+
const parts = address.split("@");
|
|
40
|
+
if (parts.length !== 2) return false;
|
|
41
|
+
const [local, domain] = parts;
|
|
42
|
+
return local.length <= 64 && /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(local) &&
|
|
43
|
+
!local.startsWith(".") && !local.endsWith(".") && !local.includes("..") &&
|
|
44
|
+
domain.split(".").every(label => /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(label));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validIngressUrl(value) {
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(value);
|
|
50
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
51
|
+
return !url.username && !url.password && !url.hash &&
|
|
52
|
+
(url.protocol === "https:" || (url.protocol === "http:" && loopback));
|
|
53
|
+
} catch { return false; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function readBounded(stream, limit) {
|
|
57
|
+
const reader = stream.getReader();
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let size = 0;
|
|
60
|
+
try {
|
|
61
|
+
while (true) {
|
|
62
|
+
const { done, value } = await reader.read();
|
|
63
|
+
if (done) break;
|
|
64
|
+
size += value.byteLength;
|
|
65
|
+
if (size > limit) {
|
|
66
|
+
await reader.cancel();
|
|
67
|
+
throw new Error("message exceeds size limit");
|
|
68
|
+
}
|
|
69
|
+
chunks.push(value);
|
|
70
|
+
}
|
|
71
|
+
} finally { reader.releaseLock(); }
|
|
72
|
+
const raw = new Uint8Array(size);
|
|
73
|
+
let offset = 0;
|
|
74
|
+
for (const chunk of chunks) { raw.set(chunk, offset); offset += chunk.byteLength; }
|
|
75
|
+
return raw;
|
|
76
|
+
}
|
|
77
|
+
|
|
36
78
|
export default {
|
|
37
79
|
async email(message, env) {
|
|
38
80
|
if (!env.RAILS_INGRESS_URL || !env.INGRESS_SECRET) {
|
|
@@ -40,10 +82,38 @@ export default {
|
|
|
40
82
|
return;
|
|
41
83
|
}
|
|
42
84
|
|
|
43
|
-
|
|
85
|
+
if (!validIngressUrl(env.RAILS_INGRESS_URL)) {
|
|
86
|
+
message.setReject("worker requires an HTTPS ingress URL (HTTP allowed only for loopback)");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const limit = env.MAX_EMAIL_BYTES === undefined ? 25 * 1024 * 1024 : Number(env.MAX_EMAIL_BYTES);
|
|
90
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
91
|
+
message.setReject("worker MAX_EMAIL_BYTES must be a positive integer");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (message.rawSize > limit) {
|
|
95
|
+
message.setReject("message exceeds size limit");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!validAddress(message.from, true) || !validAddress(message.to)) {
|
|
100
|
+
message.setReject("worker received invalid SMTP envelope");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Routing metadata comes from the SMTP envelope, never from MIME headers.
|
|
104
|
+
const envelope = btoa(JSON.stringify({ from: message.from, to: message.to }))
|
|
105
|
+
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
106
|
+
|
|
107
|
+
let raw;
|
|
108
|
+
try {
|
|
109
|
+
raw = await readBounded(message.raw, limit);
|
|
110
|
+
} catch {
|
|
111
|
+
message.setReject("message exceeds size limit or could not be read");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
44
114
|
const ts = Math.floor(Date.now() / 1000).toString();
|
|
45
115
|
|
|
46
|
-
const tsBytes = new TextEncoder().encode(
|
|
116
|
+
const tsBytes = new TextEncoder().encode(`v2.${ts}.${envelope}.`);
|
|
47
117
|
const signedPayload = new Uint8Array(tsBytes.length + raw.length);
|
|
48
118
|
signedPayload.set(tsBytes, 0);
|
|
49
119
|
signedPayload.set(raw, tsBytes.length);
|
|
@@ -51,6 +121,8 @@ export default {
|
|
|
51
121
|
const signature = await sign(env.INGRESS_SECRET, signedPayload);
|
|
52
122
|
|
|
53
123
|
let res;
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
54
126
|
try {
|
|
55
127
|
res = await fetch(env.RAILS_INGRESS_URL, {
|
|
56
128
|
method: "POST",
|
|
@@ -58,12 +130,19 @@ export default {
|
|
|
58
130
|
"Content-Type": "message/rfc822",
|
|
59
131
|
"X-CF-Email-Timestamp": ts,
|
|
60
132
|
"X-CF-Email-Signature": signature,
|
|
133
|
+
"X-CF-Email-Signature-Version": "2",
|
|
134
|
+
"X-CF-Email-Envelope": envelope,
|
|
61
135
|
},
|
|
62
136
|
body: raw,
|
|
137
|
+
signal: controller.signal,
|
|
138
|
+
// Workers supports follow/manual; non-2xx handling below rejects redirects.
|
|
139
|
+
redirect: "manual",
|
|
63
140
|
});
|
|
64
141
|
} catch (err) {
|
|
65
|
-
message.setReject(
|
|
142
|
+
message.setReject(controller.signal.aborted ? "upstream fetch timed out" : "upstream fetch failed");
|
|
66
143
|
return;
|
|
144
|
+
} finally {
|
|
145
|
+
clearTimeout(timeout);
|
|
67
146
|
}
|
|
68
147
|
|
|
69
148
|
if (!res.ok) {
|
|
@@ -19,9 +19,9 @@ function makeMessage(raw: string) {
|
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
async function verifyHmac(secret: string, ts: string, body: ArrayBuffer, hex: string) {
|
|
22
|
+
async function verifyHmac(secret: string, ts: string, envelope: string, body: ArrayBuffer, hex: string) {
|
|
23
23
|
const enc = new TextEncoder();
|
|
24
|
-
const prefix = enc.encode(
|
|
24
|
+
const prefix = enc.encode(`v2.${ts}.${envelope}.`);
|
|
25
25
|
const signed = new Uint8Array(prefix.length + body.byteLength);
|
|
26
26
|
signed.set(prefix, 0);
|
|
27
27
|
signed.set(new Uint8Array(body), prefix.length);
|
|
@@ -51,11 +51,13 @@ describe("cloudflare-email Worker", () => {
|
|
|
51
51
|
|
|
52
52
|
beforeEach(() => {
|
|
53
53
|
fetchSpy = vi.fn(async () => new Response("", { status: 200 }));
|
|
54
|
-
|
|
54
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
afterEach(() => {
|
|
58
58
|
vi.restoreAllMocks();
|
|
59
|
+
vi.unstubAllGlobals();
|
|
60
|
+
vi.useRealTimers();
|
|
59
61
|
});
|
|
60
62
|
|
|
61
63
|
it("POSTs raw MIME with HMAC signature and timestamp", async () => {
|
|
@@ -71,6 +73,11 @@ describe("cloudflare-email Worker", () => {
|
|
|
71
73
|
expect(opts.headers["Content-Type"]).toBe("message/rfc822");
|
|
72
74
|
const ts = opts.headers["X-CF-Email-Timestamp"];
|
|
73
75
|
const sig = opts.headers["X-CF-Email-Signature"];
|
|
76
|
+
const envelope = opts.headers["X-CF-Email-Envelope"];
|
|
77
|
+
expect(opts.headers["X-CF-Email-Signature-Version"]).toBe("2");
|
|
78
|
+
expect(JSON.parse(atob(envelope.replace(/-/g, "+").replace(/_/g, "/")))).toEqual({
|
|
79
|
+
from: "sender@external.test", to: "inbox@trial.test",
|
|
80
|
+
});
|
|
74
81
|
expect(ts).toMatch(/^\d+$/);
|
|
75
82
|
expect(sig).toMatch(/^[0-9a-f]{64}$/);
|
|
76
83
|
|
|
@@ -79,10 +86,50 @@ describe("cloudflare-email Worker", () => {
|
|
|
79
86
|
expect(new TextDecoder().decode(sentBytes)).toBe(RAW);
|
|
80
87
|
|
|
81
88
|
// And the signature verifies against the input.
|
|
82
|
-
const ok = await verifyHmac(SECRET, ts, sentBytes.buffer, sig);
|
|
89
|
+
const ok = await verifyHmac(SECRET, ts, envelope, sentBytes.buffer, sig);
|
|
83
90
|
expect(ok).toBe(true);
|
|
84
91
|
});
|
|
85
92
|
|
|
93
|
+
it.each(["http://rails.test/inbound", "https://user:password@rails.test/inbound", "https://rails.test/inbound#fragment", "invalid"])(
|
|
94
|
+
"rejects invalid ingress URL %j before reading mail", async (url) => {
|
|
95
|
+
const { message, rejects } = makeMessage(RAW);
|
|
96
|
+
await worker.email(message as any, { RAILS_INGRESS_URL: url, INGRESS_SECRET: SECRET });
|
|
97
|
+
expect(rejects).toHaveLength(1);
|
|
98
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
99
|
+
expect(message.raw.locked).toBe(false);
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
it("allows loopback HTTP for local verification", async () => {
|
|
104
|
+
const { message, rejects } = makeMessage(RAW);
|
|
105
|
+
await worker.email(message as any, { RAILS_INGRESS_URL: "http://127.0.0.1:3000/inbound", INGRESS_SECRET: SECRET });
|
|
106
|
+
expect(rejects).toEqual([]);
|
|
107
|
+
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("accepts mail at the configured size boundary", async () => {
|
|
111
|
+
const { message, rejects } = makeMessage(RAW);
|
|
112
|
+
await worker.email(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET, MAX_EMAIL_BYTES: String(message.rawSize) });
|
|
113
|
+
expect(rejects).toEqual([]);
|
|
114
|
+
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it.each([true, false])("enforces size limit with accurate rawSize=%j", async (accurate) => {
|
|
118
|
+
const { message, rejects } = makeMessage(RAW);
|
|
119
|
+
if (!accurate) message.rawSize = 0;
|
|
120
|
+
await worker.email(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET, MAX_EMAIL_BYTES: "16" });
|
|
121
|
+
expect(rejects).toHaveLength(1);
|
|
122
|
+
expect(rejects[0]).toMatch(/size limit/);
|
|
123
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("rejects invalid size configuration", async () => {
|
|
127
|
+
const { message, rejects } = makeMessage(RAW);
|
|
128
|
+
await worker.email(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET, MAX_EMAIL_BYTES: "0" });
|
|
129
|
+
expect(rejects).toHaveLength(1);
|
|
130
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
86
133
|
it("rejects the message when upstream returns non-2xx", async () => {
|
|
87
134
|
fetchSpy.mockResolvedValueOnce(new Response("server error", { status: 503 }));
|
|
88
135
|
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
|
|
@@ -93,6 +140,39 @@ describe("cloudflare-email Worker", () => {
|
|
|
93
140
|
expect(rejects).toEqual(["upstream returned 503"]);
|
|
94
141
|
});
|
|
95
142
|
|
|
143
|
+
it("authenticates SMTP recipients independently of sender-controlled To headers", async () => {
|
|
144
|
+
vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000);
|
|
145
|
+
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
|
|
146
|
+
await worker.email(makeMessage(RAW).message as any, env);
|
|
147
|
+
const other = makeMessage(RAW);
|
|
148
|
+
other.message.to = "bcc@trial.test";
|
|
149
|
+
await worker.email(other.message as any, env);
|
|
150
|
+
const first = fetchSpy.mock.calls[0][1];
|
|
151
|
+
const second = fetchSpy.mock.calls[1][1];
|
|
152
|
+
expect(second.body).toEqual(first.body);
|
|
153
|
+
expect(second.headers["X-CF-Email-Signature"]).not.toBe(first.headers["X-CF-Email-Signature"]);
|
|
154
|
+
expect(await verifyHmac(SECRET, first.headers["X-CF-Email-Timestamp"], second.headers["X-CF-Email-Envelope"],
|
|
155
|
+
first.body.buffer, first.headers["X-CF-Email-Signature"])).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("allows the empty SMTP reverse path used by bounce messages", async () => {
|
|
159
|
+
const { message, rejects } = makeMessage(RAW);
|
|
160
|
+
message.from = "";
|
|
161
|
+
await worker.email(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
|
|
162
|
+
expect(rejects).toEqual([]);
|
|
163
|
+
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it.each(["", "Name <recipient@test.example>", "bad\r\n@test.example", "bad\n@test.example", "x@test.example\n", "x@-bad.example", "x@bad..example", "a".repeat(65) + "@example.com"])(
|
|
167
|
+
"rejects invalid SMTP recipient %j before posting", async (address) => {
|
|
168
|
+
const { message, rejects } = makeMessage(RAW);
|
|
169
|
+
message.to = address;
|
|
170
|
+
await worker.email(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
|
|
171
|
+
expect(rejects).toEqual(["worker received invalid SMTP envelope"]);
|
|
172
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
|
|
96
176
|
it("rejects the message when RAILS_INGRESS_URL is missing", async () => {
|
|
97
177
|
const env = { RAILS_INGRESS_URL: "", INGRESS_SECRET: SECRET };
|
|
98
178
|
const { message, rejects } = makeMessage(RAW);
|
|
@@ -120,7 +200,39 @@ describe("cloudflare-email Worker", () => {
|
|
|
120
200
|
|
|
121
201
|
await worker.email(message as any, env);
|
|
122
202
|
|
|
123
|
-
expect(rejects
|
|
203
|
+
expect(rejects).toEqual(["upstream fetch failed"]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("aborts a stalled ingress request after 15 seconds", async () => {
|
|
207
|
+
vi.useFakeTimers();
|
|
208
|
+
fetchSpy.mockImplementationOnce((_url, options) => new Promise((_resolve, reject) => {
|
|
209
|
+
options.signal.addEventListener("abort", () => reject(new Error("aborted")));
|
|
210
|
+
}));
|
|
211
|
+
const { message, rejects } = makeMessage(RAW);
|
|
212
|
+
const delivery = worker.email(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
|
|
213
|
+
// Signing uses async Web Crypto, so wait until fetch starts before advancing timers.
|
|
214
|
+
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledOnce());
|
|
215
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
216
|
+
await delivery;
|
|
217
|
+
expect(fetchSpy.mock.calls[0][1].signal.aborted).toBe(true);
|
|
218
|
+
expect(rejects).toEqual(["upstream fetch timed out"]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("refuses redirects and clears the timeout after delivery", async () => {
|
|
222
|
+
vi.useFakeTimers();
|
|
223
|
+
await worker.email(makeMessage(RAW).message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
|
|
224
|
+
const options = fetchSpy.mock.calls[0][1];
|
|
225
|
+
expect(options.redirect).toBe("manual");
|
|
226
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
227
|
+
expect(options.signal.aborted).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("rejects an ingress redirect response", async () => {
|
|
231
|
+
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 302, headers: { Location: "https://other.test" } }));
|
|
232
|
+
const { message, rejects } = makeMessage(RAW);
|
|
233
|
+
await worker.email(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
|
|
234
|
+
expect(rejects).toEqual(["upstream returned 302"]);
|
|
235
|
+
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
124
236
|
});
|
|
125
237
|
|
|
126
238
|
it("signature covers tampered bodies differently", async () => {
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
name = "cloudflare-email-ingress"
|
|
1
|
+
name = "cloudflare-email-ingress-unconfigured"
|
|
2
2
|
main = "src/index.js"
|
|
3
|
-
compatibility_date = "2026-
|
|
3
|
+
compatibility_date = "2026-09-10"
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[env.development]
|
|
6
|
+
name = "cloudflare-email-ingress-development"
|
|
7
|
+
|
|
8
|
+
[env.staging]
|
|
9
|
+
name = "cloudflare-email-ingress-staging"
|
|
10
|
+
|
|
11
|
+
[env.production]
|
|
12
|
+
name = "cloudflare-email-ingress-production"
|
|
13
|
+
|
|
14
|
+
# Required secrets (set via `npx wrangler secret put NAME --env production`):
|
|
6
15
|
# RAILS_INGRESS_URL - https://your-rails-app.example.com/rails/action_mailbox/cloudflare/inbound_emails
|
|
7
16
|
# INGRESS_SECRET - shared secret matching cloudflare.ingress_secret in Rails credentials
|
|
8
17
|
|
|
9
18
|
# After deploying, in the Cloudflare dashboard:
|
|
10
|
-
# Email Routing -> Routes -> "Send to a Worker" -> select
|
|
19
|
+
# Email Routing -> Routes -> "Send to a Worker" -> select the matching environment Worker
|
metadata
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cloudflare-email
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cole
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-09-11 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.2'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '1.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '0.2'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '1.0'
|
|
12
32
|
- !ruby/object:Gem::Dependency
|
|
13
33
|
name: minitest
|
|
14
34
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -57,7 +77,7 @@ dependencies:
|
|
|
57
77
|
requirements:
|
|
58
78
|
- - ">="
|
|
59
79
|
- !ruby/object:Gem::Version
|
|
60
|
-
version:
|
|
80
|
+
version: 7.2.3.2
|
|
61
81
|
- - "<"
|
|
62
82
|
- !ruby/object:Gem::Version
|
|
63
83
|
version: '9.0'
|
|
@@ -67,7 +87,7 @@ dependencies:
|
|
|
67
87
|
requirements:
|
|
68
88
|
- - ">="
|
|
69
89
|
- !ruby/object:Gem::Version
|
|
70
|
-
version:
|
|
90
|
+
version: 7.2.3.2
|
|
71
91
|
- - "<"
|
|
72
92
|
- !ruby/object:Gem::Version
|
|
73
93
|
version: '9.0'
|
|
@@ -85,6 +105,26 @@ dependencies:
|
|
|
85
105
|
- - "~>"
|
|
86
106
|
- !ruby/object:Gem::Version
|
|
87
107
|
version: '2.1'
|
|
108
|
+
- !ruby/object:Gem::Dependency
|
|
109
|
+
name: json
|
|
110
|
+
requirement: !ruby/object:Gem::Requirement
|
|
111
|
+
requirements:
|
|
112
|
+
- - ">="
|
|
113
|
+
- !ruby/object:Gem::Version
|
|
114
|
+
version: '2.0'
|
|
115
|
+
- - "<"
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: '3.0'
|
|
118
|
+
type: :development
|
|
119
|
+
prerelease: false
|
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - ">="
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: '2.0'
|
|
125
|
+
- - "<"
|
|
126
|
+
- !ruby/object:Gem::Version
|
|
127
|
+
version: '3.0'
|
|
88
128
|
description: A Ruby client and Rails integration for Cloudflare's Email Service. Send
|
|
89
129
|
mail via an ActionMailer delivery method, receive mail via an ActionMailbox ingress
|
|
90
130
|
backed by a shipped Cloudflare Email Worker template.
|
|
@@ -97,33 +137,94 @@ files:
|
|
|
97
137
|
- CHANGELOG.md
|
|
98
138
|
- LICENSE.txt
|
|
99
139
|
- README.md
|
|
140
|
+
- SECURITY.md
|
|
100
141
|
- app/controllers/cloudflare/email/ingress_controller.rb
|
|
142
|
+
- docs/activerecord-tenanted.md
|
|
143
|
+
- docs/architecture.md
|
|
144
|
+
- docs/delivery-events.md
|
|
145
|
+
- docs/features.md
|
|
146
|
+
- docs/getting-started.md
|
|
147
|
+
- docs/mailboxes.md
|
|
148
|
+
- docs/outbox.md
|
|
149
|
+
- docs/tenant-mailboxes-plan.md
|
|
150
|
+
- docs/thread-correlation.md
|
|
151
|
+
- docs/troubleshooting.md
|
|
152
|
+
- docs/upgrading-0.2.md
|
|
153
|
+
- docs/verification/2026-09-10-followup.md
|
|
154
|
+
- docs/verification/2026-09-10-install-upgrade.md
|
|
155
|
+
- docs/verification/2026-09-10-live.md
|
|
156
|
+
- docs/verification/2026-09-10.md
|
|
157
|
+
- docs/verification/2026-09-11-extraction.md
|
|
158
|
+
- docs/verification/2026-09-11-outbound-ledger.md
|
|
159
|
+
- docs/verification/2026-09-11-security.md
|
|
160
|
+
- docs/verification/2026-09-11-tenant-mailboxes.md
|
|
161
|
+
- examples/plain_ruby.rb
|
|
101
162
|
- lib/cloudflare-email.rb
|
|
163
|
+
- lib/cloudflare/email/active_record.rb
|
|
164
|
+
- lib/cloudflare/email/active_record/base.rb
|
|
165
|
+
- lib/cloudflare/email/active_record/delivery_events.rb
|
|
166
|
+
- lib/cloudflare/email/active_record/event_inbox.rb
|
|
167
|
+
- lib/cloudflare/email/active_record/event_receipt.rb
|
|
168
|
+
- lib/cloudflare/email/active_record/mail_snapshot.rb
|
|
169
|
+
- lib/cloudflare/email/active_record/outbound_delivery.rb
|
|
170
|
+
- lib/cloudflare/email/active_record/outbound_recipient.rb
|
|
171
|
+
- lib/cloudflare/email/active_record/outbound_reconciliation.rb
|
|
172
|
+
- lib/cloudflare/email/active_record/outbox.rb
|
|
173
|
+
- lib/cloudflare/email/active_record/outbox_notifications.rb
|
|
102
174
|
- lib/cloudflare/email/client.rb
|
|
175
|
+
- lib/cloudflare/email/consume_events_task.rb
|
|
103
176
|
- lib/cloudflare/email/credentials.rb
|
|
177
|
+
- lib/cloudflare/email/delivery_event.rb
|
|
104
178
|
- lib/cloudflare/email/delivery_method.rb
|
|
105
179
|
- lib/cloudflare/email/deploy_worker_task.rb
|
|
180
|
+
- lib/cloudflare/email/dev_ingress_guard.rb
|
|
106
181
|
- lib/cloudflare/email/dev_tunnel.rb
|
|
107
182
|
- lib/cloudflare/email/doctor.rb
|
|
183
|
+
- lib/cloudflare/email/endpoint.rb
|
|
108
184
|
- lib/cloudflare/email/engine.rb
|
|
185
|
+
- lib/cloudflare/email/envelope.rb
|
|
109
186
|
- lib/cloudflare/email/error.rb
|
|
187
|
+
- lib/cloudflare/email/event_consumer.rb
|
|
188
|
+
- lib/cloudflare/email/mailboxes.rb
|
|
189
|
+
- lib/cloudflare/email/mailboxes/configuration.rb
|
|
190
|
+
- lib/cloudflare/email/mailboxes/events.rb
|
|
191
|
+
- lib/cloudflare/email/mailboxes/inbound_retention.rb
|
|
192
|
+
- lib/cloudflare/email/mailboxes/jobs.rb
|
|
193
|
+
- lib/cloudflare/email/mailboxes/models.rb
|
|
194
|
+
- lib/cloudflare/email/mailboxes/provider_correlation.rb
|
|
195
|
+
- lib/cloudflare/email/mailboxes/service.rb
|
|
196
|
+
- lib/cloudflare/email/mailboxes/shared_event_receipt.rb
|
|
197
|
+
- lib/cloudflare/email/message_id.rb
|
|
110
198
|
- lib/cloudflare/email/provision_catchall_task.rb
|
|
111
199
|
- lib/cloudflare/email/provision_route_task.rb
|
|
200
|
+
- lib/cloudflare/email/replay_events_job.rb
|
|
112
201
|
- lib/cloudflare/email/response.rb
|
|
113
202
|
- lib/cloudflare/email/routing_provisioner.rb
|
|
114
|
-
- lib/cloudflare/email/
|
|
203
|
+
- lib/cloudflare/email/send_job.rb
|
|
115
204
|
- lib/cloudflare/email/send_test.rb
|
|
116
205
|
- lib/cloudflare/email/signing.rb
|
|
117
206
|
- lib/cloudflare/email/task_base.rb
|
|
207
|
+
- lib/cloudflare/email/tenancy.rb
|
|
208
|
+
- lib/cloudflare/email/tenant_job_context.rb
|
|
118
209
|
- lib/cloudflare/email/verification.rb
|
|
119
210
|
- lib/cloudflare/email/version.rb
|
|
120
211
|
- lib/cloudflare/email/worker_deployer.rb
|
|
121
212
|
- lib/generators/cloudflare/email/install_generator.rb
|
|
213
|
+
- lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb
|
|
214
|
+
- lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb
|
|
215
|
+
- lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb
|
|
216
|
+
- lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb
|
|
217
|
+
- lib/generators/cloudflare/email/outbox/outbox_generator.rb
|
|
218
|
+
- lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb
|
|
122
219
|
- lib/generators/cloudflare/email/templates/initializer.rb
|
|
123
220
|
- lib/generators/cloudflare/email/templates/main_mailbox.rb
|
|
221
|
+
- lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb
|
|
222
|
+
- lib/generators/cloudflare/email/tracking/tracking_generator.rb
|
|
124
223
|
- lib/tasks/cloudflare_email.rake
|
|
125
224
|
- templates/worker/README.md
|
|
225
|
+
- templates/worker/package-lock.json
|
|
126
226
|
- templates/worker/package.json
|
|
227
|
+
- templates/worker/scripts/wrangler.mjs
|
|
127
228
|
- templates/worker/src/index.js
|
|
128
229
|
- templates/worker/test/index.test.ts
|
|
129
230
|
- templates/worker/vitest.config.ts
|
|
@@ -142,7 +243,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
142
243
|
requirements:
|
|
143
244
|
- - ">="
|
|
144
245
|
- !ruby/object:Gem::Version
|
|
145
|
-
version: '3.
|
|
246
|
+
version: '3.2'
|
|
146
247
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
147
248
|
requirements:
|
|
148
249
|
- - ">="
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
require "json"
|
|
2
|
-
require "cloudflare/email/signing"
|
|
3
|
-
|
|
4
|
-
module Cloudflare
|
|
5
|
-
module Email
|
|
6
|
-
# Signed outbound Message-IDs for reply authentication.
|
|
7
|
-
#
|
|
8
|
-
# Sign the outbound Message-ID with HMAC-SHA256. The recipient's reply
|
|
9
|
-
# naturally carries the signed id in `In-Reply-To:`, which your mailbox
|
|
10
|
-
# verifies and decodes to recover the original thread state.
|
|
11
|
-
#
|
|
12
|
-
# See README's "Signed replies" section for a full usage example.
|
|
13
|
-
module SecureMessageId
|
|
14
|
-
InvalidToken = Class.new(Cloudflare::Email::Error)
|
|
15
|
-
|
|
16
|
-
DEFAULT_PREFIX = "msg".freeze
|
|
17
|
-
DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 # 30 days
|
|
18
|
-
EPOCH_OFFSET = Time.utc(2026, 1, 1).to_i.freeze
|
|
19
|
-
|
|
20
|
-
class << self
|
|
21
|
-
# Build a signed Message-ID carrying `payload`. Returns the bare
|
|
22
|
-
# Message-ID without angle brackets — SMTP/Mail adds them.
|
|
23
|
-
def encode(payload:, domain:, secret:, prefix: DEFAULT_PREFIX, now: Time.now.to_i)
|
|
24
|
-
raise ArgumentError, "secret must not be empty" if secret.to_s.empty?
|
|
25
|
-
raise ArgumentError, "domain must not be empty" if domain.to_s.empty?
|
|
26
|
-
|
|
27
|
-
iat_offset = now.to_i - EPOCH_OFFSET
|
|
28
|
-
raise ArgumentError, "timestamp out of 32-bit range" if iat_offset.negative? || iat_offset >= (1 << 32)
|
|
29
|
-
|
|
30
|
-
packed = [iat_offset].pack("N") + JSON.generate(payload)
|
|
31
|
-
b64 = Signing.base64url_encode(packed)
|
|
32
|
-
mac = Signing.hmac_hex(secret, b64)
|
|
33
|
-
|
|
34
|
-
"#{prefix}.#{b64}.#{mac}@#{domain}"
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Decode a Message-ID produced by encode. Accepts `<bracketed>` form
|
|
38
|
-
# too. Returns parsed payload or raises InvalidToken.
|
|
39
|
-
def decode(message_id, secret:, max_age: DEFAULT_MAX_AGE, now: Time.now.to_i)
|
|
40
|
-
raise InvalidToken, "message-id is empty" if message_id.to_s.empty?
|
|
41
|
-
|
|
42
|
-
id = strip_brackets(message_id.to_s).strip
|
|
43
|
-
local, domain = id.split("@", 2)
|
|
44
|
-
raise InvalidToken, "missing @ in message-id" unless local && domain
|
|
45
|
-
|
|
46
|
-
_prefix, b64, mac = local.split(".", 3)
|
|
47
|
-
raise InvalidToken, "malformed message-id" unless b64 && mac && !b64.empty? && !mac.empty?
|
|
48
|
-
|
|
49
|
-
expected = Signing.hmac_hex(secret, b64)
|
|
50
|
-
raise InvalidToken, "signature mismatch" unless Signing.secure_compare(expected, mac)
|
|
51
|
-
|
|
52
|
-
packed = begin
|
|
53
|
-
Signing.base64url_decode(b64)
|
|
54
|
-
rescue StandardError
|
|
55
|
-
raise InvalidToken, "base64 decode failed"
|
|
56
|
-
end
|
|
57
|
-
raise InvalidToken, "truncated packed bytes" if packed.bytesize < 4
|
|
58
|
-
|
|
59
|
-
iat_offset = packed.byteslice(0, 4).unpack1("N")
|
|
60
|
-
iat = iat_offset + EPOCH_OFFSET
|
|
61
|
-
payload_json = packed.byteslice(4..)
|
|
62
|
-
|
|
63
|
-
raise InvalidToken, "token expired" if now - iat > max_age
|
|
64
|
-
raise InvalidToken, "token timestamp in the future" if iat - now > 5 * 60
|
|
65
|
-
|
|
66
|
-
JSON.parse(payload_json.to_s)
|
|
67
|
-
rescue JSON::ParserError
|
|
68
|
-
raise InvalidToken, "payload not valid JSON"
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Cheap heuristic — does this look like one of our signed Message-IDs?
|
|
72
|
-
def match?(message_id, prefix: DEFAULT_PREFIX)
|
|
73
|
-
id = strip_brackets(message_id.to_s).strip
|
|
74
|
-
local, domain = id.split("@", 2)
|
|
75
|
-
return false unless local && domain
|
|
76
|
-
p, b64, mac = local.split(".", 3)
|
|
77
|
-
return false unless p && b64 && mac
|
|
78
|
-
p == prefix && !b64.empty? && mac.match?(/\A[0-9a-f]{64}\z/)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
private
|
|
82
|
-
|
|
83
|
-
def strip_brackets(s)
|
|
84
|
-
s.start_with?("<") && s.end_with?(">") ? s[1..-2] : s
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
end
|