cloudflare-email 0.2.0 → 0.4.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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +109 -0
  3. data/README.md +44 -9
  4. data/app/controllers/cloudflare/email/ingress_controller.rb +15 -60
  5. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +9 -0
  6. data/app/controllers/cloudflare/email/management/styles_controller.rb +9 -0
  7. data/docs/activerecord-tenanted.md +46 -0
  8. data/docs/architecture.md +13 -3
  9. data/docs/custom-ingress.md +211 -0
  10. data/docs/delivery-events.md +9 -0
  11. data/docs/features.md +36 -5
  12. data/docs/getting-started.md +19 -8
  13. data/docs/mailboxes.md +74 -3
  14. data/docs/management-engine.md +190 -0
  15. data/docs/routing-deliveries.md +94 -0
  16. data/docs/routing-diagnostics.md +75 -0
  17. data/docs/troubleshooting.md +5 -4
  18. data/docs/upgrading-0.2.md +5 -0
  19. data/docs/upgrading-0.3.md +55 -0
  20. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  21. data/docs/verification/2026-09-12-destination-context.md +46 -0
  22. data/docs/verification/2026-09-13-action-mailbox-core.md +96 -0
  23. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  24. data/lib/cloudflare/email/active_record/base.rb +3 -41
  25. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  26. data/lib/cloudflare/email/check_route_task.rb +21 -0
  27. data/lib/cloudflare/email/client.rb +45 -10
  28. data/lib/cloudflare/email/delivery_method.rb +2 -0
  29. data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
  30. data/lib/cloudflare/email/engine.rb +6 -13
  31. data/lib/cloudflare/email/envelope.rb +4 -14
  32. data/lib/cloudflare/email/error.rb +5 -12
  33. data/lib/cloudflare/email/event_consumer.rb +12 -3
  34. data/lib/cloudflare/email/ingress.rb +126 -0
  35. data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
  36. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
  37. data/lib/cloudflare/email/mailboxes/models.rb +8 -130
  38. data/lib/cloudflare/email/mailboxes/service.rb +7 -174
  39. data/lib/cloudflare/email/mailboxes.rb +1 -0
  40. data/lib/cloudflare/email/management/adapter.rb +9 -0
  41. data/lib/cloudflare/email/management/configuration.rb +10 -0
  42. data/lib/cloudflare/email/management/engine.rb +17 -0
  43. data/lib/cloudflare/email/management/routes.rb +14 -0
  44. data/lib/cloudflare/email/management.rb +7 -0
  45. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  46. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  47. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  48. data/lib/cloudflare/email/tenancy.rb +2 -73
  49. data/lib/cloudflare/email/tenant_job_context.rb +2 -85
  50. data/lib/cloudflare/email/verification.rb +17 -9
  51. data/lib/cloudflare/email/version.rb +1 -1
  52. data/lib/cloudflare/email/worker_deployer.rb +36 -5
  53. data/lib/cloudflare-email.rb +6 -0
  54. data/lib/generators/cloudflare/email/install_generator.rb +6 -5
  55. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  56. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  57. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +5 -0
  58. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
  59. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  60. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  61. data/lib/tasks/cloudflare_email.rake +2 -1
  62. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  63. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  64. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  65. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  66. data/templates/deploy-to-cloudflare/README.md +104 -0
  67. data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
  68. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  69. data/templates/deploy-to-cloudflare/package.json +37 -0
  70. data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
  71. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  72. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  73. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  74. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  75. data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
  76. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  77. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  78. data/templates/worker/README.md +186 -15
  79. data/templates/worker/docs/domain-setup.md +198 -0
  80. data/templates/worker/docs/durable-inbound.md +149 -0
  81. data/templates/worker/package-lock.json +2 -2
  82. data/templates/worker/package.json +2 -1
  83. data/templates/worker/scripts/check-subdomains.mjs +54 -0
  84. data/templates/worker/src/index.js +331 -38
  85. data/templates/worker/test/durable.test.ts +193 -0
  86. data/templates/worker/test/index.test.ts +78 -20
  87. data/templates/worker/test/relay.test.ts +226 -0
  88. data/templates/worker/test/subdomains.test.ts +51 -0
  89. data/templates/worker/wrangler.toml +50 -0
  90. metadata +65 -3
@@ -0,0 +1,226 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { archiveEmail, relayEmail, signedEmailHeaders } from "../src/index.js";
3
+
4
+ const raw = new Uint8Array([0, 255, 13, 10, 128]);
5
+ function message(bytes = raw, rawSize = bytes.length) {
6
+ return { from: "sender@example.com", to: "inbox@tenant.example.com", rawSize,
7
+ raw: new ReadableStream({ start(c) { c.enqueue(bytes); c.close(); } }) };
8
+ }
9
+ function options(overrides = {}) {
10
+ return { resolveBackend: async () => ({ url: "https://rails.example.com/ingest", secret: "test-secret" }),
11
+ headers: async ({ raw, from, to, backend }) => signedEmailHeaders({ raw, from, to, secret: backend.secret }),
12
+ ...overrides };
13
+ }
14
+ afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
15
+
16
+ describe("host-controlled relay", () => {
17
+ it("archives once before acceptance and backend lookup, signs and sends exact binary bytes", async () => {
18
+ const order: string[] = [];
19
+ const put = vi.fn(async (key, body, metadata) => {
20
+ order.push("archive");
21
+ expect(key).toBe("mail/one.eml");
22
+ expect(body).toEqual(raw);
23
+ expect(metadata).toEqual({ httpMetadata: { contentType: "message/rfc822" },
24
+ customMetadata: { from: "sender@example.com", to: "inbox@tenant.example.com", size: "5" } });
25
+ });
26
+ const fetch = vi.fn(async (_url, init) => {
27
+ order.push("fetch");
28
+ expect(init.body).toEqual(raw);
29
+ expect(init.headers.get("X-CF-Email-Signature-Version")).toBe("2");
30
+ expect(init.redirect).toBe("manual");
31
+ expect(init.signal).toBeInstanceOf(AbortSignal);
32
+ return new Response(null, { status: 202 });
33
+ });
34
+ vi.stubGlobal("fetch", fetch);
35
+ const result = await relayEmail(message(), options({
36
+ archive: (args) => archiveEmail({ ...args, bucket: { put }, key: "mail/one.eml" }),
37
+ accepts: async () => { order.push("accepts"); return true; },
38
+ resolveBackend: async () => { order.push("backend"); return { url: "https://rails.example.com/ingest", secret: "test-secret" }; },
39
+ headers: async (args) => {
40
+ expect(args.archive).toEqual({ key: "mail/one.eml" });
41
+ order.push("headers");
42
+ return signedEmailHeaders({ ...args, secret: args.backend.secret });
43
+ },
44
+ }));
45
+ expect(order).toEqual(["archive", "accepts", "backend", "headers", "fetch"]);
46
+ expect(result).toEqual({ status: "delivered", reason: "delivered", httpStatus: 202, archiveFailed: false });
47
+ expect(fetch).toHaveBeenCalledOnce();
48
+ expect(put).toHaveBeenCalledOnce();
49
+ });
50
+
51
+ it("continues after archive failure without exposing exception content", async () => {
52
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 204 })));
53
+ const result = await relayEmail(message(), options({ archive: async () => { throw new Error("PRIVATE EMAIL SECRET"); } }));
54
+ expect(result).toEqual({ status: "delivered", reason: "delivered", httpStatus: 204, archiveFailed: true });
55
+ });
56
+
57
+ it("archives rejected recipients without resolving a backend", async () => {
58
+ const archive = vi.fn(async () => ({ key: "retained.eml" }));
59
+ const resolveBackend = vi.fn();
60
+ expect(await relayEmail(message(), options({ archive, accepts: async () => false, resolveBackend })))
61
+ .toEqual({ status: "rejected", reason: "not_accepted", archiveFailed: false });
62
+ expect(archive).toHaveBeenCalledOnce();
63
+ expect(resolveBackend).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it("continues when the archive deadline expires, allowing a late archive completion", async () => {
67
+ vi.useFakeTimers();
68
+ let finish;
69
+ const archive = vi.fn(() => new Promise(resolve => { finish = resolve; }));
70
+ const fetch = vi.fn(async () => new Response(null, { status: 200 }));
71
+ vi.stubGlobal("fetch", fetch);
72
+ const pending = relayEmail(message(), options({ archive, archiveTimeoutMs: 20,
73
+ headers: async ({ archive }) => { expect(archive).toBeUndefined(); return {}; },
74
+ }));
75
+ await vi.advanceTimersByTimeAsync(19);
76
+ expect(fetch).not.toHaveBeenCalled();
77
+ await vi.advanceTimersByTimeAsync(1);
78
+ expect(await pending).toEqual({ status: "delivered", reason: "delivered", httpStatus: 200, archiveFailed: true });
79
+ finish({ key: "late.eml" });
80
+ await Promise.resolve();
81
+ expect(fetch).toHaveBeenCalledOnce();
82
+ expect(archive).toHaveBeenCalledOnce();
83
+ });
84
+
85
+ it("uses the validated URL even when the headers callback changes the backend", async () => {
86
+ const fetch = vi.fn(async () => new Response(null, { status: 200 }));
87
+ vi.stubGlobal("fetch", fetch);
88
+ await relayEmail(message(), options({ headers: async ({ backend }) => {
89
+ backend.url = "http://untrusted.example.com/secret";
90
+ return { Authorization: "Basic private" };
91
+ } }));
92
+ expect(fetch.mock.calls[0][0]).toBe("https://rails.example.com/ingest");
93
+ });
94
+
95
+ it("bounds actual streaming bytes even when rawSize is false", async () => {
96
+ const cancel = vi.fn();
97
+ const stream = new ReadableStream({ start(c) { c.enqueue(raw); }, cancel });
98
+ const archive = vi.fn();
99
+ expect(await relayEmail({ ...message(), raw: stream, rawSize: 1 }, options({ maxEmailBytes: 4, archive })))
100
+ .toEqual({ status: "rejected", reason: "too_large", archiveFailed: false });
101
+ expect(cancel).toHaveBeenCalledOnce();
102
+ expect(archive).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it("rejects declared oversize or invalid envelopes without reading", async () => {
106
+ const getReader = vi.fn();
107
+ expect((await relayEmail({ ...message(), raw: { getReader }, rawSize: 100 }, options({ maxEmailBytes: 50 }))).reason).toBe("too_large");
108
+ expect((await relayEmail({ ...message(), raw: { getReader }, to: "bad\r\naddress" }, options())).reason).toBe("invalid_envelope");
109
+ expect(getReader).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it("reports read failures and invalid chunks without forwarding", async () => {
113
+ const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
114
+ for (const stream of [new ReadableStream({ start(c) { c.error(new Error("secret")); } }),
115
+ new ReadableStream({ start(c) { c.enqueue("bad chunk"); c.close(); } })]) {
116
+ expect(await relayEmail({ ...message(), raw: stream }, options()))
117
+ .toEqual({ status: "failed", reason: "unreadable", archiveFailed: false });
118
+ }
119
+ expect(fetch).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it.each(["http://rails.example.com", "https://user:secret@example.com", "https://example.com/#secret", "ftp://example.com", "invalid"])
123
+ ("rejects unsafe backend %s", async (url) => {
124
+ const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
125
+ expect((await relayEmail(message(), options({ resolveBackend: async () => ({ url }) }))).reason).toBe("invalid_backend");
126
+ expect(fetch).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it("supports a host-selected loopback backend and Basic headers", async () => {
130
+ const fetch = vi.fn(async (url, init) => {
131
+ expect(url).toBe("http://127.0.0.1:3000/ingest");
132
+ expect(init.headers.get("Authorization")).toBe("Basic dGVzdDp0ZXN0");
133
+ expect(init.body).toEqual(raw);
134
+ return new Response(null, { status: 200 });
135
+ });
136
+ vi.stubGlobal("fetch", fetch);
137
+ const result = await relayEmail(message(), options({
138
+ resolveBackend: async ({ to }) => ({ url: to.includes("tenant") ? "http://127.0.0.1:3000/ingest" : "https://other.example.com" }),
139
+ headers: async () => ({ Authorization: "Basic dGVzdDp0ZXN0", "Content-Type": "message/rfc822" }),
140
+ }));
141
+ expect(result.status).toBe("delivered");
142
+ });
143
+
144
+ it("leaves Unicode and legacy address shapes to host acceptance with custom headers", async () => {
145
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 200 })));
146
+ const from = "séndér@example.com";
147
+ const to = '"quoted local"@TENANT.example.com';
148
+ const accepts = vi.fn(async (envelope) => {
149
+ expect(envelope).toEqual({ from, to });
150
+ return true;
151
+ });
152
+ expect((await relayEmail({ ...message(), from, to }, options({ accepts,
153
+ headers: async () => ({ Authorization: "Basic dGVzdDp0ZXN0" }),
154
+ }))).status).toBe("delivered");
155
+ expect(accepts).toHaveBeenCalledOnce();
156
+ });
157
+
158
+ it.each([301, 400, 401, 413, 422, 429, 500, 503])("returns HTTP %i without reading response content or retrying", async (status) => {
159
+ const cancel = vi.fn();
160
+ const response = new Response(new ReadableStream({ cancel }), { status });
161
+ const text = vi.spyOn(response, "text");
162
+ const fetch = vi.fn(async () => response); vi.stubGlobal("fetch", fetch);
163
+ expect(await relayEmail(message(), options())).toEqual({ status: "failed", reason: "http_status", httpStatus: status, archiveFailed: false });
164
+ expect(text).not.toHaveBeenCalled();
165
+ expect(cancel).toHaveBeenCalledOnce();
166
+ expect(fetch).toHaveBeenCalledOnce();
167
+ });
168
+
169
+ it("does not wait for an unresponsive response cancellation", async () => {
170
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(new ReadableStream({ cancel: () => new Promise(() => {}) }), { status: 200 })));
171
+ expect((await relayEmail(message(), options())).status).toBe("delivered");
172
+ });
173
+
174
+ it("aborts fetch on the configured timeout and returns no exception text", async () => {
175
+ vi.useFakeTimers();
176
+ vi.stubGlobal("fetch", vi.fn(async (_url, { signal }) => new Promise((_resolve, reject) => {
177
+ signal.addEventListener("abort", () => reject(new Error("private backend")));
178
+ })));
179
+ const pending = relayEmail(message(), options({ timeoutMs: 25, headers: async () => ({}) }));
180
+ await vi.advanceTimersByTimeAsync(25);
181
+ expect(await pending).toEqual({ status: "failed", reason: "timeout", archiveFailed: false });
182
+ });
183
+
184
+ it.each([
185
+ [{ accepts: async () => { throw new Error("secret"); } }, "acceptance_failed"],
186
+ [{ resolveBackend: async () => { throw new Error("secret"); } }, "backend_failed"],
187
+ [{ resolveBackend: async () => null }, "invalid_backend"],
188
+ [{ headers: async () => { throw new Error("secret"); } }, "headers_failed"],
189
+ [{ headers: async () => ({ "Bad\nHeader": "secret" }) }, "headers_failed"],
190
+ [{ maxEmailBytes: 0 }, "invalid_options"],
191
+ [{ timeoutMs: Infinity }, "invalid_options"],
192
+ [{ timeoutMs: 2 ** 31 }, "invalid_options"],
193
+ [{ archive: true }, "invalid_options"],
194
+ [{ archiveTimeoutMs: 0 }, "invalid_options"],
195
+ [{ archiveTimeoutMs: 2 ** 31 }, "invalid_options"],
196
+ ])("returns safe callback/configuration failure %#", async (overrides, reason) => {
197
+ expect(await relayEmail(message(), options(overrides))).toEqual({ status: "failed", reason, archiveFailed: false });
198
+ });
199
+
200
+ it("returns fetch failures without backend secrets", async () => {
201
+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("secret-url"); }));
202
+ expect(await relayEmail(message(), options())).toEqual({ status: "failed", reason: "fetch_failed", archiveFailed: false });
203
+ });
204
+ });
205
+
206
+ describe("R2 archive helper", () => {
207
+ it("validates archive arguments before invoking the bucket", async () => {
208
+ const put = vi.fn();
209
+ const args = { bucket: { put }, key: "mail/id.eml", raw, from: "", to: "inbox@example.com" };
210
+ for (const overrides of [{ key: "" }, { key: "x".repeat(1025) }, { key: "\u0000" }, { raw: "bad" },
211
+ { from: "sender\r\n@example.com" }, { to: "" }]) {
212
+ await expect(archiveEmail({ ...args, ...overrides })).rejects.toThrow("invalid email archive arguments");
213
+ }
214
+ expect(put).not.toHaveBeenCalled();
215
+ await expect(archiveEmail(args)).resolves.toEqual({ key: "mail/id.eml" });
216
+ expect(put.mock.calls[0][2].customMetadata.from).toBe("");
217
+ });
218
+
219
+ it("stores ASCII-safe envelope metadata while preserving raw Unicode mail bytes", async () => {
220
+ const put = vi.fn();
221
+ await archiveEmail({ bucket: { put }, key: "mail/unicode.eml", raw,
222
+ from: "séndér@example.com", to: "inbox@tenant.example.com" });
223
+ expect(put.mock.calls[0][1]).toEqual(raw);
224
+ expect(put.mock.calls[0][2].customMetadata.from).toBe("s?nd?r@example.com");
225
+ });
226
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { checkSubdomains } from "../scripts/check-subdomains.mjs";
3
+
4
+ function probes() {
5
+ let id = 0;
6
+ return () => `fresh-${++id}`;
7
+ }
8
+ const cloudflare = [{ priority: 10, exchange: "route1.mx.cloudflare.net" }];
9
+
10
+ describe("subdomain setup DNS observations", () => {
11
+ it("checks chosen exact names plus fresh labels without claiming delivery", async () => {
12
+ const lookup = vi.fn(async () => cloudflare);
13
+ const report = await checkSubdomains({ base: "in.example.com", labels: ["acme"] }, lookup, probes());
14
+ expect(lookup.mock.calls.map(call => call[0])).toEqual(["acme.in.example.com", "fresh-1.in.example.com", "fresh-2.in.example.com"]);
15
+ expect(report.dns_observation).toBe("cloudflare_mx_observed_for_all_names");
16
+ expect(report.delivery_verified).toBe(false);
17
+ });
18
+
19
+ it("reports an exact-name exception even when fresh probes resolve", async () => {
20
+ const report = await checkSubdomains({ base: "in.example.com", labels: ["acme"] }, async domain => {
21
+ if (domain.startsWith("acme.")) throw Object.assign(new Error("no answer"), { code: "ENODATA" });
22
+ return cloudflare;
23
+ }, probes());
24
+ expect(report.dns_observation).toBe("needs_attention");
25
+ expect(report.checks[0].status).toBe("unknown");
26
+ });
27
+
28
+ it.each([
29
+ { mx: [] }, { mx: [{ priority: 0, exchange: "." }] },
30
+ { mx: [{ priority: 10, exchange: "mx.example.com" }] },
31
+ { mx: [...cloudflare, { priority: 5, exchange: "mx.other.test" }] }
32
+ ])("does not accept absent, null, foreign or mixed MX: $mx", async ({ mx }) => {
33
+ const report = await checkSubdomains({ base: "in.example.com" }, async () => mx, probes());
34
+ expect(report.dns_observation).toBe("needs_attention");
35
+ });
36
+
37
+ it("preserves DNS timeout uncertainty", async () => {
38
+ const report = await checkSubdomains({ base: "in.example.com" }, async () => { throw Object.assign(new Error("timeout"), { code: "ETIMEOUT" }); }, probes());
39
+ expect(report.checks.every(check => check.status === "unknown")).toBe(true);
40
+ expect(report.delivery_verified).toBe(false);
41
+ });
42
+
43
+ it("rejects malformed domains and nested labels before DNS", async () => {
44
+ const lookup = vi.fn();
45
+ for (const base of ["*.example.com", "https://example.com", "a@b.com", "localhost", "a..com"]) {
46
+ await expect(checkSubdomains({ base }, lookup)).rejects.toThrow();
47
+ }
48
+ await expect(checkSubdomains({ base: "example.com", labels: ["nested.acme"] }, lookup)).rejects.toThrow();
49
+ expect(lookup).not.toHaveBeenCalled();
50
+ });
51
+ });
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["test/**/*.test.ts"],
7
+ },
8
+ });
@@ -0,0 +1,22 @@
1
+ # Generated by script/sync_deploy_template.mjs in cloudflare-email.
2
+ name = "cloudflare-email-ingress"
3
+ main = "src/index.js"
4
+ compatibility_date = "2026-09-10"
5
+
6
+ [observability]
7
+ enabled = true
8
+
9
+ [[r2_buckets]]
10
+ binding = "INBOUND_EMAIL_STORE"
11
+ bucket_name = "cloudflare-email-inbound"
12
+ [[queues.producers]]
13
+ binding = "INBOUND_EMAIL_QUEUE"
14
+ queue = "cloudflare-email-inbound"
15
+ [[queues.consumers]]
16
+ queue = "cloudflare-email-inbound"
17
+ max_batch_size = 5
18
+ max_batch_timeout = 5
19
+ max_retries = 10
20
+ retry_delay = 300
21
+ [triggers]
22
+ crons = ["* * * * *"]
@@ -6,8 +6,21 @@ gem.
6
6
 
7
7
  ## Deploy
8
8
 
9
+ Choose a single receiving domain or dynamic organization subdomains using the
10
+ [domain setup worksheet and cited examples](docs/domain-setup.md). Once your
11
+ receiving namespace is verified, new mailboxes can be created in Rails without
12
+ editing a Worker recipient list. `npm run check:subdomains -- --base in.example.com`
13
+ checks public MX for fresh names; it does not claim live delivery verification.
14
+
15
+ For a guided dashboard deployment that provisions resources and prompts for
16
+ secrets, use the [Deploy to Cloudflare template](../deploy-to-cloudflare/README.md).
17
+ It uses this same Worker with a standalone configuration. The CLI setup below
18
+ is for the explicitly named development/staging/production environments.
19
+
9
20
  ```sh
10
21
  npm ci
22
+ npx wrangler r2 bucket create cloudflare-email-inbound-production
23
+ npx wrangler queues create cloudflare-email-inbound-production
11
24
  npx wrangler secret put INGRESS_SECRET --env production
12
25
  npx wrangler secret put RAILS_INGRESS_URL --env production
13
26
  npm run deploy -- --env production
@@ -46,20 +59,17 @@ does not change DNS or create routing rules.
46
59
 
47
60
  For each inbound message, the Worker:
48
61
 
49
- 1. Reads the raw RFC822 bytes from `message.raw`.
50
- 2. Encodes `{"from": message.from, "to": message.to}` as unpadded base64url JSON,
51
- then computes `HMAC-SHA256(INGRESS_SECRET, "v2.{unix_timestamp}.{encoded_envelope}.{raw_body}")`.
52
- 3. POSTs the raw bytes to `RAILS_INGRESS_URL` with:
53
- - `Content-Type: message/rfc822`
54
- - `X-CF-Email-Timestamp: <unix seconds>`
55
- - `X-CF-Email-Signature: <hex digest>`
56
- - `X-CF-Email-Signature-Version: 2`
57
- - `X-CF-Email-Envelope: <encoded envelope>`
58
- 4. If Rails responds non-2xx, the network fails, or the request exceeds 15 seconds,
59
- the Worker calls `message.setReject`. Redirects are refused to prevent sending
60
- message content and the signature to a different endpoint. The Worker does
61
- not retry delivery automatically. A timeout can occur after Rails accepted
62
- the message, so consumers should handle duplicate deliveries.
62
+ 1. Validates and stores the exact RFC822 bytes and envelope in private R2.
63
+ 2. Queues a small pointer after storage succeeds.
64
+ 3. Signs and POSTs the stored message to Rails; only HTTP 2xx allows deleting it.
65
+ 4. Retries failures through the Queue and a minute-by-minute scheduled recovery
66
+ pass. Even exhausted queue retries leave the original message recoverable.
67
+
68
+ The signed request uses HMAC-SHA256 and authenticates the original SMTP envelope
69
+ alongside the MIME bytes. See [durable delivery setup and recovery](./docs/durable-inbound.md).
70
+ `INBOUND_DELIVERY_MODE=direct` selects an explicit single-attempt fallback that
71
+ calls `message.setReject` on non-2xx, timeout or network failure. Existing pending
72
+ mail keeps draining through Queue and cron while this fallback is selected.
63
73
 
64
74
  The Rails controller verifies the signature in constant time and rejects
65
75
  timestamps outside its 5-minute acceptance window. A signature authenticates the
@@ -67,13 +77,174 @@ Worker request, not the original email sender. Captured requests remain valid
67
77
  inside that window; Rails deduplicates messages through Action Mailbox.
68
78
 
69
79
  Deploy the Rails gem and this Worker together with ingress paused during the
70
- transition. Rails requires v2 signatures and rejects v1/missing versions. It stores authenticated
80
+ transition. Rails accepts v2 and v3 signatures and rejects v1/missing versions. It stores authenticated
71
81
  SMTP metadata separately from MIME; applications read it using
72
82
  `Cloudflare::Email::Envelope.for(inbound_email)`. Duplicate detection includes the
73
83
  exact SMTP recipient, preserving separate To/Cc/Bcc deliveries. The format accepts
74
84
  ASCII dot-atom addresses up to 254 bytes (local part up to 64 bytes), plus an empty
75
85
  sender for bounce messages. Other address forms are rejected before forwarding.
76
86
 
87
+ ## Reuse an existing Worker
88
+
89
+ Keep your existing archiving, sender checks, and fallback policy. Copy this
90
+ template's `src/index.js` into your Worker project and import either helper.
91
+ `forwardEmail(message, env)` uses the same bounded reading, URL checks, timeout,
92
+ and rejection policy as the explicit direct fallback. Optional metadata selects v3 signing:
93
+
94
+ ```js
95
+ import { forwardEmail } from "./cloudflare-ingress.js";
96
+
97
+ export default {
98
+ async email(message, env) {
99
+ // Supplied by your integration after validating its trusted provider context.
100
+ // This is your adapter function, not a Cloudflare EmailMessage API.
101
+ const providerData = await yourValidatedProviderContext(message, env);
102
+ return forwardEmail(message, env, {
103
+ metadata: { source: "your-provider", data: providerData },
104
+ });
105
+ },
106
+ };
107
+ ```
108
+
109
+ The bundled Worker does not collect provider authentication results. Never treat
110
+ `Authentication-Results`, `Received-SPF`, or other sender-supplied MIME headers as
111
+ trusted provider context. Signing metadata authenticates your Worker's assertion;
112
+ it does not prove DMARC passed or authorize the original sender. Your application
113
+ still owns sender policy, organization resolution, review, and document processing.
114
+
115
+ If your Worker already owns transport and error handling, use the header builder:
116
+
117
+ ```js
118
+ import { signedEmailHeaders } from "./cloudflare-ingress.js";
119
+
120
+ const headers = await signedEmailHeaders({
121
+ secret: env.INGRESS_SECRET,
122
+ raw, // Original Uint8Array, obtained using your own bounded reader.
123
+ from: message.from,
124
+ to: message.to,
125
+ metadata: { source: "your-provider", data: providerData },
126
+ });
127
+ // Send these headers with exactly `raw`, using your existing transport.
128
+ ```
129
+
130
+ The builder performs no network calls. You own destination validation, body size
131
+ limits, timeouts, redirects, retries, and response handling. An optional `timestamp`
132
+ in Unix seconds supports deterministic testing; production normally uses the
133
+ default current time. Omitting `metadata` produces unchanged v2 headers. Invalid
134
+ metadata throws from the builder; the forwarding helper rejects instead of
135
+ silently falling back to v2.
136
+
137
+ Metadata must contain exactly `source` and `data`. Source matches
138
+ `[a-z][a-z0-9_.-]{0,127}`; data is a JSON object, with only JSON values and at most
139
+ eight container levels including the outer metadata object. The UTF8 JSON is
140
+ encoded as unpadded base64url, limited to 16,384 encoded characters, and sent as
141
+ `X-CF-Email-Metadata`. Version 3 signs the exact bytes:
142
+
143
+ ```text
144
+ HMAC-SHA256(secret, "v3.{timestamp}.{encoded_envelope}.{encoded_metadata}.{raw_body}")
145
+ ```
146
+
147
+ Neither the MIME nor its headers are rewritten. Upgrade Rails to a version that
148
+ supports v3 before enabling metadata in a custom Worker. Keep sensitive provider
149
+ data out of metadata unless your application's retention and access policies
150
+ permit storing it with email records.
151
+
152
+ ## Reuse the transport in an existing Worker
153
+
154
+ `relayEmail` provides bounded reading, an optional archive, backend selection and
155
+ one HTTP delivery attempt. Your Worker decides how a result maps to SMTP
156
+ rejection, retry, fallback forwarding or logging. It does not call `setReject`
157
+ or `forward` and does not extract or interpret sender authentication evidence.
158
+ `forwardEmail` remains the explicit direct helper; the default export retains mail durably.
159
+
160
+ ```js
161
+ import { relayEmail, archiveEmail, signedEmailHeaders } from "./cloudflare-ingress.js";
162
+
163
+ export default {
164
+ async email(message, env) {
165
+ const outcome = await relayEmail(message, {
166
+ maxEmailBytes: 25 * 1024 * 1024,
167
+ timeoutMs: 15_000,
168
+ archiveTimeoutMs: 10_000,
169
+ // Omit archive when no bucket is configured. Key policy belongs to your app.
170
+ archive: env.EMAIL_ARCHIVE ? (args) => archiveEmail({
171
+ ...args, bucket: env.EMAIL_ARCHIVE,
172
+ key: `email/${crypto.randomUUID()}.eml`,
173
+ }) : undefined,
174
+ accepts: async ({ to }) => to.endsWith("@inbound.example.com"),
175
+ // Select only host-configured backends; never use a URL from an email header.
176
+ resolveBackend: async ({ from, to }) => ({
177
+ url: env.RAILS_INGRESS_URL, secret: env.INGRESS_SECRET,
178
+ }),
179
+ headers: ({ raw, from, to, backend }) => signedEmailHeaders({
180
+ raw, from, to, secret: backend.secret,
181
+ }),
182
+ });
183
+ // Example host policy: permanent rejection only for invalid/unaccepted mail;
184
+ // Throwing surfaces failure; do not assume Cloudflare retries SMTP delivery.
185
+ if (outcome.status === "rejected") message.setReject("email not accepted");
186
+ if (outcome.status === "failed") throw new Error("email relay unavailable");
187
+ },
188
+ };
189
+ ```
190
+
191
+ The relay reads the original stream once, enforcing the actual byte limit even
192
+ when `rawSize` underreports it. It archives before checking `accepts` and before
193
+ resolving the backend, so an archive can retain mail rejected by host policy.
194
+ Invalid envelopes, unreadable streams and oversized messages are not archived.
195
+ An archive exception or timeout does not prevent delivery: the result has
196
+ `archiveFailed: true`. The archive deadline defaults to 10 seconds and can be
197
+ changed with `archiveTimeoutMs`. It stops waiting but cannot cancel an R2 write:
198
+ a timed-out archive may still finish later if the runtime remains alive. Monitor
199
+ that flag if the archive is your outage recovery mechanism. Other callback
200
+ implementations must finish within the Worker's runtime limits; the `timeoutMs`
201
+ option applies to the HTTP request only.
202
+
203
+ `accepts` must return exactly `true` to accept. `resolveBackend` returns an object
204
+ with `url` and any additional host configuration needed by `headers`. Backend
205
+ URLs must use HTTPS, or HTTP on localhost, `127.0.0.1` or `[::1]`; embedded
206
+ credentials and fragments are rejected. Generic relay envelope checks enforce
207
+ bounded strings without control characters, leaving address shape to the host.
208
+ `signedEmailHeaders` retains the gem ingress's stricter ASCII address validation.
209
+
210
+ The `headers` callback receives `{ raw, from, to, backend, archive }`, where
211
+ `archive` is the archive callback's return value, or undefined on omission or
212
+ failure. It can return a header object or `Headers`, including Basic credentials
213
+ for an existing custom endpoint. The relay sends exactly `raw`, follows no
214
+ redirects, cancels response bodies without reading them, and never includes
215
+ backend response content or callback exception text in results. Callbacks are
216
+ trusted host code: preserve the supplied bytes and keep secrets out of logs.
217
+ Do not put an archive key or other per-attempt value into signed provider metadata;
218
+ doing so changes the persisted message identity on retries.
219
+
220
+ Every result has `status`, `reason` and `archiveFailed`. HTTP responses also add
221
+ `httpStatus`:
222
+
223
+ | Status | Reasons |
224
+ | --- | --- |
225
+ | `delivered` | `delivered` (HTTP 2xx) |
226
+ | `rejected` | `invalid_envelope`, `too_large`, `not_accepted` |
227
+ | `failed` | `unreadable`, `invalid_options`, `acceptance_failed`, `backend_failed`, `invalid_backend`, `headers_failed`, `timeout`, `fetch_failed`, `http_status` |
228
+
229
+ All non-2xx responses, including redirects and permanent client errors, return
230
+ `failed`/`http_status`; your host chooses which are permanent, retryable or eligible
231
+ for fallback. The relay never retries on its own.
232
+
233
+ `archiveEmail({ bucket, key, raw, from, to })` writes raw RFC822 bytes to an R2
234
+ binding and returns `{ key }`. Keys must be nonempty, at most 1,024 UTF8 bytes and
235
+ contain no ASCII control characters. It stores `message/rfc822` content type and
236
+ ASCII-safe `from`, `to` and `size` custom metadata; non-ASCII envelope characters
237
+ become `?` in this display metadata only. Retention, unique keys, archive browsing
238
+ and recovery authorization belong to the host. Rails email persistence starts
239
+ after delivery and does not replace this optional outage archive.
240
+
241
+ ## Durable inbound delivery
242
+
243
+ [Durable inbound delivery](./docs/durable-inbound.md) is the default. Provision the
244
+ configured private R2 bucket, Queue producer/consumer and cron before deploying.
245
+ There is no rollout enable flag or automatic downgrade when bindings are missing.
246
+ The guide covers upgrades, monitoring, recovery tests and explicit direct fallback.
247
+
77
248
  ## Validate changes
78
249
 
79
250
  ```sh