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,193 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import worker, { retainEmail, deliverRetainedEmail, consumeRetainedEmails, sweepRetainedEmails } from "../src/index.js";
|
|
3
|
+
|
|
4
|
+
const prefix = "cloudflare-email/pending/";
|
|
5
|
+
const raw = new Uint8Array([70, 114, 111, 109, 58, 32, 120, 13, 10, 13, 10, 0, 255]);
|
|
6
|
+
function message() {
|
|
7
|
+
return { from: "sender@example.com", to: "inbox@example.com", rawSize: raw.length,
|
|
8
|
+
raw: new Response(raw).body, setReject: vi.fn() };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function setup() {
|
|
12
|
+
const objects = new Map<string, Uint8Array>();
|
|
13
|
+
const bucket = {
|
|
14
|
+
put: vi.fn(async (key: string, value: Uint8Array | string) => {
|
|
15
|
+
objects.set(key, typeof value === "string" ? new TextEncoder().encode(value) : value.slice());
|
|
16
|
+
}),
|
|
17
|
+
get: vi.fn(async (key: string) => {
|
|
18
|
+
const bytes = objects.get(key);
|
|
19
|
+
return bytes ? { size: bytes.length, body: new Response(bytes).body,
|
|
20
|
+
text: async () => new TextDecoder().decode(bytes) } : null;
|
|
21
|
+
}),
|
|
22
|
+
delete: vi.fn(async (key: string) => { objects.delete(key); }),
|
|
23
|
+
list: vi.fn(async ({ prefix, cursor, limit }) => {
|
|
24
|
+
const keys = [...objects.keys()].filter(key => key.startsWith(prefix) && (!cursor || key > cursor)).sort();
|
|
25
|
+
const page = keys.slice(0, limit);
|
|
26
|
+
return { objects: page.map(key => ({ key })), truncated: keys.length > limit, cursor: page.at(-1) };
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
const env = { RAILS_INGRESS_URL: "https://rails.example.com/ingress",
|
|
30
|
+
INGRESS_SECRET: "secret", INBOUND_EMAIL_STORE: bucket, INBOUND_EMAIL_QUEUE: { send: vi.fn() } };
|
|
31
|
+
return { env, bucket, objects };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("durable inbound", () => {
|
|
35
|
+
beforeEach(() => { vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 204 })));
|
|
36
|
+
vi.spyOn(console, "warn").mockImplementation(() => {}); });
|
|
37
|
+
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); });
|
|
38
|
+
|
|
39
|
+
it("commits raw and context before acknowledgement and queues only a pointer", async () => {
|
|
40
|
+
const { env, bucket, objects } = setup();
|
|
41
|
+
env.INBOUND_EMAIL_QUEUE.send.mockImplementation(async ({ key }) => { expect(objects.has(key)).toBe(true); });
|
|
42
|
+
const result = await worker.email(message(), env);
|
|
43
|
+
expect(bucket.put).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(env.INBOUND_EMAIL_QUEUE.send).toHaveBeenCalledWith({ version: 1, key: result.key });
|
|
45
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("does not acknowledge failed storage or enqueue a missing payload", async () => {
|
|
49
|
+
const { env, bucket } = setup();
|
|
50
|
+
bucket.put.mockRejectedValue(new Error("secret failure"));
|
|
51
|
+
const archive = vi.fn();
|
|
52
|
+
await expect(retainEmail(message(), env, { archive })).rejects.toThrow("durable storage unavailable");
|
|
53
|
+
expect(archive).not.toHaveBeenCalled();
|
|
54
|
+
expect(env.INBOUND_EMAIL_QUEUE.send).not.toHaveBeenCalled();
|
|
55
|
+
expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain("secret failure");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("fails closed when durable infrastructure is missing or mode is misspelled", async () => {
|
|
59
|
+
const { env } = setup();
|
|
60
|
+
await expect(worker.email(message(), { ...env, INBOUND_EMAIL_STORE: undefined })).rejects.toThrow("configuration invalid");
|
|
61
|
+
expect(() => worker.email(message(), { ...env, INBOUND_DELIVERY_MODE: "durabel" })).toThrow("INBOUND_DELIVERY_MODE");
|
|
62
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("direct fallback still drains the existing R2 backlog through scheduled recovery", async () => {
|
|
66
|
+
const { env, objects } = setup();
|
|
67
|
+
await worker.email(message(), env);
|
|
68
|
+
const fallback = { ...env, INBOUND_DELIVERY_MODE: "direct" };
|
|
69
|
+
await worker.email(message(), fallback);
|
|
70
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
71
|
+
const tasks = [];
|
|
72
|
+
worker.scheduled({}, fallback, { waitUntil: task => tasks.push(task) });
|
|
73
|
+
await Promise.all(tasks);
|
|
74
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
75
|
+
expect(objects.size).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it.each(["error", "timeout"])("retains and enqueues when a secondary archive has an %s", async failure => {
|
|
79
|
+
const { env, objects } = setup();
|
|
80
|
+
vi.useFakeTimers();
|
|
81
|
+
const archive = vi.fn(async ({ raw: bytes, from, to, key }) => {
|
|
82
|
+
expect(objects.has(key)).toBe(true);
|
|
83
|
+
expect(bytes).toEqual(raw);
|
|
84
|
+
expect(from).toBe("sender@example.com");
|
|
85
|
+
expect(to).toBe("inbox@example.com");
|
|
86
|
+
if (failure === "error") throw new Error("private archive error");
|
|
87
|
+
await new Promise(() => {});
|
|
88
|
+
});
|
|
89
|
+
const pending = retainEmail(message(), env, { archive });
|
|
90
|
+
await vi.advanceTimersByTimeAsync(10_001);
|
|
91
|
+
const { key } = await pending;
|
|
92
|
+
expect(archive).toHaveBeenCalledTimes(1);
|
|
93
|
+
expect(objects.has(key)).toBe(true);
|
|
94
|
+
expect(env.INBOUND_EMAIL_QUEUE.send).toHaveBeenCalledWith({ version: 1, key });
|
|
95
|
+
expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain("private archive error");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("honors the host archive deadline without delaying durable queue delivery", async () => {
|
|
99
|
+
const { env } = setup();
|
|
100
|
+
vi.useFakeTimers();
|
|
101
|
+
const pending = retainEmail(message(), env, {
|
|
102
|
+
archive: () => new Promise(() => {}), archiveTimeoutMs: 5,
|
|
103
|
+
});
|
|
104
|
+
await vi.advanceTimersByTimeAsync(6);
|
|
105
|
+
const { key } = await pending;
|
|
106
|
+
expect(env.INBOUND_EMAIL_QUEUE.send).toHaveBeenCalledWith({ version: 1, key });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it.each([0, -1, 1.5, 120_001, NaN, Infinity, "5"])("rejects invalid archive timeout %s before accepting mail", async archiveTimeoutMs => {
|
|
110
|
+
const { env, bucket } = setup();
|
|
111
|
+
await expect(retainEmail(message(), env, { archiveTimeoutMs })).rejects.toThrow("inbound archive timeout");
|
|
112
|
+
expect(bucket.put).not.toHaveBeenCalled();
|
|
113
|
+
expect(env.INBOUND_EMAIL_QUEUE.send).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("recovers failed enqueue through scheduled scanning", async () => {
|
|
117
|
+
const { env, objects } = setup();
|
|
118
|
+
env.INBOUND_EMAIL_QUEUE.send.mockRejectedValue(new Error("unavailable"));
|
|
119
|
+
const { key } = await retainEmail(message(), env);
|
|
120
|
+
expect(objects.has(key)).toBe(true);
|
|
121
|
+
await sweepRetainedEmails(env);
|
|
122
|
+
expect(objects.has(key)).toBe(false);
|
|
123
|
+
expect(new Uint8Array(vi.mocked(fetch).mock.calls[0][1].body)).toEqual(raw);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it.each([401, 413, 429, 500, 503, 302])("retains HTTP %s until recovery, even after queue exhaustion", async status => {
|
|
127
|
+
const { env, objects } = setup();
|
|
128
|
+
const { key } = await retainEmail(message(), env);
|
|
129
|
+
vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status }));
|
|
130
|
+
const item = { body: { version: 1, key }, ack: vi.fn(), retry: vi.fn() };
|
|
131
|
+
await consumeRetainedEmails({ messages: [item] }, env);
|
|
132
|
+
expect(item.ack).not.toHaveBeenCalled();
|
|
133
|
+
expect(item.retry).toHaveBeenCalled();
|
|
134
|
+
expect(objects.has(key)).toBe(true);
|
|
135
|
+
// No queue redelivery: recovery depends exclusively on the scheduled sweep.
|
|
136
|
+
await sweepRetainedEmails(env);
|
|
137
|
+
expect(objects.has(key)).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("replays lost HTTP acknowledgements with identical bytes and metadata but fresh signatures", async () => {
|
|
141
|
+
const { env, objects } = setup();
|
|
142
|
+
const { key } = await retainEmail(message(), env, { metadata: { source: "provider", data: { auth: "pass" } } });
|
|
143
|
+
vi.spyOn(Date, "now").mockReturnValue(1_800_000_000_000);
|
|
144
|
+
vi.mocked(fetch).mockRejectedValueOnce(new Error("lost response"));
|
|
145
|
+
await expect(deliverRetainedEmail(key, env)).rejects.toThrow("handoff incomplete");
|
|
146
|
+
expect(objects.has(key)).toBe(true);
|
|
147
|
+
vi.mocked(Date.now).mockReturnValue(1_800_000_600_000);
|
|
148
|
+
await deliverRetainedEmail(key, env);
|
|
149
|
+
const [first, second] = vi.mocked(fetch).mock.calls.map(call => call[1]);
|
|
150
|
+
expect(first.body).toEqual(second.body);
|
|
151
|
+
expect(first.headers["X-CF-Email-Metadata"]).toEqual(second.headers["X-CF-Email-Metadata"]);
|
|
152
|
+
expect(first.headers["X-CF-Email-Envelope"]).toEqual(second.headers["X-CF-Email-Envelope"]);
|
|
153
|
+
expect(first.headers["X-CF-Email-Signature"]).not.toEqual(second.headers["X-CF-Email-Signature"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("does not ACK a failed completion delete; missing completed keys ACK without resending", async () => {
|
|
157
|
+
const { env, bucket, objects } = setup();
|
|
158
|
+
const { key } = await retainEmail(message(), env);
|
|
159
|
+
bucket.delete.mockRejectedValueOnce(new Error("unavailable"));
|
|
160
|
+
const item = { body: { version: 1, key }, ack: vi.fn(), retry: vi.fn() };
|
|
161
|
+
await consumeRetainedEmails({ messages: [item] }, env);
|
|
162
|
+
expect(item.ack).not.toHaveBeenCalled();
|
|
163
|
+
expect(objects.has(key)).toBe(true);
|
|
164
|
+
await consumeRetainedEmails({ messages: [item] }, env);
|
|
165
|
+
await consumeRetainedEmails({ messages: [item] }, env);
|
|
166
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
167
|
+
expect(item.ack).toHaveBeenCalledTimes(2);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("advances pages past poison entries and wraps to retry them", async () => {
|
|
171
|
+
const { env, objects, bucket } = setup();
|
|
172
|
+
for (let i = 0; i < 12; i++) await retainEmail(message(), env);
|
|
173
|
+
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 503 }));
|
|
174
|
+
await sweepRetainedEmails(env);
|
|
175
|
+
await sweepRetainedEmails(env);
|
|
176
|
+
await sweepRetainedEmails(env);
|
|
177
|
+
expect(fetch).toHaveBeenCalledTimes(12);
|
|
178
|
+
expect([...objects.keys()].filter(key => key.startsWith(prefix))).toHaveLength(12);
|
|
179
|
+
expect(objects.has("cloudflare-email/state/sweep")).toBe(false);
|
|
180
|
+
await sweepRetainedEmails(env);
|
|
181
|
+
expect(fetch).toHaveBeenCalledTimes(17);
|
|
182
|
+
expect(bucket.list.mock.calls[1][0].cursor).toBeTruthy();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("rejects oversize messages without retaining incomplete bodies", async () => {
|
|
186
|
+
const { env, objects } = setup();
|
|
187
|
+
const input = message();
|
|
188
|
+
input.rawSize = 0;
|
|
189
|
+
await retainEmail(input, { ...env, MAX_EMAIL_BYTES: "2" });
|
|
190
|
+
expect(input.setReject).toHaveBeenCalledWith("message exceeds size limit");
|
|
191
|
+
expect(objects.size).toBe(0);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -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
|
+
});
|