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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Generated by script/sync_deploy_template.mjs in cloudflare-email.
|
|
2
|
+
name = "cloudflare-email-ingress"
|
|
3
|
+
main = "src/index.js"
|
|
4
|
+
compatibility_date = "2026-09-10"
|
|
5
|
+
|
|
6
|
+
[observability]
|
|
7
|
+
enabled = true
|
|
8
|
+
|
|
9
|
+
[[r2_buckets]]
|
|
10
|
+
binding = "INBOUND_EMAIL_STORE"
|
|
11
|
+
bucket_name = "cloudflare-email-inbound"
|
|
12
|
+
[[queues.producers]]
|
|
13
|
+
binding = "INBOUND_EMAIL_QUEUE"
|
|
14
|
+
queue = "cloudflare-email-inbound"
|
|
15
|
+
[[queues.consumers]]
|
|
16
|
+
queue = "cloudflare-email-inbound"
|
|
17
|
+
max_batch_size = 5
|
|
18
|
+
max_batch_timeout = 5
|
|
19
|
+
max_retries = 10
|
|
20
|
+
retry_delay = 300
|
|
21
|
+
[triggers]
|
|
22
|
+
crons = ["* * * * *"]
|
data/templates/worker/README.md
CHANGED
|
@@ -6,8 +6,21 @@ gem.
|
|
|
6
6
|
|
|
7
7
|
## Deploy
|
|
8
8
|
|
|
9
|
+
Choose a single receiving domain or dynamic organization subdomains using the
|
|
10
|
+
[domain setup worksheet and cited examples](docs/domain-setup.md). Once your
|
|
11
|
+
receiving namespace is verified, new mailboxes can be created in Rails without
|
|
12
|
+
editing a Worker recipient list. `npm run check:subdomains -- --base in.example.com`
|
|
13
|
+
checks public MX for fresh names; it does not claim live delivery verification.
|
|
14
|
+
|
|
15
|
+
For a guided dashboard deployment that provisions resources and prompts for
|
|
16
|
+
secrets, use the [Deploy to Cloudflare template](../deploy-to-cloudflare/README.md).
|
|
17
|
+
It uses this same Worker with a standalone configuration. The CLI setup below
|
|
18
|
+
is for the explicitly named development/staging/production environments.
|
|
19
|
+
|
|
9
20
|
```sh
|
|
10
21
|
npm ci
|
|
22
|
+
npx wrangler r2 bucket create cloudflare-email-inbound-production
|
|
23
|
+
npx wrangler queues create cloudflare-email-inbound-production
|
|
11
24
|
npx wrangler secret put INGRESS_SECRET --env production
|
|
12
25
|
npx wrangler secret put RAILS_INGRESS_URL --env production
|
|
13
26
|
npm run deploy -- --env production
|
|
@@ -46,20 +59,17 @@ does not change DNS or create routing rules.
|
|
|
46
59
|
|
|
47
60
|
For each inbound message, the Worker:
|
|
48
61
|
|
|
49
|
-
1.
|
|
50
|
-
2.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
message content and the signature to a different endpoint. The Worker does
|
|
61
|
-
not retry delivery automatically. A timeout can occur after Rails accepted
|
|
62
|
-
the message, so consumers should handle duplicate deliveries.
|
|
62
|
+
1. Validates and stores the exact RFC822 bytes and envelope in private R2.
|
|
63
|
+
2. Queues a small pointer after storage succeeds.
|
|
64
|
+
3. Signs and POSTs the stored message to Rails; only HTTP 2xx allows deleting it.
|
|
65
|
+
4. Retries failures through the Queue and a minute-by-minute scheduled recovery
|
|
66
|
+
pass. Even exhausted queue retries leave the original message recoverable.
|
|
67
|
+
|
|
68
|
+
The signed request uses HMAC-SHA256 and authenticates the original SMTP envelope
|
|
69
|
+
alongside the MIME bytes. See [durable delivery setup and recovery](./docs/durable-inbound.md).
|
|
70
|
+
`INBOUND_DELIVERY_MODE=direct` selects an explicit single-attempt fallback that
|
|
71
|
+
calls `message.setReject` on non-2xx, timeout or network failure. Existing pending
|
|
72
|
+
mail keeps draining through Queue and cron while this fallback is selected.
|
|
63
73
|
|
|
64
74
|
The Rails controller verifies the signature in constant time and rejects
|
|
65
75
|
timestamps outside its 5-minute acceptance window. A signature authenticates the
|
|
@@ -67,13 +77,174 @@ Worker request, not the original email sender. Captured requests remain valid
|
|
|
67
77
|
inside that window; Rails deduplicates messages through Action Mailbox.
|
|
68
78
|
|
|
69
79
|
Deploy the Rails gem and this Worker together with ingress paused during the
|
|
70
|
-
transition. Rails
|
|
80
|
+
transition. Rails accepts v2 and v3 signatures and rejects v1/missing versions. It stores authenticated
|
|
71
81
|
SMTP metadata separately from MIME; applications read it using
|
|
72
82
|
`Cloudflare::Email::Envelope.for(inbound_email)`. Duplicate detection includes the
|
|
73
83
|
exact SMTP recipient, preserving separate To/Cc/Bcc deliveries. The format accepts
|
|
74
84
|
ASCII dot-atom addresses up to 254 bytes (local part up to 64 bytes), plus an empty
|
|
75
85
|
sender for bounce messages. Other address forms are rejected before forwarding.
|
|
76
86
|
|
|
87
|
+
## Reuse an existing Worker
|
|
88
|
+
|
|
89
|
+
Keep your existing archiving, sender checks, and fallback policy. Copy this
|
|
90
|
+
template's `src/index.js` into your Worker project and import either helper.
|
|
91
|
+
`forwardEmail(message, env)` uses the same bounded reading, URL checks, timeout,
|
|
92
|
+
and rejection policy as the explicit direct fallback. Optional metadata selects v3 signing:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
import { forwardEmail } from "./cloudflare-ingress.js";
|
|
96
|
+
|
|
97
|
+
export default {
|
|
98
|
+
async email(message, env) {
|
|
99
|
+
// Supplied by your integration after validating its trusted provider context.
|
|
100
|
+
// This is your adapter function, not a Cloudflare EmailMessage API.
|
|
101
|
+
const providerData = await yourValidatedProviderContext(message, env);
|
|
102
|
+
return forwardEmail(message, env, {
|
|
103
|
+
metadata: { source: "your-provider", data: providerData },
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The bundled Worker does not collect provider authentication results. Never treat
|
|
110
|
+
`Authentication-Results`, `Received-SPF`, or other sender-supplied MIME headers as
|
|
111
|
+
trusted provider context. Signing metadata authenticates your Worker's assertion;
|
|
112
|
+
it does not prove DMARC passed or authorize the original sender. Your application
|
|
113
|
+
still owns sender policy, organization resolution, review, and document processing.
|
|
114
|
+
|
|
115
|
+
If your Worker already owns transport and error handling, use the header builder:
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
import { signedEmailHeaders } from "./cloudflare-ingress.js";
|
|
119
|
+
|
|
120
|
+
const headers = await signedEmailHeaders({
|
|
121
|
+
secret: env.INGRESS_SECRET,
|
|
122
|
+
raw, // Original Uint8Array, obtained using your own bounded reader.
|
|
123
|
+
from: message.from,
|
|
124
|
+
to: message.to,
|
|
125
|
+
metadata: { source: "your-provider", data: providerData },
|
|
126
|
+
});
|
|
127
|
+
// Send these headers with exactly `raw`, using your existing transport.
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The builder performs no network calls. You own destination validation, body size
|
|
131
|
+
limits, timeouts, redirects, retries, and response handling. An optional `timestamp`
|
|
132
|
+
in Unix seconds supports deterministic testing; production normally uses the
|
|
133
|
+
default current time. Omitting `metadata` produces unchanged v2 headers. Invalid
|
|
134
|
+
metadata throws from the builder; the forwarding helper rejects instead of
|
|
135
|
+
silently falling back to v2.
|
|
136
|
+
|
|
137
|
+
Metadata must contain exactly `source` and `data`. Source matches
|
|
138
|
+
`[a-z][a-z0-9_.-]{0,127}`; data is a JSON object, with only JSON values and at most
|
|
139
|
+
eight container levels including the outer metadata object. The UTF8 JSON is
|
|
140
|
+
encoded as unpadded base64url, limited to 16,384 encoded characters, and sent as
|
|
141
|
+
`X-CF-Email-Metadata`. Version 3 signs the exact bytes:
|
|
142
|
+
|
|
143
|
+
```text
|
|
144
|
+
HMAC-SHA256(secret, "v3.{timestamp}.{encoded_envelope}.{encoded_metadata}.{raw_body}")
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Neither the MIME nor its headers are rewritten. Upgrade Rails to a version that
|
|
148
|
+
supports v3 before enabling metadata in a custom Worker. Keep sensitive provider
|
|
149
|
+
data out of metadata unless your application's retention and access policies
|
|
150
|
+
permit storing it with email records.
|
|
151
|
+
|
|
152
|
+
## Reuse the transport in an existing Worker
|
|
153
|
+
|
|
154
|
+
`relayEmail` provides bounded reading, an optional archive, backend selection and
|
|
155
|
+
one HTTP delivery attempt. Your Worker decides how a result maps to SMTP
|
|
156
|
+
rejection, retry, fallback forwarding or logging. It does not call `setReject`
|
|
157
|
+
or `forward` and does not extract or interpret sender authentication evidence.
|
|
158
|
+
`forwardEmail` remains the explicit direct helper; the default export retains mail durably.
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
import { relayEmail, archiveEmail, signedEmailHeaders } from "./cloudflare-ingress.js";
|
|
162
|
+
|
|
163
|
+
export default {
|
|
164
|
+
async email(message, env) {
|
|
165
|
+
const outcome = await relayEmail(message, {
|
|
166
|
+
maxEmailBytes: 25 * 1024 * 1024,
|
|
167
|
+
timeoutMs: 15_000,
|
|
168
|
+
archiveTimeoutMs: 10_000,
|
|
169
|
+
// Omit archive when no bucket is configured. Key policy belongs to your app.
|
|
170
|
+
archive: env.EMAIL_ARCHIVE ? (args) => archiveEmail({
|
|
171
|
+
...args, bucket: env.EMAIL_ARCHIVE,
|
|
172
|
+
key: `email/${crypto.randomUUID()}.eml`,
|
|
173
|
+
}) : undefined,
|
|
174
|
+
accepts: async ({ to }) => to.endsWith("@inbound.example.com"),
|
|
175
|
+
// Select only host-configured backends; never use a URL from an email header.
|
|
176
|
+
resolveBackend: async ({ from, to }) => ({
|
|
177
|
+
url: env.RAILS_INGRESS_URL, secret: env.INGRESS_SECRET,
|
|
178
|
+
}),
|
|
179
|
+
headers: ({ raw, from, to, backend }) => signedEmailHeaders({
|
|
180
|
+
raw, from, to, secret: backend.secret,
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
183
|
+
// Example host policy: permanent rejection only for invalid/unaccepted mail;
|
|
184
|
+
// Throwing surfaces failure; do not assume Cloudflare retries SMTP delivery.
|
|
185
|
+
if (outcome.status === "rejected") message.setReject("email not accepted");
|
|
186
|
+
if (outcome.status === "failed") throw new Error("email relay unavailable");
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The relay reads the original stream once, enforcing the actual byte limit even
|
|
192
|
+
when `rawSize` underreports it. It archives before checking `accepts` and before
|
|
193
|
+
resolving the backend, so an archive can retain mail rejected by host policy.
|
|
194
|
+
Invalid envelopes, unreadable streams and oversized messages are not archived.
|
|
195
|
+
An archive exception or timeout does not prevent delivery: the result has
|
|
196
|
+
`archiveFailed: true`. The archive deadline defaults to 10 seconds and can be
|
|
197
|
+
changed with `archiveTimeoutMs`. It stops waiting but cannot cancel an R2 write:
|
|
198
|
+
a timed-out archive may still finish later if the runtime remains alive. Monitor
|
|
199
|
+
that flag if the archive is your outage recovery mechanism. Other callback
|
|
200
|
+
implementations must finish within the Worker's runtime limits; the `timeoutMs`
|
|
201
|
+
option applies to the HTTP request only.
|
|
202
|
+
|
|
203
|
+
`accepts` must return exactly `true` to accept. `resolveBackend` returns an object
|
|
204
|
+
with `url` and any additional host configuration needed by `headers`. Backend
|
|
205
|
+
URLs must use HTTPS, or HTTP on localhost, `127.0.0.1` or `[::1]`; embedded
|
|
206
|
+
credentials and fragments are rejected. Generic relay envelope checks enforce
|
|
207
|
+
bounded strings without control characters, leaving address shape to the host.
|
|
208
|
+
`signedEmailHeaders` retains the gem ingress's stricter ASCII address validation.
|
|
209
|
+
|
|
210
|
+
The `headers` callback receives `{ raw, from, to, backend, archive }`, where
|
|
211
|
+
`archive` is the archive callback's return value, or undefined on omission or
|
|
212
|
+
failure. It can return a header object or `Headers`, including Basic credentials
|
|
213
|
+
for an existing custom endpoint. The relay sends exactly `raw`, follows no
|
|
214
|
+
redirects, cancels response bodies without reading them, and never includes
|
|
215
|
+
backend response content or callback exception text in results. Callbacks are
|
|
216
|
+
trusted host code: preserve the supplied bytes and keep secrets out of logs.
|
|
217
|
+
Do not put an archive key or other per-attempt value into signed provider metadata;
|
|
218
|
+
doing so changes the persisted message identity on retries.
|
|
219
|
+
|
|
220
|
+
Every result has `status`, `reason` and `archiveFailed`. HTTP responses also add
|
|
221
|
+
`httpStatus`:
|
|
222
|
+
|
|
223
|
+
| Status | Reasons |
|
|
224
|
+
| --- | --- |
|
|
225
|
+
| `delivered` | `delivered` (HTTP 2xx) |
|
|
226
|
+
| `rejected` | `invalid_envelope`, `too_large`, `not_accepted` |
|
|
227
|
+
| `failed` | `unreadable`, `invalid_options`, `acceptance_failed`, `backend_failed`, `invalid_backend`, `headers_failed`, `timeout`, `fetch_failed`, `http_status` |
|
|
228
|
+
|
|
229
|
+
All non-2xx responses, including redirects and permanent client errors, return
|
|
230
|
+
`failed`/`http_status`; your host chooses which are permanent, retryable or eligible
|
|
231
|
+
for fallback. The relay never retries on its own.
|
|
232
|
+
|
|
233
|
+
`archiveEmail({ bucket, key, raw, from, to })` writes raw RFC822 bytes to an R2
|
|
234
|
+
binding and returns `{ key }`. Keys must be nonempty, at most 1,024 UTF8 bytes and
|
|
235
|
+
contain no ASCII control characters. It stores `message/rfc822` content type and
|
|
236
|
+
ASCII-safe `from`, `to` and `size` custom metadata; non-ASCII envelope characters
|
|
237
|
+
become `?` in this display metadata only. Retention, unique keys, archive browsing
|
|
238
|
+
and recovery authorization belong to the host. Rails email persistence starts
|
|
239
|
+
after delivery and does not replace this optional outage archive.
|
|
240
|
+
|
|
241
|
+
## Durable inbound delivery
|
|
242
|
+
|
|
243
|
+
[Durable inbound delivery](./docs/durable-inbound.md) is the default. Provision the
|
|
244
|
+
configured private R2 bucket, Queue producer/consumer and cron before deploying.
|
|
245
|
+
There is no rollout enable flag or automatic downgrade when bindings are missing.
|
|
246
|
+
The guide covers upgrades, monitoring, recovery tests and explicit direct fallback.
|
|
247
|
+
|
|
77
248
|
## Validate changes
|
|
78
249
|
|
|
79
250
|
```sh
|