cloudflare-email 0.2.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/README.md +34 -9
  4. data/app/controllers/cloudflare/email/ingress_controller.rb +16 -60
  5. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
  6. data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
  7. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
  8. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
  9. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
  10. data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
  11. data/docs/activerecord-tenanted.md +46 -0
  12. data/docs/architecture.md +13 -3
  13. data/docs/custom-ingress.md +207 -0
  14. data/docs/delivery-events.md +9 -0
  15. data/docs/features.md +36 -5
  16. data/docs/getting-started.md +18 -8
  17. data/docs/mailboxes.md +67 -3
  18. data/docs/management-engine.md +190 -0
  19. data/docs/routing-deliveries.md +94 -0
  20. data/docs/routing-diagnostics.md +68 -0
  21. data/docs/troubleshooting.md +4 -3
  22. data/docs/upgrading-0.2.md +5 -0
  23. data/docs/upgrading-0.3.md +55 -0
  24. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  25. data/docs/verification/2026-09-12-destination-context.md +46 -0
  26. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  27. data/lib/cloudflare/email/active_record/base.rb +13 -1
  28. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  29. data/lib/cloudflare/email/check_route_task.rb +21 -0
  30. data/lib/cloudflare/email/client.rb +45 -10
  31. data/lib/cloudflare/email/delivery_method.rb +2 -0
  32. data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
  33. data/lib/cloudflare/email/engine.rb +5 -0
  34. data/lib/cloudflare/email/envelope.rb +2 -2
  35. data/lib/cloudflare/email/event_consumer.rb +12 -3
  36. data/lib/cloudflare/email/ingress.rb +115 -0
  37. data/lib/cloudflare/email/mailboxes/models.rb +19 -2
  38. data/lib/cloudflare/email/mailboxes/service.rb +99 -15
  39. data/lib/cloudflare/email/management/adapter.rb +31 -0
  40. data/lib/cloudflare/email/management/configuration.rb +17 -0
  41. data/lib/cloudflare/email/management/engine.rb +15 -0
  42. data/lib/cloudflare/email/management/management.css +68 -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/verification.rb +17 -9
  49. data/lib/cloudflare/email/version.rb +1 -1
  50. data/lib/cloudflare/email/worker_deployer.rb +36 -5
  51. data/lib/generators/cloudflare/email/install_generator.rb +6 -5
  52. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  53. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  54. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +4 -0
  55. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  56. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  57. data/lib/tasks/cloudflare_email.rake +2 -1
  58. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  59. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  60. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  61. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  62. data/templates/deploy-to-cloudflare/README.md +84 -0
  63. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  64. data/templates/deploy-to-cloudflare/package.json +36 -0
  65. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  66. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  67. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  68. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  69. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  70. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  71. data/templates/worker/README.md +180 -15
  72. data/templates/worker/docs/durable-inbound.md +149 -0
  73. data/templates/worker/package-lock.json +2 -2
  74. data/templates/worker/package.json +1 -1
  75. data/templates/worker/src/index.js +331 -38
  76. data/templates/worker/test/durable.test.ts +193 -0
  77. data/templates/worker/test/index.test.ts +78 -20
  78. data/templates/worker/test/relay.test.ts +226 -0
  79. data/templates/worker/wrangler.toml +50 -0
  80. metadata +49 -3
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import worker from "../src/index.js";
2
+ import { forwardEmail, signedEmailHeaders } from "../src/index.js";
3
3
 
4
4
  // Minimal fake EmailMessage implementing the surface the Worker uses.
5
5
  function makeMessage(raw: string) {
@@ -64,7 +64,7 @@ describe("cloudflare-email Worker", () => {
64
64
  const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
65
65
  const { message } = makeMessage(RAW);
66
66
 
67
- await worker.email(message as any, env);
67
+ await forwardEmail(message as any, env);
68
68
 
69
69
  expect(fetchSpy).toHaveBeenCalledOnce();
70
70
  const [url, opts] = fetchSpy.mock.calls[0];
@@ -75,6 +75,7 @@ describe("cloudflare-email Worker", () => {
75
75
  const sig = opts.headers["X-CF-Email-Signature"];
76
76
  const envelope = opts.headers["X-CF-Email-Envelope"];
77
77
  expect(opts.headers["X-CF-Email-Signature-Version"]).toBe("2");
78
+ expect(opts.headers["X-CF-Email-Metadata"]).toBeUndefined();
78
79
  expect(JSON.parse(atob(envelope.replace(/-/g, "+").replace(/_/g, "/")))).toEqual({
79
80
  from: "sender@external.test", to: "inbox@trial.test",
80
81
  });
@@ -93,7 +94,7 @@ describe("cloudflare-email Worker", () => {
93
94
  it.each(["http://rails.test/inbound", "https://user:password@rails.test/inbound", "https://rails.test/inbound#fragment", "invalid"])(
94
95
  "rejects invalid ingress URL %j before reading mail", async (url) => {
95
96
  const { message, rejects } = makeMessage(RAW);
96
- await worker.email(message as any, { RAILS_INGRESS_URL: url, INGRESS_SECRET: SECRET });
97
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: url, INGRESS_SECRET: SECRET });
97
98
  expect(rejects).toHaveLength(1);
98
99
  expect(fetchSpy).not.toHaveBeenCalled();
99
100
  expect(message.raw.locked).toBe(false);
@@ -102,14 +103,14 @@ describe("cloudflare-email Worker", () => {
102
103
 
103
104
  it("allows loopback HTTP for local verification", async () => {
104
105
  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
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: "http://127.0.0.1:3000/inbound", INGRESS_SECRET: SECRET });
106
107
  expect(rejects).toEqual([]);
107
108
  expect(fetchSpy).toHaveBeenCalledOnce();
108
109
  });
109
110
 
110
111
  it("accepts mail at the configured size boundary", async () => {
111
112
  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
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET, MAX_EMAIL_BYTES: String(message.rawSize) });
113
114
  expect(rejects).toEqual([]);
114
115
  expect(fetchSpy).toHaveBeenCalledOnce();
115
116
  });
@@ -117,7 +118,7 @@ describe("cloudflare-email Worker", () => {
117
118
  it.each([true, false])("enforces size limit with accurate rawSize=%j", async (accurate) => {
118
119
  const { message, rejects } = makeMessage(RAW);
119
120
  if (!accurate) message.rawSize = 0;
120
- await worker.email(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET, MAX_EMAIL_BYTES: "16" });
121
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET, MAX_EMAIL_BYTES: "16" });
121
122
  expect(rejects).toHaveLength(1);
122
123
  expect(rejects[0]).toMatch(/size limit/);
123
124
  expect(fetchSpy).not.toHaveBeenCalled();
@@ -125,7 +126,7 @@ describe("cloudflare-email Worker", () => {
125
126
 
126
127
  it("rejects invalid size configuration", async () => {
127
128
  const { message, rejects } = makeMessage(RAW);
128
- await worker.email(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET, MAX_EMAIL_BYTES: "0" });
129
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET, MAX_EMAIL_BYTES: "0" });
129
130
  expect(rejects).toHaveLength(1);
130
131
  expect(fetchSpy).not.toHaveBeenCalled();
131
132
  });
@@ -135,7 +136,7 @@ describe("cloudflare-email Worker", () => {
135
136
  const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
136
137
  const { message, rejects } = makeMessage(RAW);
137
138
 
138
- await worker.email(message as any, env);
139
+ await forwardEmail(message as any, env);
139
140
 
140
141
  expect(rejects).toEqual(["upstream returned 503"]);
141
142
  });
@@ -143,10 +144,10 @@ describe("cloudflare-email Worker", () => {
143
144
  it("authenticates SMTP recipients independently of sender-controlled To headers", async () => {
144
145
  vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000);
145
146
  const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
146
- await worker.email(makeMessage(RAW).message as any, env);
147
+ await forwardEmail(makeMessage(RAW).message as any, env);
147
148
  const other = makeMessage(RAW);
148
149
  other.message.to = "bcc@trial.test";
149
- await worker.email(other.message as any, env);
150
+ await forwardEmail(other.message as any, env);
150
151
  const first = fetchSpy.mock.calls[0][1];
151
152
  const second = fetchSpy.mock.calls[1][1];
152
153
  expect(second.body).toEqual(first.body);
@@ -158,7 +159,7 @@ describe("cloudflare-email Worker", () => {
158
159
  it("allows the empty SMTP reverse path used by bounce messages", async () => {
159
160
  const { message, rejects } = makeMessage(RAW);
160
161
  message.from = "";
161
- await worker.email(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
162
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
162
163
  expect(rejects).toEqual([]);
163
164
  expect(fetchSpy).toHaveBeenCalledOnce();
164
165
  });
@@ -167,7 +168,7 @@ describe("cloudflare-email Worker", () => {
167
168
  "rejects invalid SMTP recipient %j before posting", async (address) => {
168
169
  const { message, rejects } = makeMessage(RAW);
169
170
  message.to = address;
170
- await worker.email(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
171
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
171
172
  expect(rejects).toEqual(["worker received invalid SMTP envelope"]);
172
173
  expect(fetchSpy).not.toHaveBeenCalled();
173
174
  },
@@ -177,7 +178,7 @@ describe("cloudflare-email Worker", () => {
177
178
  const env = { RAILS_INGRESS_URL: "", INGRESS_SECRET: SECRET };
178
179
  const { message, rejects } = makeMessage(RAW);
179
180
 
180
- await worker.email(message as any, env);
181
+ await forwardEmail(message as any, env);
181
182
 
182
183
  expect(rejects[0]).toMatch(/missing RAILS_INGRESS_URL/);
183
184
  expect(fetchSpy).not.toHaveBeenCalled();
@@ -187,7 +188,7 @@ describe("cloudflare-email Worker", () => {
187
188
  const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: "" };
188
189
  const { message, rejects } = makeMessage(RAW);
189
190
 
190
- await worker.email(message as any, env);
191
+ await forwardEmail(message as any, env);
191
192
 
192
193
  expect(rejects[0]).toMatch(/missing .*INGRESS_SECRET/);
193
194
  expect(fetchSpy).not.toHaveBeenCalled();
@@ -198,7 +199,7 @@ describe("cloudflare-email Worker", () => {
198
199
  const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
199
200
  const { message, rejects } = makeMessage(RAW);
200
201
 
201
- await worker.email(message as any, env);
202
+ await forwardEmail(message as any, env);
202
203
 
203
204
  expect(rejects).toEqual(["upstream fetch failed"]);
204
205
  });
@@ -209,7 +210,7 @@ describe("cloudflare-email Worker", () => {
209
210
  options.signal.addEventListener("abort", () => reject(new Error("aborted")));
210
211
  }));
211
212
  const { message, rejects } = makeMessage(RAW);
212
- const delivery = worker.email(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
213
+ const delivery = forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
213
214
  // Signing uses async Web Crypto, so wait until fetch starts before advancing timers.
214
215
  await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledOnce());
215
216
  await vi.advanceTimersByTimeAsync(15_000);
@@ -220,7 +221,7 @@ describe("cloudflare-email Worker", () => {
220
221
 
221
222
  it("refuses redirects and clears the timeout after delivery", async () => {
222
223
  vi.useFakeTimers();
223
- await worker.email(makeMessage(RAW).message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
224
+ await forwardEmail(makeMessage(RAW).message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
224
225
  const options = fetchSpy.mock.calls[0][1];
225
226
  expect(options.redirect).toBe("manual");
226
227
  await vi.advanceTimersByTimeAsync(15_000);
@@ -230,7 +231,7 @@ describe("cloudflare-email Worker", () => {
230
231
  it("rejects an ingress redirect response", async () => {
231
232
  fetchSpy.mockResolvedValueOnce(new Response(null, { status: 302, headers: { Location: "https://other.test" } }));
232
233
  const { message, rejects } = makeMessage(RAW);
233
- await worker.email(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
234
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
234
235
  expect(rejects).toEqual(["upstream returned 302"]);
235
236
  expect(fetchSpy).toHaveBeenCalledOnce();
236
237
  });
@@ -239,13 +240,70 @@ describe("cloudflare-email Worker", () => {
239
240
  // Sanity check: two different bodies produce two different signatures.
240
241
  const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
241
242
 
242
- await worker.email(makeMessage(RAW).message as any, env);
243
+ await forwardEmail(makeMessage(RAW).message as any, env);
243
244
  const sig1 = fetchSpy.mock.calls[0][1].headers["X-CF-Email-Signature"];
244
245
 
245
246
  fetchSpy.mockClear();
246
- await worker.email(makeMessage(RAW + "tamper").message as any, env);
247
+ await forwardEmail(makeMessage(RAW + "tamper").message as any, env);
247
248
  const sig2 = fetchSpy.mock.calls[0][1].headers["X-CF-Email-Signature"];
248
249
 
249
250
  expect(sig1).not.toBe(sig2);
250
251
  });
252
+
253
+ const metadata = { source: "cloudflare", data: { note: "Résumé ☁", spf: "pass", score: 0.5, flags: [true, null] } };
254
+ const signingInput = { secret: SECRET, raw: new Uint8Array([0, 255, 13, 10, 128]), from: "sender@external.test", to: "inbox@trial.test", timestamp: "1750000000" };
255
+
256
+ it("matches the shared v3 binary body and UTF8 metadata vector", async () => {
257
+ const headers = await signedEmailHeaders({ ...signingInput, metadata });
258
+ expect(headers["X-CF-Email-Signature-Version"]).toBe("3");
259
+ expect(headers["X-CF-Email-Metadata"]).toBe("eyJzb3VyY2UiOiJjbG91ZGZsYXJlIiwiZGF0YSI6eyJub3RlIjoiUsOpc3Vtw6kg4piBIiwic3BmIjoicGFzcyIsInNjb3JlIjowLjUsImZsYWdzIjpbdHJ1ZSxudWxsXX19");
260
+ expect(headers["X-CF-Email-Signature"]).toBe("d4e5ed55306e1619b5c40f3ec5dfea44a3131ad422a22b49ffb042069f6b720f");
261
+ for (const changed of [{ ...metadata, source: "other" }, { ...metadata, data: { ...metadata.data, spf: "fail" } }]) {
262
+ expect((await signedEmailHeaders({ ...signingInput, metadata: changed }))["X-CF-Email-Signature"]).not.toBe(headers["X-CF-Email-Signature"]);
263
+ }
264
+ });
265
+
266
+ it("forwards opt-in metadata while preserving raw MIME and normal transport policy", async () => {
267
+ vi.spyOn(Date, "now").mockReturnValue(1750000000000);
268
+ const { message, rejects } = makeMessage(RAW);
269
+ await forwardEmail(message, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET }, { metadata });
270
+ expect(rejects).toEqual([]);
271
+ const options = fetchSpy.mock.calls[0][1];
272
+ expect(options.headers).toEqual(await signedEmailHeaders({ ...signingInput, raw: new TextEncoder().encode(RAW), metadata }));
273
+ expect(options.redirect).toBe("manual");
274
+ expect(new TextDecoder().decode(options.body)).toBe(RAW);
275
+ });
276
+
277
+ it.each([null, {}, { source: "Cloudflare", data: {} }, { source: "a\n", data: {} },
278
+ { source: "a".repeat(129), data: {} }, { source: "a", data: [] },
279
+ { source: "a", data: {}, extra: true }, { source: "a", data: { x: undefined } },
280
+ { source: "a", data: { x: NaN } }, { source: "a", data: { x: new Date() } },
281
+ { source: "a", data: { x: "\uD800" } }, { source: "a", data: { "\uDC00": true } },
282
+ { source: "a", data: { x: "x".repeat(16384) } }])("rejects malformed metadata without downgrading to v2 (%j)", async (invalid) => {
283
+ await expect(signedEmailHeaders({ ...signingInput, metadata: invalid })).rejects.toThrow();
284
+ const { message, rejects } = makeMessage(RAW);
285
+ await forwardEmail(message, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET }, { metadata: invalid });
286
+ expect(rejects).toEqual(["worker could not sign envelope or provider metadata"]);
287
+ expect(fetchSpy).not.toHaveBeenCalled();
288
+ });
289
+
290
+ it("bounds metadata nesting including cyclic data", async () => {
291
+ const data: any = {};
292
+ let node = data;
293
+ for (let i = 0; i < 6; i++) { node.child = {}; node = node.child; }
294
+ await expect(signedEmailHeaders({ ...signingInput, metadata: { source: "a", data } })).resolves.toBeDefined();
295
+ node.child = {};
296
+ await expect(signedEmailHeaders({ ...signingInput, metadata: { source: "a", data } })).rejects.toThrow();
297
+ data.child = data;
298
+ await expect(signedEmailHeaders({ ...signingInput, metadata: { source: "a", data } })).rejects.toThrow();
299
+ });
300
+
301
+ it("enforces the encoded metadata size boundary", async () => {
302
+ // The JSON wrapper occupies 33 UTF8 bytes; 12288 bytes encode to 16384 characters.
303
+ const atLimit = { source: "a", data: { text: "x".repeat(12288 - 33) } };
304
+ const headers = await signedEmailHeaders({ ...signingInput, metadata: atLimit });
305
+ expect(headers["X-CF-Email-Metadata"]).toHaveLength(16384);
306
+ atLimit.data.text += "x";
307
+ await expect(signedEmailHeaders({ ...signingInput, metadata: atLimit })).rejects.toThrow(/16384/);
308
+ });
251
309
  });
@@ -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
+ });
@@ -17,3 +17,53 @@ name = "cloudflare-email-ingress-production"
17
17
 
18
18
  # After deploying, in the Cloudflare dashboard:
19
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,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cloudflare-email
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.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-09-11 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64
@@ -139,25 +139,39 @@ files:
139
139
  - README.md
140
140
  - SECURITY.md
141
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
142
148
  - docs/activerecord-tenanted.md
143
149
  - docs/architecture.md
150
+ - docs/custom-ingress.md
144
151
  - docs/delivery-events.md
145
152
  - docs/features.md
146
153
  - docs/getting-started.md
147
154
  - docs/mailboxes.md
155
+ - docs/management-engine.md
148
156
  - docs/outbox.md
157
+ - docs/routing-deliveries.md
158
+ - docs/routing-diagnostics.md
149
159
  - docs/tenant-mailboxes-plan.md
150
160
  - docs/thread-correlation.md
151
161
  - docs/troubleshooting.md
152
162
  - docs/upgrading-0.2.md
163
+ - docs/upgrading-0.3.md
153
164
  - docs/verification/2026-09-10-followup.md
154
165
  - docs/verification/2026-09-10-install-upgrade.md
155
166
  - docs/verification/2026-09-10-live.md
156
167
  - docs/verification/2026-09-10.md
168
+ - docs/verification/2026-09-11-custom-ingress.md
157
169
  - docs/verification/2026-09-11-extraction.md
158
170
  - docs/verification/2026-09-11-outbound-ledger.md
159
171
  - docs/verification/2026-09-11-security.md
160
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
161
175
  - examples/plain_ruby.rb
162
176
  - lib/cloudflare-email.rb
163
177
  - lib/cloudflare/email/active_record.rb
@@ -171,6 +185,8 @@ files:
171
185
  - lib/cloudflare/email/active_record/outbound_reconciliation.rb
172
186
  - lib/cloudflare/email/active_record/outbox.rb
173
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
174
190
  - lib/cloudflare/email/client.rb
175
191
  - lib/cloudflare/email/consume_events_task.rb
176
192
  - lib/cloudflare/email/credentials.rb
@@ -185,6 +201,7 @@ files:
185
201
  - lib/cloudflare/email/envelope.rb
186
202
  - lib/cloudflare/email/error.rb
187
203
  - lib/cloudflare/email/event_consumer.rb
204
+ - lib/cloudflare/email/ingress.rb
188
205
  - lib/cloudflare/email/mailboxes.rb
189
206
  - lib/cloudflare/email/mailboxes/configuration.rb
190
207
  - lib/cloudflare/email/mailboxes/events.rb
@@ -194,11 +211,20 @@ files:
194
211
  - lib/cloudflare/email/mailboxes/provider_correlation.rb
195
212
  - lib/cloudflare/email/mailboxes/service.rb
196
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
197
220
  - lib/cloudflare/email/message_id.rb
221
+ - lib/cloudflare/email/provider_metadata.rb
198
222
  - lib/cloudflare/email/provision_catchall_task.rb
199
223
  - lib/cloudflare/email/provision_route_task.rb
200
224
  - lib/cloudflare/email/replay_events_job.rb
201
225
  - lib/cloudflare/email/response.rb
226
+ - lib/cloudflare/email/routing_analytics.rb
227
+ - lib/cloudflare/email/routing_diagnostics.rb
202
228
  - lib/cloudflare/email/routing_provisioner.rb
203
229
  - lib/cloudflare/email/send_job.rb
204
230
  - lib/cloudflare/email/send_test.rb
@@ -210,23 +236,43 @@ files:
210
236
  - lib/cloudflare/email/version.rb
211
237
  - lib/cloudflare/email/worker_deployer.rb
212
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
213
241
  - lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb
214
242
  - lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb
215
243
  - lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb
216
244
  - lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb
217
245
  - lib/generators/cloudflare/email/outbox/outbox_generator.rb
218
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
219
249
  - lib/generators/cloudflare/email/templates/initializer.rb
220
250
  - lib/generators/cloudflare/email/templates/main_mailbox.rb
221
251
  - lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb
222
252
  - lib/generators/cloudflare/email/tracking/tracking_generator.rb
223
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
224
267
  - templates/worker/README.md
268
+ - templates/worker/docs/durable-inbound.md
225
269
  - templates/worker/package-lock.json
226
270
  - templates/worker/package.json
227
271
  - templates/worker/scripts/wrangler.mjs
228
272
  - templates/worker/src/index.js
273
+ - templates/worker/test/durable.test.ts
229
274
  - templates/worker/test/index.test.ts
275
+ - templates/worker/test/relay.test.ts
230
276
  - templates/worker/vitest.config.ts
231
277
  - templates/worker/wrangler.toml
232
278
  homepage: https://github.com/cole-robertson/cloudflare-email
@@ -250,7 +296,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
250
296
  - !ruby/object:Gem::Version
251
297
  version: '0'
252
298
  requirements: []
253
- rubygems_version: 3.6.2
299
+ rubygems_version: 3.6.9
254
300
  specification_version: 4
255
301
  summary: Cloudflare Email Service client for Ruby and Rails (ActionMailer + ActionMailbox).
256
302
  test_files: []