cloudflare-email 0.2.0 → 0.4.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 +109 -0
- data/README.md +44 -9
- data/app/controllers/cloudflare/email/ingress_controller.rb +15 -60
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +9 -0
- data/app/controllers/cloudflare/email/management/styles_controller.rb +9 -0
- data/docs/activerecord-tenanted.md +46 -0
- data/docs/architecture.md +13 -3
- data/docs/custom-ingress.md +211 -0
- data/docs/delivery-events.md +9 -0
- data/docs/features.md +36 -5
- data/docs/getting-started.md +19 -8
- data/docs/mailboxes.md +74 -3
- data/docs/management-engine.md +190 -0
- data/docs/routing-deliveries.md +94 -0
- data/docs/routing-diagnostics.md +75 -0
- data/docs/troubleshooting.md +5 -4
- data/docs/upgrading-0.2.md +5 -0
- data/docs/upgrading-0.3.md +55 -0
- data/docs/verification/2026-09-11-custom-ingress.md +49 -0
- data/docs/verification/2026-09-12-destination-context.md +46 -0
- data/docs/verification/2026-09-13-action-mailbox-core.md +96 -0
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
- data/lib/cloudflare/email/active_record/base.rb +3 -41
- data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
- data/lib/cloudflare/email/check_route_task.rb +21 -0
- data/lib/cloudflare/email/client.rb +45 -10
- data/lib/cloudflare/email/delivery_method.rb +2 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
- data/lib/cloudflare/email/engine.rb +6 -13
- data/lib/cloudflare/email/envelope.rb +4 -14
- data/lib/cloudflare/email/error.rb +5 -12
- data/lib/cloudflare/email/event_consumer.rb +12 -3
- data/lib/cloudflare/email/ingress.rb +126 -0
- data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
- data/lib/cloudflare/email/mailboxes/models.rb +8 -130
- data/lib/cloudflare/email/mailboxes/service.rb +7 -174
- data/lib/cloudflare/email/mailboxes.rb +1 -0
- data/lib/cloudflare/email/management/adapter.rb +9 -0
- data/lib/cloudflare/email/management/configuration.rb +10 -0
- data/lib/cloudflare/email/management/engine.rb +17 -0
- data/lib/cloudflare/email/management/routes.rb +14 -0
- data/lib/cloudflare/email/management.rb +7 -0
- data/lib/cloudflare/email/provider_metadata.rb +77 -0
- data/lib/cloudflare/email/routing_analytics.rb +196 -0
- data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
- data/lib/cloudflare/email/tenancy.rb +2 -73
- data/lib/cloudflare/email/tenant_job_context.rb +2 -85
- data/lib/cloudflare/email/verification.rb +17 -9
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +36 -5
- data/lib/cloudflare-email.rb +6 -0
- data/lib/generators/cloudflare/email/install_generator.rb +6 -5
- 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/templates/create_cloudflare_email_mailboxes.rb +5 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
- 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/tasks/cloudflare_email.rake +2 -1
- 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 +104 -0
- data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
- data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
- data/templates/deploy-to-cloudflare/package.json +37 -0
- data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -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/test/subdomains.test.ts +51 -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 +186 -15
- data/templates/worker/docs/domain-setup.md +198 -0
- data/templates/worker/docs/durable-inbound.md +149 -0
- data/templates/worker/package-lock.json +2 -2
- data/templates/worker/package.json +2 -1
- data/templates/worker/scripts/check-subdomains.mjs +54 -0
- data/templates/worker/src/index.js +331 -38
- data/templates/worker/test/durable.test.ts +193 -0
- data/templates/worker/test/index.test.ts +78 -20
- data/templates/worker/test/relay.test.ts +226 -0
- data/templates/worker/test/subdomains.test.ts +51 -0
- data/templates/worker/wrangler.toml +50 -0
- metadata +65 -3
|
@@ -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) {
|
|
@@ -64,7 +64,7 @@ describe("cloudflare-email Worker", () => {
|
|
|
64
64
|
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
|
|
65
65
|
const { message } = makeMessage(RAW);
|
|
66
66
|
|
|
67
|
-
await
|
|
67
|
+
await forwardEmail(message as any, env);
|
|
68
68
|
|
|
69
69
|
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
70
70
|
const [url, opts] = fetchSpy.mock.calls[0];
|
|
@@ -75,6 +75,7 @@ describe("cloudflare-email Worker", () => {
|
|
|
75
75
|
const sig = opts.headers["X-CF-Email-Signature"];
|
|
76
76
|
const envelope = opts.headers["X-CF-Email-Envelope"];
|
|
77
77
|
expect(opts.headers["X-CF-Email-Signature-Version"]).toBe("2");
|
|
78
|
+
expect(opts.headers["X-CF-Email-Metadata"]).toBeUndefined();
|
|
78
79
|
expect(JSON.parse(atob(envelope.replace(/-/g, "+").replace(/_/g, "/")))).toEqual({
|
|
79
80
|
from: "sender@external.test", to: "inbox@trial.test",
|
|
80
81
|
});
|
|
@@ -93,7 +94,7 @@ describe("cloudflare-email Worker", () => {
|
|
|
93
94
|
it.each(["http://rails.test/inbound", "https://user:password@rails.test/inbound", "https://rails.test/inbound#fragment", "invalid"])(
|
|
94
95
|
"rejects invalid ingress URL %j before reading mail", async (url) => {
|
|
95
96
|
const { message, rejects } = makeMessage(RAW);
|
|
96
|
-
await
|
|
97
|
+
await forwardEmail(message as any, { RAILS_INGRESS_URL: url, INGRESS_SECRET: SECRET });
|
|
97
98
|
expect(rejects).toHaveLength(1);
|
|
98
99
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
99
100
|
expect(message.raw.locked).toBe(false);
|
|
@@ -102,14 +103,14 @@ describe("cloudflare-email Worker", () => {
|
|
|
102
103
|
|
|
103
104
|
it("allows loopback HTTP for local verification", async () => {
|
|
104
105
|
const { message, rejects } = makeMessage(RAW);
|
|
105
|
-
await
|
|
106
|
+
await forwardEmail(message as any, { RAILS_INGRESS_URL: "http://127.0.0.1:3000/inbound", INGRESS_SECRET: SECRET });
|
|
106
107
|
expect(rejects).toEqual([]);
|
|
107
108
|
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
108
109
|
});
|
|
109
110
|
|
|
110
111
|
it("accepts mail at the configured size boundary", async () => {
|
|
111
112
|
const { message, rejects } = makeMessage(RAW);
|
|
112
|
-
await
|
|
113
|
+
await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET, MAX_EMAIL_BYTES: String(message.rawSize) });
|
|
113
114
|
expect(rejects).toEqual([]);
|
|
114
115
|
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
115
116
|
});
|
|
@@ -117,7 +118,7 @@ describe("cloudflare-email Worker", () => {
|
|
|
117
118
|
it.each([true, false])("enforces size limit with accurate rawSize=%j", async (accurate) => {
|
|
118
119
|
const { message, rejects } = makeMessage(RAW);
|
|
119
120
|
if (!accurate) message.rawSize = 0;
|
|
120
|
-
await
|
|
121
|
+
await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET, MAX_EMAIL_BYTES: "16" });
|
|
121
122
|
expect(rejects).toHaveLength(1);
|
|
122
123
|
expect(rejects[0]).toMatch(/size limit/);
|
|
123
124
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
@@ -125,7 +126,7 @@ describe("cloudflare-email Worker", () => {
|
|
|
125
126
|
|
|
126
127
|
it("rejects invalid size configuration", async () => {
|
|
127
128
|
const { message, rejects } = makeMessage(RAW);
|
|
128
|
-
await
|
|
129
|
+
await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET, MAX_EMAIL_BYTES: "0" });
|
|
129
130
|
expect(rejects).toHaveLength(1);
|
|
130
131
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
131
132
|
});
|
|
@@ -135,7 +136,7 @@ describe("cloudflare-email Worker", () => {
|
|
|
135
136
|
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
|
|
136
137
|
const { message, rejects } = makeMessage(RAW);
|
|
137
138
|
|
|
138
|
-
await
|
|
139
|
+
await forwardEmail(message as any, env);
|
|
139
140
|
|
|
140
141
|
expect(rejects).toEqual(["upstream returned 503"]);
|
|
141
142
|
});
|
|
@@ -143,10 +144,10 @@ describe("cloudflare-email Worker", () => {
|
|
|
143
144
|
it("authenticates SMTP recipients independently of sender-controlled To headers", async () => {
|
|
144
145
|
vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000);
|
|
145
146
|
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
|
|
146
|
-
await
|
|
147
|
+
await forwardEmail(makeMessage(RAW).message as any, env);
|
|
147
148
|
const other = makeMessage(RAW);
|
|
148
149
|
other.message.to = "bcc@trial.test";
|
|
149
|
-
await
|
|
150
|
+
await forwardEmail(other.message as any, env);
|
|
150
151
|
const first = fetchSpy.mock.calls[0][1];
|
|
151
152
|
const second = fetchSpy.mock.calls[1][1];
|
|
152
153
|
expect(second.body).toEqual(first.body);
|
|
@@ -158,7 +159,7 @@ describe("cloudflare-email Worker", () => {
|
|
|
158
159
|
it("allows the empty SMTP reverse path used by bounce messages", async () => {
|
|
159
160
|
const { message, rejects } = makeMessage(RAW);
|
|
160
161
|
message.from = "";
|
|
161
|
-
await
|
|
162
|
+
await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
|
|
162
163
|
expect(rejects).toEqual([]);
|
|
163
164
|
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
164
165
|
});
|
|
@@ -167,7 +168,7 @@ describe("cloudflare-email Worker", () => {
|
|
|
167
168
|
"rejects invalid SMTP recipient %j before posting", async (address) => {
|
|
168
169
|
const { message, rejects } = makeMessage(RAW);
|
|
169
170
|
message.to = address;
|
|
170
|
-
await
|
|
171
|
+
await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
|
|
171
172
|
expect(rejects).toEqual(["worker received invalid SMTP envelope"]);
|
|
172
173
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
173
174
|
},
|
|
@@ -177,7 +178,7 @@ describe("cloudflare-email Worker", () => {
|
|
|
177
178
|
const env = { RAILS_INGRESS_URL: "", INGRESS_SECRET: SECRET };
|
|
178
179
|
const { message, rejects } = makeMessage(RAW);
|
|
179
180
|
|
|
180
|
-
await
|
|
181
|
+
await forwardEmail(message as any, env);
|
|
181
182
|
|
|
182
183
|
expect(rejects[0]).toMatch(/missing RAILS_INGRESS_URL/);
|
|
183
184
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
@@ -187,7 +188,7 @@ describe("cloudflare-email Worker", () => {
|
|
|
187
188
|
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: "" };
|
|
188
189
|
const { message, rejects } = makeMessage(RAW);
|
|
189
190
|
|
|
190
|
-
await
|
|
191
|
+
await forwardEmail(message as any, env);
|
|
191
192
|
|
|
192
193
|
expect(rejects[0]).toMatch(/missing .*INGRESS_SECRET/);
|
|
193
194
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
@@ -198,7 +199,7 @@ describe("cloudflare-email Worker", () => {
|
|
|
198
199
|
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
|
|
199
200
|
const { message, rejects } = makeMessage(RAW);
|
|
200
201
|
|
|
201
|
-
await
|
|
202
|
+
await forwardEmail(message as any, env);
|
|
202
203
|
|
|
203
204
|
expect(rejects).toEqual(["upstream fetch failed"]);
|
|
204
205
|
});
|
|
@@ -209,7 +210,7 @@ describe("cloudflare-email Worker", () => {
|
|
|
209
210
|
options.signal.addEventListener("abort", () => reject(new Error("aborted")));
|
|
210
211
|
}));
|
|
211
212
|
const { message, rejects } = makeMessage(RAW);
|
|
212
|
-
const delivery =
|
|
213
|
+
const delivery = forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
|
|
213
214
|
// Signing uses async Web Crypto, so wait until fetch starts before advancing timers.
|
|
214
215
|
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledOnce());
|
|
215
216
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
@@ -220,7 +221,7 @@ describe("cloudflare-email Worker", () => {
|
|
|
220
221
|
|
|
221
222
|
it("refuses redirects and clears the timeout after delivery", async () => {
|
|
222
223
|
vi.useFakeTimers();
|
|
223
|
-
await
|
|
224
|
+
await forwardEmail(makeMessage(RAW).message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
|
|
224
225
|
const options = fetchSpy.mock.calls[0][1];
|
|
225
226
|
expect(options.redirect).toBe("manual");
|
|
226
227
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
@@ -230,7 +231,7 @@ describe("cloudflare-email Worker", () => {
|
|
|
230
231
|
it("rejects an ingress redirect response", async () => {
|
|
231
232
|
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 302, headers: { Location: "https://other.test" } }));
|
|
232
233
|
const { message, rejects } = makeMessage(RAW);
|
|
233
|
-
await
|
|
234
|
+
await forwardEmail(message as any, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET });
|
|
234
235
|
expect(rejects).toEqual(["upstream returned 302"]);
|
|
235
236
|
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
236
237
|
});
|
|
@@ -239,13 +240,70 @@ describe("cloudflare-email Worker", () => {
|
|
|
239
240
|
// Sanity check: two different bodies produce two different signatures.
|
|
240
241
|
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
|
|
241
242
|
|
|
242
|
-
await
|
|
243
|
+
await forwardEmail(makeMessage(RAW).message as any, env);
|
|
243
244
|
const sig1 = fetchSpy.mock.calls[0][1].headers["X-CF-Email-Signature"];
|
|
244
245
|
|
|
245
246
|
fetchSpy.mockClear();
|
|
246
|
-
await
|
|
247
|
+
await forwardEmail(makeMessage(RAW + "tamper").message as any, env);
|
|
247
248
|
const sig2 = fetchSpy.mock.calls[0][1].headers["X-CF-Email-Signature"];
|
|
248
249
|
|
|
249
250
|
expect(sig1).not.toBe(sig2);
|
|
250
251
|
});
|
|
252
|
+
|
|
253
|
+
const metadata = { source: "cloudflare", data: { note: "Résumé ☁", spf: "pass", score: 0.5, flags: [true, null] } };
|
|
254
|
+
const signingInput = { secret: SECRET, raw: new Uint8Array([0, 255, 13, 10, 128]), from: "sender@external.test", to: "inbox@trial.test", timestamp: "1750000000" };
|
|
255
|
+
|
|
256
|
+
it("matches the shared v3 binary body and UTF8 metadata vector", async () => {
|
|
257
|
+
const headers = await signedEmailHeaders({ ...signingInput, metadata });
|
|
258
|
+
expect(headers["X-CF-Email-Signature-Version"]).toBe("3");
|
|
259
|
+
expect(headers["X-CF-Email-Metadata"]).toBe("eyJzb3VyY2UiOiJjbG91ZGZsYXJlIiwiZGF0YSI6eyJub3RlIjoiUsOpc3Vtw6kg4piBIiwic3BmIjoicGFzcyIsInNjb3JlIjowLjUsImZsYWdzIjpbdHJ1ZSxudWxsXX19");
|
|
260
|
+
expect(headers["X-CF-Email-Signature"]).toBe("d4e5ed55306e1619b5c40f3ec5dfea44a3131ad422a22b49ffb042069f6b720f");
|
|
261
|
+
for (const changed of [{ ...metadata, source: "other" }, { ...metadata, data: { ...metadata.data, spf: "fail" } }]) {
|
|
262
|
+
expect((await signedEmailHeaders({ ...signingInput, metadata: changed }))["X-CF-Email-Signature"]).not.toBe(headers["X-CF-Email-Signature"]);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("forwards opt-in metadata while preserving raw MIME and normal transport policy", async () => {
|
|
267
|
+
vi.spyOn(Date, "now").mockReturnValue(1750000000000);
|
|
268
|
+
const { message, rejects } = makeMessage(RAW);
|
|
269
|
+
await forwardEmail(message, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET }, { metadata });
|
|
270
|
+
expect(rejects).toEqual([]);
|
|
271
|
+
const options = fetchSpy.mock.calls[0][1];
|
|
272
|
+
expect(options.headers).toEqual(await signedEmailHeaders({ ...signingInput, raw: new TextEncoder().encode(RAW), metadata }));
|
|
273
|
+
expect(options.redirect).toBe("manual");
|
|
274
|
+
expect(new TextDecoder().decode(options.body)).toBe(RAW);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it.each([null, {}, { source: "Cloudflare", data: {} }, { source: "a\n", data: {} },
|
|
278
|
+
{ source: "a".repeat(129), data: {} }, { source: "a", data: [] },
|
|
279
|
+
{ source: "a", data: {}, extra: true }, { source: "a", data: { x: undefined } },
|
|
280
|
+
{ source: "a", data: { x: NaN } }, { source: "a", data: { x: new Date() } },
|
|
281
|
+
{ source: "a", data: { x: "\uD800" } }, { source: "a", data: { "\uDC00": true } },
|
|
282
|
+
{ source: "a", data: { x: "x".repeat(16384) } }])("rejects malformed metadata without downgrading to v2 (%j)", async (invalid) => {
|
|
283
|
+
await expect(signedEmailHeaders({ ...signingInput, metadata: invalid })).rejects.toThrow();
|
|
284
|
+
const { message, rejects } = makeMessage(RAW);
|
|
285
|
+
await forwardEmail(message, { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET }, { metadata: invalid });
|
|
286
|
+
expect(rejects).toEqual(["worker could not sign envelope or provider metadata"]);
|
|
287
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("bounds metadata nesting including cyclic data", async () => {
|
|
291
|
+
const data: any = {};
|
|
292
|
+
let node = data;
|
|
293
|
+
for (let i = 0; i < 6; i++) { node.child = {}; node = node.child; }
|
|
294
|
+
await expect(signedEmailHeaders({ ...signingInput, metadata: { source: "a", data } })).resolves.toBeDefined();
|
|
295
|
+
node.child = {};
|
|
296
|
+
await expect(signedEmailHeaders({ ...signingInput, metadata: { source: "a", data } })).rejects.toThrow();
|
|
297
|
+
data.child = data;
|
|
298
|
+
await expect(signedEmailHeaders({ ...signingInput, metadata: { source: "a", data } })).rejects.toThrow();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("enforces the encoded metadata size boundary", async () => {
|
|
302
|
+
// The JSON wrapper occupies 33 UTF8 bytes; 12288 bytes encode to 16384 characters.
|
|
303
|
+
const atLimit = { source: "a", data: { text: "x".repeat(12288 - 33) } };
|
|
304
|
+
const headers = await signedEmailHeaders({ ...signingInput, metadata: atLimit });
|
|
305
|
+
expect(headers["X-CF-Email-Metadata"]).toHaveLength(16384);
|
|
306
|
+
atLimit.data.text += "x";
|
|
307
|
+
await expect(signedEmailHeaders({ ...signingInput, metadata: atLimit })).rejects.toThrow(/16384/);
|
|
308
|
+
});
|
|
251
309
|
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { archiveEmail, relayEmail, signedEmailHeaders } from "../src/index.js";
|
|
3
|
+
|
|
4
|
+
const raw = new Uint8Array([0, 255, 13, 10, 128]);
|
|
5
|
+
function message(bytes = raw, rawSize = bytes.length) {
|
|
6
|
+
return { from: "sender@example.com", to: "inbox@tenant.example.com", rawSize,
|
|
7
|
+
raw: new ReadableStream({ start(c) { c.enqueue(bytes); c.close(); } }) };
|
|
8
|
+
}
|
|
9
|
+
function options(overrides = {}) {
|
|
10
|
+
return { resolveBackend: async () => ({ url: "https://rails.example.com/ingest", secret: "test-secret" }),
|
|
11
|
+
headers: async ({ raw, from, to, backend }) => signedEmailHeaders({ raw, from, to, secret: backend.secret }),
|
|
12
|
+
...overrides };
|
|
13
|
+
}
|
|
14
|
+
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
|
|
15
|
+
|
|
16
|
+
describe("host-controlled relay", () => {
|
|
17
|
+
it("archives once before acceptance and backend lookup, signs and sends exact binary bytes", async () => {
|
|
18
|
+
const order: string[] = [];
|
|
19
|
+
const put = vi.fn(async (key, body, metadata) => {
|
|
20
|
+
order.push("archive");
|
|
21
|
+
expect(key).toBe("mail/one.eml");
|
|
22
|
+
expect(body).toEqual(raw);
|
|
23
|
+
expect(metadata).toEqual({ httpMetadata: { contentType: "message/rfc822" },
|
|
24
|
+
customMetadata: { from: "sender@example.com", to: "inbox@tenant.example.com", size: "5" } });
|
|
25
|
+
});
|
|
26
|
+
const fetch = vi.fn(async (_url, init) => {
|
|
27
|
+
order.push("fetch");
|
|
28
|
+
expect(init.body).toEqual(raw);
|
|
29
|
+
expect(init.headers.get("X-CF-Email-Signature-Version")).toBe("2");
|
|
30
|
+
expect(init.redirect).toBe("manual");
|
|
31
|
+
expect(init.signal).toBeInstanceOf(AbortSignal);
|
|
32
|
+
return new Response(null, { status: 202 });
|
|
33
|
+
});
|
|
34
|
+
vi.stubGlobal("fetch", fetch);
|
|
35
|
+
const result = await relayEmail(message(), options({
|
|
36
|
+
archive: (args) => archiveEmail({ ...args, bucket: { put }, key: "mail/one.eml" }),
|
|
37
|
+
accepts: async () => { order.push("accepts"); return true; },
|
|
38
|
+
resolveBackend: async () => { order.push("backend"); return { url: "https://rails.example.com/ingest", secret: "test-secret" }; },
|
|
39
|
+
headers: async (args) => {
|
|
40
|
+
expect(args.archive).toEqual({ key: "mail/one.eml" });
|
|
41
|
+
order.push("headers");
|
|
42
|
+
return signedEmailHeaders({ ...args, secret: args.backend.secret });
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
expect(order).toEqual(["archive", "accepts", "backend", "headers", "fetch"]);
|
|
46
|
+
expect(result).toEqual({ status: "delivered", reason: "delivered", httpStatus: 202, archiveFailed: false });
|
|
47
|
+
expect(fetch).toHaveBeenCalledOnce();
|
|
48
|
+
expect(put).toHaveBeenCalledOnce();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("continues after archive failure without exposing exception content", async () => {
|
|
52
|
+
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 204 })));
|
|
53
|
+
const result = await relayEmail(message(), options({ archive: async () => { throw new Error("PRIVATE EMAIL SECRET"); } }));
|
|
54
|
+
expect(result).toEqual({ status: "delivered", reason: "delivered", httpStatus: 204, archiveFailed: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("archives rejected recipients without resolving a backend", async () => {
|
|
58
|
+
const archive = vi.fn(async () => ({ key: "retained.eml" }));
|
|
59
|
+
const resolveBackend = vi.fn();
|
|
60
|
+
expect(await relayEmail(message(), options({ archive, accepts: async () => false, resolveBackend })))
|
|
61
|
+
.toEqual({ status: "rejected", reason: "not_accepted", archiveFailed: false });
|
|
62
|
+
expect(archive).toHaveBeenCalledOnce();
|
|
63
|
+
expect(resolveBackend).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("continues when the archive deadline expires, allowing a late archive completion", async () => {
|
|
67
|
+
vi.useFakeTimers();
|
|
68
|
+
let finish;
|
|
69
|
+
const archive = vi.fn(() => new Promise(resolve => { finish = resolve; }));
|
|
70
|
+
const fetch = vi.fn(async () => new Response(null, { status: 200 }));
|
|
71
|
+
vi.stubGlobal("fetch", fetch);
|
|
72
|
+
const pending = relayEmail(message(), options({ archive, archiveTimeoutMs: 20,
|
|
73
|
+
headers: async ({ archive }) => { expect(archive).toBeUndefined(); return {}; },
|
|
74
|
+
}));
|
|
75
|
+
await vi.advanceTimersByTimeAsync(19);
|
|
76
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
77
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
78
|
+
expect(await pending).toEqual({ status: "delivered", reason: "delivered", httpStatus: 200, archiveFailed: true });
|
|
79
|
+
finish({ key: "late.eml" });
|
|
80
|
+
await Promise.resolve();
|
|
81
|
+
expect(fetch).toHaveBeenCalledOnce();
|
|
82
|
+
expect(archive).toHaveBeenCalledOnce();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("uses the validated URL even when the headers callback changes the backend", async () => {
|
|
86
|
+
const fetch = vi.fn(async () => new Response(null, { status: 200 }));
|
|
87
|
+
vi.stubGlobal("fetch", fetch);
|
|
88
|
+
await relayEmail(message(), options({ headers: async ({ backend }) => {
|
|
89
|
+
backend.url = "http://untrusted.example.com/secret";
|
|
90
|
+
return { Authorization: "Basic private" };
|
|
91
|
+
} }));
|
|
92
|
+
expect(fetch.mock.calls[0][0]).toBe("https://rails.example.com/ingest");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("bounds actual streaming bytes even when rawSize is false", async () => {
|
|
96
|
+
const cancel = vi.fn();
|
|
97
|
+
const stream = new ReadableStream({ start(c) { c.enqueue(raw); }, cancel });
|
|
98
|
+
const archive = vi.fn();
|
|
99
|
+
expect(await relayEmail({ ...message(), raw: stream, rawSize: 1 }, options({ maxEmailBytes: 4, archive })))
|
|
100
|
+
.toEqual({ status: "rejected", reason: "too_large", archiveFailed: false });
|
|
101
|
+
expect(cancel).toHaveBeenCalledOnce();
|
|
102
|
+
expect(archive).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("rejects declared oversize or invalid envelopes without reading", async () => {
|
|
106
|
+
const getReader = vi.fn();
|
|
107
|
+
expect((await relayEmail({ ...message(), raw: { getReader }, rawSize: 100 }, options({ maxEmailBytes: 50 }))).reason).toBe("too_large");
|
|
108
|
+
expect((await relayEmail({ ...message(), raw: { getReader }, to: "bad\r\naddress" }, options())).reason).toBe("invalid_envelope");
|
|
109
|
+
expect(getReader).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("reports read failures and invalid chunks without forwarding", async () => {
|
|
113
|
+
const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
|
|
114
|
+
for (const stream of [new ReadableStream({ start(c) { c.error(new Error("secret")); } }),
|
|
115
|
+
new ReadableStream({ start(c) { c.enqueue("bad chunk"); c.close(); } })]) {
|
|
116
|
+
expect(await relayEmail({ ...message(), raw: stream }, options()))
|
|
117
|
+
.toEqual({ status: "failed", reason: "unreadable", archiveFailed: false });
|
|
118
|
+
}
|
|
119
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it.each(["http://rails.example.com", "https://user:secret@example.com", "https://example.com/#secret", "ftp://example.com", "invalid"])
|
|
123
|
+
("rejects unsafe backend %s", async (url) => {
|
|
124
|
+
const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
|
|
125
|
+
expect((await relayEmail(message(), options({ resolveBackend: async () => ({ url }) }))).reason).toBe("invalid_backend");
|
|
126
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("supports a host-selected loopback backend and Basic headers", async () => {
|
|
130
|
+
const fetch = vi.fn(async (url, init) => {
|
|
131
|
+
expect(url).toBe("http://127.0.0.1:3000/ingest");
|
|
132
|
+
expect(init.headers.get("Authorization")).toBe("Basic dGVzdDp0ZXN0");
|
|
133
|
+
expect(init.body).toEqual(raw);
|
|
134
|
+
return new Response(null, { status: 200 });
|
|
135
|
+
});
|
|
136
|
+
vi.stubGlobal("fetch", fetch);
|
|
137
|
+
const result = await relayEmail(message(), options({
|
|
138
|
+
resolveBackend: async ({ to }) => ({ url: to.includes("tenant") ? "http://127.0.0.1:3000/ingest" : "https://other.example.com" }),
|
|
139
|
+
headers: async () => ({ Authorization: "Basic dGVzdDp0ZXN0", "Content-Type": "message/rfc822" }),
|
|
140
|
+
}));
|
|
141
|
+
expect(result.status).toBe("delivered");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("leaves Unicode and legacy address shapes to host acceptance with custom headers", async () => {
|
|
145
|
+
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 200 })));
|
|
146
|
+
const from = "séndér@example.com";
|
|
147
|
+
const to = '"quoted local"@TENANT.example.com';
|
|
148
|
+
const accepts = vi.fn(async (envelope) => {
|
|
149
|
+
expect(envelope).toEqual({ from, to });
|
|
150
|
+
return true;
|
|
151
|
+
});
|
|
152
|
+
expect((await relayEmail({ ...message(), from, to }, options({ accepts,
|
|
153
|
+
headers: async () => ({ Authorization: "Basic dGVzdDp0ZXN0" }),
|
|
154
|
+
}))).status).toBe("delivered");
|
|
155
|
+
expect(accepts).toHaveBeenCalledOnce();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it.each([301, 400, 401, 413, 422, 429, 500, 503])("returns HTTP %i without reading response content or retrying", async (status) => {
|
|
159
|
+
const cancel = vi.fn();
|
|
160
|
+
const response = new Response(new ReadableStream({ cancel }), { status });
|
|
161
|
+
const text = vi.spyOn(response, "text");
|
|
162
|
+
const fetch = vi.fn(async () => response); vi.stubGlobal("fetch", fetch);
|
|
163
|
+
expect(await relayEmail(message(), options())).toEqual({ status: "failed", reason: "http_status", httpStatus: status, archiveFailed: false });
|
|
164
|
+
expect(text).not.toHaveBeenCalled();
|
|
165
|
+
expect(cancel).toHaveBeenCalledOnce();
|
|
166
|
+
expect(fetch).toHaveBeenCalledOnce();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("does not wait for an unresponsive response cancellation", async () => {
|
|
170
|
+
vi.stubGlobal("fetch", vi.fn(async () => new Response(new ReadableStream({ cancel: () => new Promise(() => {}) }), { status: 200 })));
|
|
171
|
+
expect((await relayEmail(message(), options())).status).toBe("delivered");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("aborts fetch on the configured timeout and returns no exception text", async () => {
|
|
175
|
+
vi.useFakeTimers();
|
|
176
|
+
vi.stubGlobal("fetch", vi.fn(async (_url, { signal }) => new Promise((_resolve, reject) => {
|
|
177
|
+
signal.addEventListener("abort", () => reject(new Error("private backend")));
|
|
178
|
+
})));
|
|
179
|
+
const pending = relayEmail(message(), options({ timeoutMs: 25, headers: async () => ({}) }));
|
|
180
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
181
|
+
expect(await pending).toEqual({ status: "failed", reason: "timeout", archiveFailed: false });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it.each([
|
|
185
|
+
[{ accepts: async () => { throw new Error("secret"); } }, "acceptance_failed"],
|
|
186
|
+
[{ resolveBackend: async () => { throw new Error("secret"); } }, "backend_failed"],
|
|
187
|
+
[{ resolveBackend: async () => null }, "invalid_backend"],
|
|
188
|
+
[{ headers: async () => { throw new Error("secret"); } }, "headers_failed"],
|
|
189
|
+
[{ headers: async () => ({ "Bad\nHeader": "secret" }) }, "headers_failed"],
|
|
190
|
+
[{ maxEmailBytes: 0 }, "invalid_options"],
|
|
191
|
+
[{ timeoutMs: Infinity }, "invalid_options"],
|
|
192
|
+
[{ timeoutMs: 2 ** 31 }, "invalid_options"],
|
|
193
|
+
[{ archive: true }, "invalid_options"],
|
|
194
|
+
[{ archiveTimeoutMs: 0 }, "invalid_options"],
|
|
195
|
+
[{ archiveTimeoutMs: 2 ** 31 }, "invalid_options"],
|
|
196
|
+
])("returns safe callback/configuration failure %#", async (overrides, reason) => {
|
|
197
|
+
expect(await relayEmail(message(), options(overrides))).toEqual({ status: "failed", reason, archiveFailed: false });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("returns fetch failures without backend secrets", async () => {
|
|
201
|
+
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("secret-url"); }));
|
|
202
|
+
expect(await relayEmail(message(), options())).toEqual({ status: "failed", reason: "fetch_failed", archiveFailed: false });
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("R2 archive helper", () => {
|
|
207
|
+
it("validates archive arguments before invoking the bucket", async () => {
|
|
208
|
+
const put = vi.fn();
|
|
209
|
+
const args = { bucket: { put }, key: "mail/id.eml", raw, from: "", to: "inbox@example.com" };
|
|
210
|
+
for (const overrides of [{ key: "" }, { key: "x".repeat(1025) }, { key: "\u0000" }, { raw: "bad" },
|
|
211
|
+
{ from: "sender\r\n@example.com" }, { to: "" }]) {
|
|
212
|
+
await expect(archiveEmail({ ...args, ...overrides })).rejects.toThrow("invalid email archive arguments");
|
|
213
|
+
}
|
|
214
|
+
expect(put).not.toHaveBeenCalled();
|
|
215
|
+
await expect(archiveEmail(args)).resolves.toEqual({ key: "mail/id.eml" });
|
|
216
|
+
expect(put.mock.calls[0][2].customMetadata.from).toBe("");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("stores ASCII-safe envelope metadata while preserving raw Unicode mail bytes", async () => {
|
|
220
|
+
const put = vi.fn();
|
|
221
|
+
await archiveEmail({ bucket: { put }, key: "mail/unicode.eml", raw,
|
|
222
|
+
from: "séndér@example.com", to: "inbox@tenant.example.com" });
|
|
223
|
+
expect(put.mock.calls[0][1]).toEqual(raw);
|
|
224
|
+
expect(put.mock.calls[0][2].customMetadata.from).toBe("s?nd?r@example.com");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { checkSubdomains } from "../scripts/check-subdomains.mjs";
|
|
3
|
+
|
|
4
|
+
function probes() {
|
|
5
|
+
let id = 0;
|
|
6
|
+
return () => `fresh-${++id}`;
|
|
7
|
+
}
|
|
8
|
+
const cloudflare = [{ priority: 10, exchange: "route1.mx.cloudflare.net" }];
|
|
9
|
+
|
|
10
|
+
describe("subdomain setup DNS observations", () => {
|
|
11
|
+
it("checks chosen exact names plus fresh labels without claiming delivery", async () => {
|
|
12
|
+
const lookup = vi.fn(async () => cloudflare);
|
|
13
|
+
const report = await checkSubdomains({ base: "in.example.com", labels: ["acme"] }, lookup, probes());
|
|
14
|
+
expect(lookup.mock.calls.map(call => call[0])).toEqual(["acme.in.example.com", "fresh-1.in.example.com", "fresh-2.in.example.com"]);
|
|
15
|
+
expect(report.dns_observation).toBe("cloudflare_mx_observed_for_all_names");
|
|
16
|
+
expect(report.delivery_verified).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("reports an exact-name exception even when fresh probes resolve", async () => {
|
|
20
|
+
const report = await checkSubdomains({ base: "in.example.com", labels: ["acme"] }, async domain => {
|
|
21
|
+
if (domain.startsWith("acme.")) throw Object.assign(new Error("no answer"), { code: "ENODATA" });
|
|
22
|
+
return cloudflare;
|
|
23
|
+
}, probes());
|
|
24
|
+
expect(report.dns_observation).toBe("needs_attention");
|
|
25
|
+
expect(report.checks[0].status).toBe("unknown");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it.each([
|
|
29
|
+
{ mx: [] }, { mx: [{ priority: 0, exchange: "." }] },
|
|
30
|
+
{ mx: [{ priority: 10, exchange: "mx.example.com" }] },
|
|
31
|
+
{ mx: [...cloudflare, { priority: 5, exchange: "mx.other.test" }] }
|
|
32
|
+
])("does not accept absent, null, foreign or mixed MX: $mx", async ({ mx }) => {
|
|
33
|
+
const report = await checkSubdomains({ base: "in.example.com" }, async () => mx, probes());
|
|
34
|
+
expect(report.dns_observation).toBe("needs_attention");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("preserves DNS timeout uncertainty", async () => {
|
|
38
|
+
const report = await checkSubdomains({ base: "in.example.com" }, async () => { throw Object.assign(new Error("timeout"), { code: "ETIMEOUT" }); }, probes());
|
|
39
|
+
expect(report.checks.every(check => check.status === "unknown")).toBe(true);
|
|
40
|
+
expect(report.delivery_verified).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects malformed domains and nested labels before DNS", async () => {
|
|
44
|
+
const lookup = vi.fn();
|
|
45
|
+
for (const base of ["*.example.com", "https://example.com", "a@b.com", "localhost", "a..com"]) {
|
|
46
|
+
await expect(checkSubdomains({ base }, lookup)).rejects.toThrow();
|
|
47
|
+
}
|
|
48
|
+
await expect(checkSubdomains({ base: "example.com", labels: ["nested.acme"] }, lookup)).rejects.toThrow();
|
|
49
|
+
expect(lookup).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -17,3 +17,53 @@ name = "cloudflare-email-ingress-production"
|
|
|
17
17
|
|
|
18
18
|
# After deploying, in the Cloudflare dashboard:
|
|
19
19
|
# Email Routing -> Routes -> "Send to a Worker" -> select the matching environment Worker
|
|
20
|
+
|
|
21
|
+
# Durable delivery is the default. Create these private resources before deploy.
|
|
22
|
+
# See docs/durable-inbound.md. Buckets must not expire pending objects.
|
|
23
|
+
# Explicit emergency fallback: set INBOUND_DELIVERY_MODE = "direct" in the
|
|
24
|
+
# chosen environment's [env.<name>.vars]. Keep recovery bindings and cron.
|
|
25
|
+
|
|
26
|
+
[[env.development.r2_buckets]]
|
|
27
|
+
binding = "INBOUND_EMAIL_STORE"
|
|
28
|
+
bucket_name = "cloudflare-email-inbound-development"
|
|
29
|
+
[[env.development.queues.producers]]
|
|
30
|
+
binding = "INBOUND_EMAIL_QUEUE"
|
|
31
|
+
queue = "cloudflare-email-inbound-development"
|
|
32
|
+
[[env.development.queues.consumers]]
|
|
33
|
+
queue = "cloudflare-email-inbound-development"
|
|
34
|
+
max_batch_size = 5
|
|
35
|
+
max_batch_timeout = 5
|
|
36
|
+
max_retries = 10
|
|
37
|
+
retry_delay = 300
|
|
38
|
+
[env.development.triggers]
|
|
39
|
+
crons = ["* * * * *"]
|
|
40
|
+
|
|
41
|
+
[[env.staging.r2_buckets]]
|
|
42
|
+
binding = "INBOUND_EMAIL_STORE"
|
|
43
|
+
bucket_name = "cloudflare-email-inbound-staging"
|
|
44
|
+
[[env.staging.queues.producers]]
|
|
45
|
+
binding = "INBOUND_EMAIL_QUEUE"
|
|
46
|
+
queue = "cloudflare-email-inbound-staging"
|
|
47
|
+
[[env.staging.queues.consumers]]
|
|
48
|
+
queue = "cloudflare-email-inbound-staging"
|
|
49
|
+
max_batch_size = 5
|
|
50
|
+
max_batch_timeout = 5
|
|
51
|
+
max_retries = 10
|
|
52
|
+
retry_delay = 300
|
|
53
|
+
[env.staging.triggers]
|
|
54
|
+
crons = ["* * * * *"]
|
|
55
|
+
|
|
56
|
+
[[env.production.r2_buckets]]
|
|
57
|
+
binding = "INBOUND_EMAIL_STORE"
|
|
58
|
+
bucket_name = "cloudflare-email-inbound-production"
|
|
59
|
+
[[env.production.queues.producers]]
|
|
60
|
+
binding = "INBOUND_EMAIL_QUEUE"
|
|
61
|
+
queue = "cloudflare-email-inbound-production"
|
|
62
|
+
[[env.production.queues.consumers]]
|
|
63
|
+
queue = "cloudflare-email-inbound-production"
|
|
64
|
+
max_batch_size = 5
|
|
65
|
+
max_batch_timeout = 5
|
|
66
|
+
max_retries = 10
|
|
67
|
+
retry_delay = 300
|
|
68
|
+
[env.production.triggers]
|
|
69
|
+
crons = ["* * * * *"]
|