cloudflare-email 0.1.0 → 0.3.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 (139) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +287 -385
  4. data/SECURITY.md +51 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +33 -21
  6. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
  7. data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
  8. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
  9. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
  10. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
  11. data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
  12. data/docs/activerecord-tenanted.md +181 -0
  13. data/docs/architecture.md +105 -0
  14. data/docs/custom-ingress.md +207 -0
  15. data/docs/delivery-events.md +186 -0
  16. data/docs/features.md +138 -0
  17. data/docs/getting-started.md +308 -0
  18. data/docs/mailboxes.md +363 -0
  19. data/docs/management-engine.md +190 -0
  20. data/docs/outbox.md +175 -0
  21. data/docs/routing-deliveries.md +94 -0
  22. data/docs/routing-diagnostics.md +68 -0
  23. data/docs/tenant-mailboxes-plan.md +149 -0
  24. data/docs/thread-correlation.md +27 -0
  25. data/docs/troubleshooting.md +104 -0
  26. data/docs/upgrading-0.2.md +101 -0
  27. data/docs/upgrading-0.3.md +55 -0
  28. data/docs/verification/2026-09-10-followup.md +49 -0
  29. data/docs/verification/2026-09-10-install-upgrade.md +86 -0
  30. data/docs/verification/2026-09-10-live.md +102 -0
  31. data/docs/verification/2026-09-10.md +175 -0
  32. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  33. data/docs/verification/2026-09-11-extraction.md +32 -0
  34. data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
  35. data/docs/verification/2026-09-11-security.md +76 -0
  36. data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
  37. data/docs/verification/2026-09-12-destination-context.md +46 -0
  38. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  39. data/examples/plain_ruby.rb +24 -0
  40. data/lib/cloudflare/email/active_record/base.rb +60 -0
  41. data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
  42. data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
  43. data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
  44. data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
  45. data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
  46. data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
  47. data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
  48. data/lib/cloudflare/email/active_record/outbox.rb +219 -0
  49. data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
  50. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  51. data/lib/cloudflare/email/active_record.rb +5 -0
  52. data/lib/cloudflare/email/check_route_task.rb +21 -0
  53. data/lib/cloudflare/email/client.rb +93 -18
  54. data/lib/cloudflare/email/consume_events_task.rb +22 -0
  55. data/lib/cloudflare/email/delivery_event.rb +70 -0
  56. data/lib/cloudflare/email/delivery_method.rb +5 -0
  57. data/lib/cloudflare/email/deploy_worker_task.rb +6 -3
  58. data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
  59. data/lib/cloudflare/email/dev_tunnel.rb +31 -5
  60. data/lib/cloudflare/email/doctor.rb +16 -38
  61. data/lib/cloudflare/email/endpoint.rb +24 -0
  62. data/lib/cloudflare/email/engine.rb +33 -0
  63. data/lib/cloudflare/email/envelope.rb +58 -0
  64. data/lib/cloudflare/email/event_consumer.rb +95 -0
  65. data/lib/cloudflare/email/ingress.rb +115 -0
  66. data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
  67. data/lib/cloudflare/email/mailboxes/events.rb +112 -0
  68. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
  69. data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
  70. data/lib/cloudflare/email/mailboxes/models.rb +165 -0
  71. data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
  72. data/lib/cloudflare/email/mailboxes/service.rb +372 -0
  73. data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
  74. data/lib/cloudflare/email/mailboxes.rb +11 -0
  75. data/lib/cloudflare/email/management/adapter.rb +31 -0
  76. data/lib/cloudflare/email/management/configuration.rb +17 -0
  77. data/lib/cloudflare/email/management/engine.rb +15 -0
  78. data/lib/cloudflare/email/management/management.css +68 -0
  79. data/lib/cloudflare/email/management/routes.rb +14 -0
  80. data/lib/cloudflare/email/management.rb +7 -0
  81. data/lib/cloudflare/email/message_id.rb +12 -0
  82. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  83. data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
  84. data/lib/cloudflare/email/provision_route_task.rb +2 -1
  85. data/lib/cloudflare/email/replay_events_job.rb +18 -0
  86. data/lib/cloudflare/email/response.rb +21 -3
  87. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  88. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  89. data/lib/cloudflare/email/routing_provisioner.rb +71 -45
  90. data/lib/cloudflare/email/send_job.rb +41 -0
  91. data/lib/cloudflare/email/send_test.rb +5 -29
  92. data/lib/cloudflare/email/signing.rb +2 -2
  93. data/lib/cloudflare/email/tenancy.rb +78 -0
  94. data/lib/cloudflare/email/tenant_job_context.rb +90 -0
  95. data/lib/cloudflare/email/verification.rb +38 -8
  96. data/lib/cloudflare/email/version.rb +1 -1
  97. data/lib/cloudflare/email/worker_deployer.rb +41 -8
  98. data/lib/cloudflare-email.rb +4 -1
  99. data/lib/generators/cloudflare/email/install_generator.rb +53 -37
  100. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  101. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  102. data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
  103. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +58 -0
  104. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
  105. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
  106. data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
  107. data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
  108. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  109. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  110. data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
  111. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
  112. data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
  113. data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
  114. data/lib/tasks/cloudflare_email.rake +42 -4
  115. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  116. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  117. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  118. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  119. data/templates/deploy-to-cloudflare/README.md +84 -0
  120. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  121. data/templates/deploy-to-cloudflare/package.json +36 -0
  122. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  123. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  124. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  125. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  126. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  127. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  128. data/templates/worker/README.md +231 -16
  129. data/templates/worker/docs/durable-inbound.md +149 -0
  130. data/templates/worker/package-lock.json +2627 -0
  131. data/templates/worker/package.json +8 -5
  132. data/templates/worker/scripts/wrangler.mjs +16 -0
  133. data/templates/worker/src/index.js +397 -25
  134. data/templates/worker/test/durable.test.ts +193 -0
  135. data/templates/worker/test/index.test.ts +183 -13
  136. data/templates/worker/test/relay.test.ts +226 -0
  137. data/templates/worker/wrangler.toml +63 -4
  138. metadata +154 -7
  139. data/lib/cloudflare/email/secure_message_id.rb +0 -89
@@ -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
+ });
@@ -1,10 +1,69 @@
1
- name = "cloudflare-email-ingress"
1
+ name = "cloudflare-email-ingress-unconfigured"
2
2
  main = "src/index.js"
3
- compatibility_date = "2026-04-01"
3
+ compatibility_date = "2026-09-10"
4
4
 
5
- # Required secrets (set via `wrangler secret put`):
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 cloudflare-email-ingress
19
+ # Email Routing -> Routes -> "Send to a Worker" -> select the matching environment Worker
20
+
21
+ # Durable delivery is the default. Create these private resources before deploy.
22
+ # See docs/durable-inbound.md. Buckets must not expire pending objects.
23
+ # Explicit emergency fallback: set INBOUND_DELIVERY_MODE = "direct" in the
24
+ # chosen environment's [env.<name>.vars]. Keep recovery bindings and cron.
25
+
26
+ [[env.development.r2_buckets]]
27
+ binding = "INBOUND_EMAIL_STORE"
28
+ bucket_name = "cloudflare-email-inbound-development"
29
+ [[env.development.queues.producers]]
30
+ binding = "INBOUND_EMAIL_QUEUE"
31
+ queue = "cloudflare-email-inbound-development"
32
+ [[env.development.queues.consumers]]
33
+ queue = "cloudflare-email-inbound-development"
34
+ max_batch_size = 5
35
+ max_batch_timeout = 5
36
+ max_retries = 10
37
+ retry_delay = 300
38
+ [env.development.triggers]
39
+ crons = ["* * * * *"]
40
+
41
+ [[env.staging.r2_buckets]]
42
+ binding = "INBOUND_EMAIL_STORE"
43
+ bucket_name = "cloudflare-email-inbound-staging"
44
+ [[env.staging.queues.producers]]
45
+ binding = "INBOUND_EMAIL_QUEUE"
46
+ queue = "cloudflare-email-inbound-staging"
47
+ [[env.staging.queues.consumers]]
48
+ queue = "cloudflare-email-inbound-staging"
49
+ max_batch_size = 5
50
+ max_batch_timeout = 5
51
+ max_retries = 10
52
+ retry_delay = 300
53
+ [env.staging.triggers]
54
+ crons = ["* * * * *"]
55
+
56
+ [[env.production.r2_buckets]]
57
+ binding = "INBOUND_EMAIL_STORE"
58
+ bucket_name = "cloudflare-email-inbound-production"
59
+ [[env.production.queues.producers]]
60
+ binding = "INBOUND_EMAIL_QUEUE"
61
+ queue = "cloudflare-email-inbound-production"
62
+ [[env.production.queues.consumers]]
63
+ queue = "cloudflare-email-inbound-production"
64
+ max_batch_size = 5
65
+ max_batch_timeout = 5
66
+ max_retries = 10
67
+ retry_delay = 300
68
+ [env.production.triggers]
69
+ crons = ["* * * * *"]
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cole
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-04-18 00:00:00.000000000 Z
10
+ date: 1980-01-02 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: '7.1'
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: '7.1'
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,35 +137,142 @@ 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
+ - app/controllers/cloudflare/email/management/mailboxes_controller.rb
143
+ - app/controllers/cloudflare/email/management/styles_controller.rb
144
+ - app/views/cloudflare/email/management/mailboxes/index.html.erb
145
+ - app/views/cloudflare/email/management/mailboxes/message.html.erb
146
+ - app/views/cloudflare/email/management/mailboxes/show.html.erb
147
+ - app/views/layouts/cloudflare/email/management.html.erb
148
+ - docs/activerecord-tenanted.md
149
+ - docs/architecture.md
150
+ - docs/custom-ingress.md
151
+ - docs/delivery-events.md
152
+ - docs/features.md
153
+ - docs/getting-started.md
154
+ - docs/mailboxes.md
155
+ - docs/management-engine.md
156
+ - docs/outbox.md
157
+ - docs/routing-deliveries.md
158
+ - docs/routing-diagnostics.md
159
+ - docs/tenant-mailboxes-plan.md
160
+ - docs/thread-correlation.md
161
+ - docs/troubleshooting.md
162
+ - docs/upgrading-0.2.md
163
+ - docs/upgrading-0.3.md
164
+ - docs/verification/2026-09-10-followup.md
165
+ - docs/verification/2026-09-10-install-upgrade.md
166
+ - docs/verification/2026-09-10-live.md
167
+ - docs/verification/2026-09-10.md
168
+ - docs/verification/2026-09-11-custom-ingress.md
169
+ - docs/verification/2026-09-11-extraction.md
170
+ - docs/verification/2026-09-11-outbound-ledger.md
171
+ - docs/verification/2026-09-11-security.md
172
+ - docs/verification/2026-09-11-tenant-mailboxes.md
173
+ - docs/verification/2026-09-12-destination-context.md
174
+ - docs/verification/2026-09-13-durable-inbound-and-routing.md
175
+ - examples/plain_ruby.rb
101
176
  - lib/cloudflare-email.rb
177
+ - lib/cloudflare/email/active_record.rb
178
+ - lib/cloudflare/email/active_record/base.rb
179
+ - lib/cloudflare/email/active_record/delivery_events.rb
180
+ - lib/cloudflare/email/active_record/event_inbox.rb
181
+ - lib/cloudflare/email/active_record/event_receipt.rb
182
+ - lib/cloudflare/email/active_record/mail_snapshot.rb
183
+ - lib/cloudflare/email/active_record/outbound_delivery.rb
184
+ - lib/cloudflare/email/active_record/outbound_recipient.rb
185
+ - lib/cloudflare/email/active_record/outbound_reconciliation.rb
186
+ - lib/cloudflare/email/active_record/outbox.rb
187
+ - lib/cloudflare/email/active_record/outbox_notifications.rb
188
+ - lib/cloudflare/email/active_record/routing_deliveries.rb
189
+ - lib/cloudflare/email/check_route_task.rb
102
190
  - lib/cloudflare/email/client.rb
191
+ - lib/cloudflare/email/consume_events_task.rb
103
192
  - lib/cloudflare/email/credentials.rb
193
+ - lib/cloudflare/email/delivery_event.rb
104
194
  - lib/cloudflare/email/delivery_method.rb
105
195
  - lib/cloudflare/email/deploy_worker_task.rb
196
+ - lib/cloudflare/email/dev_ingress_guard.rb
106
197
  - lib/cloudflare/email/dev_tunnel.rb
107
198
  - lib/cloudflare/email/doctor.rb
199
+ - lib/cloudflare/email/endpoint.rb
108
200
  - lib/cloudflare/email/engine.rb
201
+ - lib/cloudflare/email/envelope.rb
109
202
  - lib/cloudflare/email/error.rb
203
+ - lib/cloudflare/email/event_consumer.rb
204
+ - lib/cloudflare/email/ingress.rb
205
+ - lib/cloudflare/email/mailboxes.rb
206
+ - lib/cloudflare/email/mailboxes/configuration.rb
207
+ - lib/cloudflare/email/mailboxes/events.rb
208
+ - lib/cloudflare/email/mailboxes/inbound_retention.rb
209
+ - lib/cloudflare/email/mailboxes/jobs.rb
210
+ - lib/cloudflare/email/mailboxes/models.rb
211
+ - lib/cloudflare/email/mailboxes/provider_correlation.rb
212
+ - lib/cloudflare/email/mailboxes/service.rb
213
+ - lib/cloudflare/email/mailboxes/shared_event_receipt.rb
214
+ - lib/cloudflare/email/management.rb
215
+ - lib/cloudflare/email/management/adapter.rb
216
+ - lib/cloudflare/email/management/configuration.rb
217
+ - lib/cloudflare/email/management/engine.rb
218
+ - lib/cloudflare/email/management/management.css
219
+ - lib/cloudflare/email/management/routes.rb
220
+ - lib/cloudflare/email/message_id.rb
221
+ - lib/cloudflare/email/provider_metadata.rb
110
222
  - lib/cloudflare/email/provision_catchall_task.rb
111
223
  - lib/cloudflare/email/provision_route_task.rb
224
+ - lib/cloudflare/email/replay_events_job.rb
112
225
  - lib/cloudflare/email/response.rb
226
+ - lib/cloudflare/email/routing_analytics.rb
227
+ - lib/cloudflare/email/routing_diagnostics.rb
113
228
  - lib/cloudflare/email/routing_provisioner.rb
114
- - lib/cloudflare/email/secure_message_id.rb
229
+ - lib/cloudflare/email/send_job.rb
115
230
  - lib/cloudflare/email/send_test.rb
116
231
  - lib/cloudflare/email/signing.rb
117
232
  - lib/cloudflare/email/task_base.rb
233
+ - lib/cloudflare/email/tenancy.rb
234
+ - lib/cloudflare/email/tenant_job_context.rb
118
235
  - lib/cloudflare/email/verification.rb
119
236
  - lib/cloudflare/email/version.rb
120
237
  - lib/cloudflare/email/worker_deployer.rb
121
238
  - lib/generators/cloudflare/email/install_generator.rb
239
+ - lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb
240
+ - lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb
241
+ - lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb
242
+ - lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb
243
+ - lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb
244
+ - lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb
245
+ - lib/generators/cloudflare/email/outbox/outbox_generator.rb
246
+ - lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb
247
+ - lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb
248
+ - lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb
122
249
  - lib/generators/cloudflare/email/templates/initializer.rb
123
250
  - lib/generators/cloudflare/email/templates/main_mailbox.rb
251
+ - lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb
252
+ - lib/generators/cloudflare/email/tracking/tracking_generator.rb
124
253
  - lib/tasks/cloudflare_email.rake
254
+ - lib/tasks/cloudflare_email_diagnostics.rake
255
+ - templates/deploy-to-cloudflare/.dev.vars.example
256
+ - templates/deploy-to-cloudflare/.gitignore
257
+ - templates/deploy-to-cloudflare/LICENSE.txt
258
+ - templates/deploy-to-cloudflare/README.md
259
+ - templates/deploy-to-cloudflare/package-lock.json
260
+ - templates/deploy-to-cloudflare/package.json
261
+ - templates/deploy-to-cloudflare/src/index.js
262
+ - templates/deploy-to-cloudflare/test/durable.test.ts
263
+ - templates/deploy-to-cloudflare/test/index.test.ts
264
+ - templates/deploy-to-cloudflare/test/relay.test.ts
265
+ - templates/deploy-to-cloudflare/vitest.config.ts
266
+ - templates/deploy-to-cloudflare/wrangler.toml
125
267
  - templates/worker/README.md
268
+ - templates/worker/docs/durable-inbound.md
269
+ - templates/worker/package-lock.json
126
270
  - templates/worker/package.json
271
+ - templates/worker/scripts/wrangler.mjs
127
272
  - templates/worker/src/index.js
273
+ - templates/worker/test/durable.test.ts
128
274
  - templates/worker/test/index.test.ts
275
+ - templates/worker/test/relay.test.ts
129
276
  - templates/worker/vitest.config.ts
130
277
  - templates/worker/wrangler.toml
131
278
  homepage: https://github.com/cole-robertson/cloudflare-email
@@ -142,14 +289,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
142
289
  requirements:
143
290
  - - ">="
144
291
  - !ruby/object:Gem::Version
145
- version: '3.1'
292
+ version: '3.2'
146
293
  required_rubygems_version: !ruby/object:Gem::Requirement
147
294
  requirements:
148
295
  - - ">="
149
296
  - !ruby/object:Gem::Version
150
297
  version: '0'
151
298
  requirements: []
152
- rubygems_version: 3.6.2
299
+ rubygems_version: 3.6.9
153
300
  specification_version: 4
154
301
  summary: Cloudflare Email Service client for Ruby and Rails (ActionMailer + ActionMailbox).
155
302
  test_files: []
@@ -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