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,193 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import worker, { retainEmail, deliverRetainedEmail, consumeRetainedEmails, sweepRetainedEmails } from "../src/index.js";
3
+
4
+ const prefix = "cloudflare-email/pending/";
5
+ const raw = new Uint8Array([70, 114, 111, 109, 58, 32, 120, 13, 10, 13, 10, 0, 255]);
6
+ function message() {
7
+ return { from: "sender@example.com", to: "inbox@example.com", rawSize: raw.length,
8
+ raw: new Response(raw).body, setReject: vi.fn() };
9
+ }
10
+
11
+ function setup() {
12
+ const objects = new Map<string, Uint8Array>();
13
+ const bucket = {
14
+ put: vi.fn(async (key: string, value: Uint8Array | string) => {
15
+ objects.set(key, typeof value === "string" ? new TextEncoder().encode(value) : value.slice());
16
+ }),
17
+ get: vi.fn(async (key: string) => {
18
+ const bytes = objects.get(key);
19
+ return bytes ? { size: bytes.length, body: new Response(bytes).body,
20
+ text: async () => new TextDecoder().decode(bytes) } : null;
21
+ }),
22
+ delete: vi.fn(async (key: string) => { objects.delete(key); }),
23
+ list: vi.fn(async ({ prefix, cursor, limit }) => {
24
+ const keys = [...objects.keys()].filter(key => key.startsWith(prefix) && (!cursor || key > cursor)).sort();
25
+ const page = keys.slice(0, limit);
26
+ return { objects: page.map(key => ({ key })), truncated: keys.length > limit, cursor: page.at(-1) };
27
+ }),
28
+ };
29
+ const env = { RAILS_INGRESS_URL: "https://rails.example.com/ingress",
30
+ INGRESS_SECRET: "secret", INBOUND_EMAIL_STORE: bucket, INBOUND_EMAIL_QUEUE: { send: vi.fn() } };
31
+ return { env, bucket, objects };
32
+ }
33
+
34
+ describe("durable inbound", () => {
35
+ beforeEach(() => { vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 204 })));
36
+ vi.spyOn(console, "warn").mockImplementation(() => {}); });
37
+ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); });
38
+
39
+ it("commits raw and context before acknowledgement and queues only a pointer", async () => {
40
+ const { env, bucket, objects } = setup();
41
+ env.INBOUND_EMAIL_QUEUE.send.mockImplementation(async ({ key }) => { expect(objects.has(key)).toBe(true); });
42
+ const result = await worker.email(message(), env);
43
+ expect(bucket.put).toHaveBeenCalledTimes(1);
44
+ expect(env.INBOUND_EMAIL_QUEUE.send).toHaveBeenCalledWith({ version: 1, key: result.key });
45
+ expect(fetch).not.toHaveBeenCalled();
46
+ });
47
+
48
+ it("does not acknowledge failed storage or enqueue a missing payload", async () => {
49
+ const { env, bucket } = setup();
50
+ bucket.put.mockRejectedValue(new Error("secret failure"));
51
+ const archive = vi.fn();
52
+ await expect(retainEmail(message(), env, { archive })).rejects.toThrow("durable storage unavailable");
53
+ expect(archive).not.toHaveBeenCalled();
54
+ expect(env.INBOUND_EMAIL_QUEUE.send).not.toHaveBeenCalled();
55
+ expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain("secret failure");
56
+ });
57
+
58
+ it("fails closed when durable infrastructure is missing or mode is misspelled", async () => {
59
+ const { env } = setup();
60
+ await expect(worker.email(message(), { ...env, INBOUND_EMAIL_STORE: undefined })).rejects.toThrow("configuration invalid");
61
+ expect(() => worker.email(message(), { ...env, INBOUND_DELIVERY_MODE: "durabel" })).toThrow("INBOUND_DELIVERY_MODE");
62
+ expect(fetch).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it("direct fallback still drains the existing R2 backlog through scheduled recovery", async () => {
66
+ const { env, objects } = setup();
67
+ await worker.email(message(), env);
68
+ const fallback = { ...env, INBOUND_DELIVERY_MODE: "direct" };
69
+ await worker.email(message(), fallback);
70
+ expect(fetch).toHaveBeenCalledTimes(1);
71
+ const tasks = [];
72
+ worker.scheduled({}, fallback, { waitUntil: task => tasks.push(task) });
73
+ await Promise.all(tasks);
74
+ expect(fetch).toHaveBeenCalledTimes(2);
75
+ expect(objects.size).toBe(0);
76
+ });
77
+
78
+ it.each(["error", "timeout"])("retains and enqueues when a secondary archive has an %s", async failure => {
79
+ const { env, objects } = setup();
80
+ vi.useFakeTimers();
81
+ const archive = vi.fn(async ({ raw: bytes, from, to, key }) => {
82
+ expect(objects.has(key)).toBe(true);
83
+ expect(bytes).toEqual(raw);
84
+ expect(from).toBe("sender@example.com");
85
+ expect(to).toBe("inbox@example.com");
86
+ if (failure === "error") throw new Error("private archive error");
87
+ await new Promise(() => {});
88
+ });
89
+ const pending = retainEmail(message(), env, { archive });
90
+ await vi.advanceTimersByTimeAsync(10_001);
91
+ const { key } = await pending;
92
+ expect(archive).toHaveBeenCalledTimes(1);
93
+ expect(objects.has(key)).toBe(true);
94
+ expect(env.INBOUND_EMAIL_QUEUE.send).toHaveBeenCalledWith({ version: 1, key });
95
+ expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain("private archive error");
96
+ });
97
+
98
+ it("honors the host archive deadline without delaying durable queue delivery", async () => {
99
+ const { env } = setup();
100
+ vi.useFakeTimers();
101
+ const pending = retainEmail(message(), env, {
102
+ archive: () => new Promise(() => {}), archiveTimeoutMs: 5,
103
+ });
104
+ await vi.advanceTimersByTimeAsync(6);
105
+ const { key } = await pending;
106
+ expect(env.INBOUND_EMAIL_QUEUE.send).toHaveBeenCalledWith({ version: 1, key });
107
+ });
108
+
109
+ it.each([0, -1, 1.5, 120_001, NaN, Infinity, "5"])("rejects invalid archive timeout %s before accepting mail", async archiveTimeoutMs => {
110
+ const { env, bucket } = setup();
111
+ await expect(retainEmail(message(), env, { archiveTimeoutMs })).rejects.toThrow("inbound archive timeout");
112
+ expect(bucket.put).not.toHaveBeenCalled();
113
+ expect(env.INBOUND_EMAIL_QUEUE.send).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it("recovers failed enqueue through scheduled scanning", async () => {
117
+ const { env, objects } = setup();
118
+ env.INBOUND_EMAIL_QUEUE.send.mockRejectedValue(new Error("unavailable"));
119
+ const { key } = await retainEmail(message(), env);
120
+ expect(objects.has(key)).toBe(true);
121
+ await sweepRetainedEmails(env);
122
+ expect(objects.has(key)).toBe(false);
123
+ expect(new Uint8Array(vi.mocked(fetch).mock.calls[0][1].body)).toEqual(raw);
124
+ });
125
+
126
+ it.each([401, 413, 429, 500, 503, 302])("retains HTTP %s until recovery, even after queue exhaustion", async status => {
127
+ const { env, objects } = setup();
128
+ const { key } = await retainEmail(message(), env);
129
+ vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status }));
130
+ const item = { body: { version: 1, key }, ack: vi.fn(), retry: vi.fn() };
131
+ await consumeRetainedEmails({ messages: [item] }, env);
132
+ expect(item.ack).not.toHaveBeenCalled();
133
+ expect(item.retry).toHaveBeenCalled();
134
+ expect(objects.has(key)).toBe(true);
135
+ // No queue redelivery: recovery depends exclusively on the scheduled sweep.
136
+ await sweepRetainedEmails(env);
137
+ expect(objects.has(key)).toBe(false);
138
+ });
139
+
140
+ it("replays lost HTTP acknowledgements with identical bytes and metadata but fresh signatures", async () => {
141
+ const { env, objects } = setup();
142
+ const { key } = await retainEmail(message(), env, { metadata: { source: "provider", data: { auth: "pass" } } });
143
+ vi.spyOn(Date, "now").mockReturnValue(1_800_000_000_000);
144
+ vi.mocked(fetch).mockRejectedValueOnce(new Error("lost response"));
145
+ await expect(deliverRetainedEmail(key, env)).rejects.toThrow("handoff incomplete");
146
+ expect(objects.has(key)).toBe(true);
147
+ vi.mocked(Date.now).mockReturnValue(1_800_000_600_000);
148
+ await deliverRetainedEmail(key, env);
149
+ const [first, second] = vi.mocked(fetch).mock.calls.map(call => call[1]);
150
+ expect(first.body).toEqual(second.body);
151
+ expect(first.headers["X-CF-Email-Metadata"]).toEqual(second.headers["X-CF-Email-Metadata"]);
152
+ expect(first.headers["X-CF-Email-Envelope"]).toEqual(second.headers["X-CF-Email-Envelope"]);
153
+ expect(first.headers["X-CF-Email-Signature"]).not.toEqual(second.headers["X-CF-Email-Signature"]);
154
+ });
155
+
156
+ it("does not ACK a failed completion delete; missing completed keys ACK without resending", async () => {
157
+ const { env, bucket, objects } = setup();
158
+ const { key } = await retainEmail(message(), env);
159
+ bucket.delete.mockRejectedValueOnce(new Error("unavailable"));
160
+ const item = { body: { version: 1, key }, ack: vi.fn(), retry: vi.fn() };
161
+ await consumeRetainedEmails({ messages: [item] }, env);
162
+ expect(item.ack).not.toHaveBeenCalled();
163
+ expect(objects.has(key)).toBe(true);
164
+ await consumeRetainedEmails({ messages: [item] }, env);
165
+ await consumeRetainedEmails({ messages: [item] }, env);
166
+ expect(fetch).toHaveBeenCalledTimes(2);
167
+ expect(item.ack).toHaveBeenCalledTimes(2);
168
+ });
169
+
170
+ it("advances pages past poison entries and wraps to retry them", async () => {
171
+ const { env, objects, bucket } = setup();
172
+ for (let i = 0; i < 12; i++) await retainEmail(message(), env);
173
+ vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 503 }));
174
+ await sweepRetainedEmails(env);
175
+ await sweepRetainedEmails(env);
176
+ await sweepRetainedEmails(env);
177
+ expect(fetch).toHaveBeenCalledTimes(12);
178
+ expect([...objects.keys()].filter(key => key.startsWith(prefix))).toHaveLength(12);
179
+ expect(objects.has("cloudflare-email/state/sweep")).toBe(false);
180
+ await sweepRetainedEmails(env);
181
+ expect(fetch).toHaveBeenCalledTimes(17);
182
+ expect(bucket.list.mock.calls[1][0].cursor).toBeTruthy();
183
+ });
184
+
185
+ it("rejects oversize messages without retaining incomplete bodies", async () => {
186
+ const { env, objects } = setup();
187
+ const input = message();
188
+ input.rawSize = 0;
189
+ await retainEmail(input, { ...env, MAX_EMAIL_BYTES: "2" });
190
+ expect(input.setReject).toHaveBeenCalledWith("message exceeds size limit");
191
+ expect(objects.size).toBe(0);
192
+ });
193
+ });
@@ -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) {
@@ -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,18 +51,20 @@ 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 () => {
62
64
  const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
63
65
  const { message } = makeMessage(RAW);
64
66
 
65
- await worker.email(message as any, env);
67
+ await forwardEmail(message as any, env);
66
68
 
67
69
  expect(fetchSpy).toHaveBeenCalledOnce();
68
70
  const [url, opts] = fetchSpy.mock.calls[0];
@@ -71,6 +73,12 @@ 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(opts.headers["X-CF-Email-Metadata"]).toBeUndefined();
79
+ expect(JSON.parse(atob(envelope.replace(/-/g, "+").replace(/_/g, "/")))).toEqual({
80
+ from: "sender@external.test", to: "inbox@trial.test",
81
+ });
74
82
  expect(ts).toMatch(/^\d+$/);
75
83
  expect(sig).toMatch(/^[0-9a-f]{64}$/);
76
84
 
@@ -79,25 +87,98 @@ describe("cloudflare-email Worker", () => {
79
87
  expect(new TextDecoder().decode(sentBytes)).toBe(RAW);
80
88
 
81
89
  // And the signature verifies against the input.
82
- const ok = await verifyHmac(SECRET, ts, sentBytes.buffer, sig);
90
+ const ok = await verifyHmac(SECRET, ts, envelope, sentBytes.buffer, sig);
83
91
  expect(ok).toBe(true);
84
92
  });
85
93
 
94
+ it.each(["http://rails.test/inbound", "https://user:password@rails.test/inbound", "https://rails.test/inbound#fragment", "invalid"])(
95
+ "rejects invalid ingress URL %j before reading mail", async (url) => {
96
+ const { message, rejects } = makeMessage(RAW);
97
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: url, INGRESS_SECRET: SECRET });
98
+ expect(rejects).toHaveLength(1);
99
+ expect(fetchSpy).not.toHaveBeenCalled();
100
+ expect(message.raw.locked).toBe(false);
101
+ },
102
+ );
103
+
104
+ it("allows loopback HTTP for local verification", async () => {
105
+ const { message, rejects } = makeMessage(RAW);
106
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: "http://127.0.0.1:3000/inbound", INGRESS_SECRET: SECRET });
107
+ expect(rejects).toEqual([]);
108
+ expect(fetchSpy).toHaveBeenCalledOnce();
109
+ });
110
+
111
+ it("accepts mail at the configured size boundary", async () => {
112
+ const { message, rejects } = makeMessage(RAW);
113
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET, MAX_EMAIL_BYTES: String(message.rawSize) });
114
+ expect(rejects).toEqual([]);
115
+ expect(fetchSpy).toHaveBeenCalledOnce();
116
+ });
117
+
118
+ it.each([true, false])("enforces size limit with accurate rawSize=%j", async (accurate) => {
119
+ const { message, rejects } = makeMessage(RAW);
120
+ if (!accurate) message.rawSize = 0;
121
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET, MAX_EMAIL_BYTES: "16" });
122
+ expect(rejects).toHaveLength(1);
123
+ expect(rejects[0]).toMatch(/size limit/);
124
+ expect(fetchSpy).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it("rejects invalid size configuration", async () => {
128
+ const { message, rejects } = makeMessage(RAW);
129
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET, MAX_EMAIL_BYTES: "0" });
130
+ expect(rejects).toHaveLength(1);
131
+ expect(fetchSpy).not.toHaveBeenCalled();
132
+ });
133
+
86
134
  it("rejects the message when upstream returns non-2xx", async () => {
87
135
  fetchSpy.mockResolvedValueOnce(new Response("server error", { status: 503 }));
88
136
  const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
89
137
  const { message, rejects } = makeMessage(RAW);
90
138
 
91
- await worker.email(message as any, env);
139
+ await forwardEmail(message as any, env);
92
140
 
93
141
  expect(rejects).toEqual(["upstream returned 503"]);
94
142
  });
95
143
 
144
+ it("authenticates SMTP recipients independently of sender-controlled To headers", async () => {
145
+ vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000);
146
+ const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
147
+ await forwardEmail(makeMessage(RAW).message as any, env);
148
+ const other = makeMessage(RAW);
149
+ other.message.to = "bcc@trial.test";
150
+ await forwardEmail(other.message as any, env);
151
+ const first = fetchSpy.mock.calls[0][1];
152
+ const second = fetchSpy.mock.calls[1][1];
153
+ expect(second.body).toEqual(first.body);
154
+ expect(second.headers["X-CF-Email-Signature"]).not.toBe(first.headers["X-CF-Email-Signature"]);
155
+ expect(await verifyHmac(SECRET, first.headers["X-CF-Email-Timestamp"], second.headers["X-CF-Email-Envelope"],
156
+ first.body.buffer, first.headers["X-CF-Email-Signature"])).toBe(false);
157
+ });
158
+
159
+ it("allows the empty SMTP reverse path used by bounce messages", async () => {
160
+ const { message, rejects } = makeMessage(RAW);
161
+ message.from = "";
162
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
163
+ expect(rejects).toEqual([]);
164
+ expect(fetchSpy).toHaveBeenCalledOnce();
165
+ });
166
+
167
+ 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"])(
168
+ "rejects invalid SMTP recipient %j before posting", async (address) => {
169
+ const { message, rejects } = makeMessage(RAW);
170
+ message.to = address;
171
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
172
+ expect(rejects).toEqual(["worker received invalid SMTP envelope"]);
173
+ expect(fetchSpy).not.toHaveBeenCalled();
174
+ },
175
+ );
176
+
96
177
  it("rejects the message when RAILS_INGRESS_URL is missing", async () => {
97
178
  const env = { RAILS_INGRESS_URL: "", INGRESS_SECRET: SECRET };
98
179
  const { message, rejects } = makeMessage(RAW);
99
180
 
100
- await worker.email(message as any, env);
181
+ await forwardEmail(message as any, env);
101
182
 
102
183
  expect(rejects[0]).toMatch(/missing RAILS_INGRESS_URL/);
103
184
  expect(fetchSpy).not.toHaveBeenCalled();
@@ -107,7 +188,7 @@ describe("cloudflare-email Worker", () => {
107
188
  const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: "" };
108
189
  const { message, rejects } = makeMessage(RAW);
109
190
 
110
- await worker.email(message as any, env);
191
+ await forwardEmail(message as any, env);
111
192
 
112
193
  expect(rejects[0]).toMatch(/missing .*INGRESS_SECRET/);
113
194
  expect(fetchSpy).not.toHaveBeenCalled();
@@ -118,22 +199,111 @@ describe("cloudflare-email Worker", () => {
118
199
  const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
119
200
  const { message, rejects } = makeMessage(RAW);
120
201
 
121
- await worker.email(message as any, env);
202
+ await forwardEmail(message as any, env);
122
203
 
123
- expect(rejects[0]).toMatch(/upstream fetch failed: DNS fail/);
204
+ expect(rejects).toEqual(["upstream fetch failed"]);
205
+ });
206
+
207
+ it("aborts a stalled ingress request after 15 seconds", async () => {
208
+ vi.useFakeTimers();
209
+ fetchSpy.mockImplementationOnce((_url, options) => new Promise((_resolve, reject) => {
210
+ options.signal.addEventListener("abort", () => reject(new Error("aborted")));
211
+ }));
212
+ const { message, rejects } = makeMessage(RAW);
213
+ const delivery = forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
214
+ // Signing uses async Web Crypto, so wait until fetch starts before advancing timers.
215
+ await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledOnce());
216
+ await vi.advanceTimersByTimeAsync(15_000);
217
+ await delivery;
218
+ expect(fetchSpy.mock.calls[0][1].signal.aborted).toBe(true);
219
+ expect(rejects).toEqual(["upstream fetch timed out"]);
220
+ });
221
+
222
+ it("refuses redirects and clears the timeout after delivery", async () => {
223
+ vi.useFakeTimers();
224
+ await forwardEmail(makeMessage(RAW).message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
225
+ const options = fetchSpy.mock.calls[0][1];
226
+ expect(options.redirect).toBe("manual");
227
+ await vi.advanceTimersByTimeAsync(15_000);
228
+ expect(options.signal.aborted).toBe(false);
229
+ });
230
+
231
+ it("rejects an ingress redirect response", async () => {
232
+ fetchSpy.mockResolvedValueOnce(new Response(null, { status: 302, headers: { Location: "https://other.test" } }));
233
+ const { message, rejects } = makeMessage(RAW);
234
+ await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
235
+ expect(rejects).toEqual(["upstream returned 302"]);
236
+ expect(fetchSpy).toHaveBeenCalledOnce();
124
237
  });
125
238
 
126
239
  it("signature covers tampered bodies differently", async () => {
127
240
  // Sanity check: two different bodies produce two different signatures.
128
241
  const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
129
242
 
130
- await worker.email(makeMessage(RAW).message as any, env);
243
+ await forwardEmail(makeMessage(RAW).message as any, env);
131
244
  const sig1 = fetchSpy.mock.calls[0][1].headers["X-CF-Email-Signature"];
132
245
 
133
246
  fetchSpy.mockClear();
134
- await worker.email(makeMessage(RAW + "tamper").message as any, env);
247
+ await forwardEmail(makeMessage(RAW + "tamper").message as any, env);
135
248
  const sig2 = fetchSpy.mock.calls[0][1].headers["X-CF-Email-Signature"];
136
249
 
137
250
  expect(sig1).not.toBe(sig2);
138
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
+ });
139
309
  });