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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +149 -1
- data/README.md +287 -385
- data/SECURITY.md +51 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +33 -21
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
- data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
- data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
- data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
- data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
- data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
- data/docs/activerecord-tenanted.md +181 -0
- data/docs/architecture.md +105 -0
- data/docs/custom-ingress.md +207 -0
- data/docs/delivery-events.md +186 -0
- data/docs/features.md +138 -0
- data/docs/getting-started.md +308 -0
- data/docs/mailboxes.md +363 -0
- data/docs/management-engine.md +190 -0
- data/docs/outbox.md +175 -0
- data/docs/routing-deliveries.md +94 -0
- data/docs/routing-diagnostics.md +68 -0
- data/docs/tenant-mailboxes-plan.md +149 -0
- data/docs/thread-correlation.md +27 -0
- data/docs/troubleshooting.md +104 -0
- data/docs/upgrading-0.2.md +101 -0
- data/docs/upgrading-0.3.md +55 -0
- data/docs/verification/2026-09-10-followup.md +49 -0
- data/docs/verification/2026-09-10-install-upgrade.md +86 -0
- data/docs/verification/2026-09-10-live.md +102 -0
- data/docs/verification/2026-09-10.md +175 -0
- data/docs/verification/2026-09-11-custom-ingress.md +49 -0
- data/docs/verification/2026-09-11-extraction.md +32 -0
- data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
- data/docs/verification/2026-09-11-security.md +76 -0
- data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
- data/docs/verification/2026-09-12-destination-context.md +46 -0
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
- data/examples/plain_ruby.rb +24 -0
- data/lib/cloudflare/email/active_record/base.rb +60 -0
- data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
- data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
- data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
- data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
- data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
- data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
- data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
- data/lib/cloudflare/email/active_record/outbox.rb +219 -0
- data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
- data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
- data/lib/cloudflare/email/active_record.rb +5 -0
- data/lib/cloudflare/email/check_route_task.rb +21 -0
- data/lib/cloudflare/email/client.rb +93 -18
- data/lib/cloudflare/email/consume_events_task.rb +22 -0
- data/lib/cloudflare/email/delivery_event.rb +70 -0
- data/lib/cloudflare/email/delivery_method.rb +5 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +6 -3
- data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
- data/lib/cloudflare/email/dev_tunnel.rb +31 -5
- data/lib/cloudflare/email/doctor.rb +16 -38
- data/lib/cloudflare/email/endpoint.rb +24 -0
- data/lib/cloudflare/email/engine.rb +33 -0
- data/lib/cloudflare/email/envelope.rb +58 -0
- data/lib/cloudflare/email/event_consumer.rb +95 -0
- data/lib/cloudflare/email/ingress.rb +115 -0
- data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
- data/lib/cloudflare/email/mailboxes/events.rb +112 -0
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
- data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
- data/lib/cloudflare/email/mailboxes/models.rb +165 -0
- data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
- data/lib/cloudflare/email/mailboxes/service.rb +372 -0
- data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
- data/lib/cloudflare/email/mailboxes.rb +11 -0
- data/lib/cloudflare/email/management/adapter.rb +31 -0
- data/lib/cloudflare/email/management/configuration.rb +17 -0
- data/lib/cloudflare/email/management/engine.rb +15 -0
- data/lib/cloudflare/email/management/management.css +68 -0
- data/lib/cloudflare/email/management/routes.rb +14 -0
- data/lib/cloudflare/email/management.rb +7 -0
- data/lib/cloudflare/email/message_id.rb +12 -0
- data/lib/cloudflare/email/provider_metadata.rb +77 -0
- data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
- data/lib/cloudflare/email/provision_route_task.rb +2 -1
- data/lib/cloudflare/email/replay_events_job.rb +18 -0
- data/lib/cloudflare/email/response.rb +21 -3
- data/lib/cloudflare/email/routing_analytics.rb +196 -0
- data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
- data/lib/cloudflare/email/routing_provisioner.rb +71 -45
- data/lib/cloudflare/email/send_job.rb +41 -0
- data/lib/cloudflare/email/send_test.rb +5 -29
- data/lib/cloudflare/email/signing.rb +2 -2
- data/lib/cloudflare/email/tenancy.rb +78 -0
- data/lib/cloudflare/email/tenant_job_context.rb +90 -0
- data/lib/cloudflare/email/verification.rb +38 -8
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +41 -8
- data/lib/cloudflare-email.rb +4 -1
- data/lib/generators/cloudflare/email/install_generator.rb +53 -37
- data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
- data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
- data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +58 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
- data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
- data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
- data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
- data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
- data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
- data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
- data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
- data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
- data/lib/tasks/cloudflare_email.rake +42 -4
- data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
- data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
- data/templates/deploy-to-cloudflare/.gitignore +6 -0
- data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
- data/templates/deploy-to-cloudflare/README.md +84 -0
- data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
- data/templates/deploy-to-cloudflare/package.json +36 -0
- data/templates/deploy-to-cloudflare/src/index.js +445 -0
- data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
- data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
- data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
- data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
- data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
- data/templates/worker/README.md +231 -16
- data/templates/worker/docs/durable-inbound.md +149 -0
- data/templates/worker/package-lock.json +2627 -0
- data/templates/worker/package.json +8 -5
- data/templates/worker/scripts/wrangler.mjs +16 -0
- data/templates/worker/src/index.js +397 -25
- data/templates/worker/test/durable.test.ts +193 -0
- data/templates/worker/test/index.test.ts +183 -13
- data/templates/worker/test/relay.test.ts +226 -0
- data/templates/worker/wrangler.toml +63 -4
- metadata +154 -7
- 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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
202
|
+
await forwardEmail(message as any, env);
|
|
122
203
|
|
|
123
|
-
expect(rejects
|
|
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
|
|
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
|
|
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
|
});
|