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,309 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { forwardEmail, signedEmailHeaders } from "../src/index.js";
|
|
3
|
+
|
|
4
|
+
// Minimal fake EmailMessage implementing the surface the Worker uses.
|
|
5
|
+
function makeMessage(raw: string) {
|
|
6
|
+
const rawBytes = new TextEncoder().encode(raw);
|
|
7
|
+
const rejects: string[] = [];
|
|
8
|
+
return {
|
|
9
|
+
message: {
|
|
10
|
+
from: "sender@external.test",
|
|
11
|
+
to: "inbox@trial.test",
|
|
12
|
+
raw: new ReadableStream({
|
|
13
|
+
start(c) { c.enqueue(rawBytes); c.close(); },
|
|
14
|
+
}),
|
|
15
|
+
rawSize: rawBytes.byteLength,
|
|
16
|
+
setReject(reason: string) { rejects.push(reason); },
|
|
17
|
+
},
|
|
18
|
+
rejects,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function verifyHmac(secret: string, ts: string, envelope: string, body: ArrayBuffer, hex: string) {
|
|
23
|
+
const enc = new TextEncoder();
|
|
24
|
+
const prefix = enc.encode(`v2.${ts}.${envelope}.`);
|
|
25
|
+
const signed = new Uint8Array(prefix.length + body.byteLength);
|
|
26
|
+
signed.set(prefix, 0);
|
|
27
|
+
signed.set(new Uint8Array(body), prefix.length);
|
|
28
|
+
|
|
29
|
+
const key = await crypto.subtle.importKey(
|
|
30
|
+
"raw",
|
|
31
|
+
enc.encode(secret),
|
|
32
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
33
|
+
false,
|
|
34
|
+
["verify"],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
38
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
39
|
+
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return crypto.subtle.verify("HMAC", key, bytes, signed);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("cloudflare-email Worker", () => {
|
|
46
|
+
const RAW = "From: a@b.com\r\nTo: c@d.com\r\nSubject: hi\r\n\r\nBody line.\r\n";
|
|
47
|
+
const SECRET = "worker-test-secret-abc123";
|
|
48
|
+
const URL_ = "https://rails.test/rails/action_mailbox/cloudflare/inbound_emails";
|
|
49
|
+
|
|
50
|
+
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
fetchSpy = vi.fn(async () => new Response("", { status: 200 }));
|
|
54
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
vi.restoreAllMocks();
|
|
59
|
+
vi.unstubAllGlobals();
|
|
60
|
+
vi.useRealTimers();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("POSTs raw MIME with HMAC signature and timestamp", async () => {
|
|
64
|
+
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
|
|
65
|
+
const { message } = makeMessage(RAW);
|
|
66
|
+
|
|
67
|
+
await forwardEmail(message as any, env);
|
|
68
|
+
|
|
69
|
+
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
70
|
+
const [url, opts] = fetchSpy.mock.calls[0];
|
|
71
|
+
expect(url).toBe(URL_);
|
|
72
|
+
expect(opts.method).toBe("POST");
|
|
73
|
+
expect(opts.headers["Content-Type"]).toBe("message/rfc822");
|
|
74
|
+
const ts = opts.headers["X-CF-Email-Timestamp"];
|
|
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
|
+
});
|
|
82
|
+
expect(ts).toMatch(/^\d+$/);
|
|
83
|
+
expect(sig).toMatch(/^[0-9a-f]{64}$/);
|
|
84
|
+
|
|
85
|
+
// Body bytes match the input.
|
|
86
|
+
const sentBytes = opts.body as Uint8Array;
|
|
87
|
+
expect(new TextDecoder().decode(sentBytes)).toBe(RAW);
|
|
88
|
+
|
|
89
|
+
// And the signature verifies against the input.
|
|
90
|
+
const ok = await verifyHmac(SECRET, ts, envelope, sentBytes.buffer, sig);
|
|
91
|
+
expect(ok).toBe(true);
|
|
92
|
+
});
|
|
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
|
+
|
|
134
|
+
it("rejects the message when upstream returns non-2xx", async () => {
|
|
135
|
+
fetchSpy.mockResolvedValueOnce(new Response("server error", { status: 503 }));
|
|
136
|
+
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
|
|
137
|
+
const { message, rejects } = makeMessage(RAW);
|
|
138
|
+
|
|
139
|
+
await forwardEmail(message as any, env);
|
|
140
|
+
|
|
141
|
+
expect(rejects).toEqual(["upstream returned 503"]);
|
|
142
|
+
});
|
|
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
|
+
|
|
177
|
+
it("rejects the message when RAILS_INGRESS_URL is missing", async () => {
|
|
178
|
+
const env = { RAILS_INGRESS_URL: "", INGRESS_SECRET: SECRET };
|
|
179
|
+
const { message, rejects } = makeMessage(RAW);
|
|
180
|
+
|
|
181
|
+
await forwardEmail(message as any, env);
|
|
182
|
+
|
|
183
|
+
expect(rejects[0]).toMatch(/missing RAILS_INGRESS_URL/);
|
|
184
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("rejects the message when INGRESS_SECRET is missing", async () => {
|
|
188
|
+
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: "" };
|
|
189
|
+
const { message, rejects } = makeMessage(RAW);
|
|
190
|
+
|
|
191
|
+
await forwardEmail(message as any, env);
|
|
192
|
+
|
|
193
|
+
expect(rejects[0]).toMatch(/missing .*INGRESS_SECRET/);
|
|
194
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("rejects when the upstream fetch throws", async () => {
|
|
198
|
+
fetchSpy.mockRejectedValueOnce(new Error("DNS fail"));
|
|
199
|
+
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
|
|
200
|
+
const { message, rejects } = makeMessage(RAW);
|
|
201
|
+
|
|
202
|
+
await forwardEmail(message as any, env);
|
|
203
|
+
|
|
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();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("signature covers tampered bodies differently", async () => {
|
|
240
|
+
// Sanity check: two different bodies produce two different signatures.
|
|
241
|
+
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
|
|
242
|
+
|
|
243
|
+
await forwardEmail(makeMessage(RAW).message as any, env);
|
|
244
|
+
const sig1 = fetchSpy.mock.calls[0][1].headers["X-CF-Email-Signature"];
|
|
245
|
+
|
|
246
|
+
fetchSpy.mockClear();
|
|
247
|
+
await forwardEmail(makeMessage(RAW + "tamper").message as any, env);
|
|
248
|
+
const sig2 = fetchSpy.mock.calls[0][1].headers["X-CF-Email-Signature"];
|
|
249
|
+
|
|
250
|
+
expect(sig1).not.toBe(sig2);
|
|
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
|
+
});
|
|
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,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 = ["* * * * *"]
|