cloudflare-email 0.1.0 → 0.2.0

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