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,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
|
+
});
|
|
@@ -1,10 +1,69 @@
|
|
|
1
|
-
name = "cloudflare-email-ingress"
|
|
1
|
+
name = "cloudflare-email-ingress-unconfigured"
|
|
2
2
|
main = "src/index.js"
|
|
3
|
-
compatibility_date = "2026-
|
|
3
|
+
compatibility_date = "2026-09-10"
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[env.development]
|
|
6
|
+
name = "cloudflare-email-ingress-development"
|
|
7
|
+
|
|
8
|
+
[env.staging]
|
|
9
|
+
name = "cloudflare-email-ingress-staging"
|
|
10
|
+
|
|
11
|
+
[env.production]
|
|
12
|
+
name = "cloudflare-email-ingress-production"
|
|
13
|
+
|
|
14
|
+
# Required secrets (set via `npx wrangler secret put NAME --env production`):
|
|
6
15
|
# RAILS_INGRESS_URL - https://your-rails-app.example.com/rails/action_mailbox/cloudflare/inbound_emails
|
|
7
16
|
# INGRESS_SECRET - shared secret matching cloudflare.ingress_secret in Rails credentials
|
|
8
17
|
|
|
9
18
|
# After deploying, in the Cloudflare dashboard:
|
|
10
|
-
# Email Routing -> Routes -> "Send to a Worker" -> select
|
|
19
|
+
# Email Routing -> Routes -> "Send to a Worker" -> select the matching environment Worker
|
|
20
|
+
|
|
21
|
+
# Durable delivery is the default. Create these private resources before deploy.
|
|
22
|
+
# See docs/durable-inbound.md. Buckets must not expire pending objects.
|
|
23
|
+
# Explicit emergency fallback: set INBOUND_DELIVERY_MODE = "direct" in the
|
|
24
|
+
# chosen environment's [env.<name>.vars]. Keep recovery bindings and cron.
|
|
25
|
+
|
|
26
|
+
[[env.development.r2_buckets]]
|
|
27
|
+
binding = "INBOUND_EMAIL_STORE"
|
|
28
|
+
bucket_name = "cloudflare-email-inbound-development"
|
|
29
|
+
[[env.development.queues.producers]]
|
|
30
|
+
binding = "INBOUND_EMAIL_QUEUE"
|
|
31
|
+
queue = "cloudflare-email-inbound-development"
|
|
32
|
+
[[env.development.queues.consumers]]
|
|
33
|
+
queue = "cloudflare-email-inbound-development"
|
|
34
|
+
max_batch_size = 5
|
|
35
|
+
max_batch_timeout = 5
|
|
36
|
+
max_retries = 10
|
|
37
|
+
retry_delay = 300
|
|
38
|
+
[env.development.triggers]
|
|
39
|
+
crons = ["* * * * *"]
|
|
40
|
+
|
|
41
|
+
[[env.staging.r2_buckets]]
|
|
42
|
+
binding = "INBOUND_EMAIL_STORE"
|
|
43
|
+
bucket_name = "cloudflare-email-inbound-staging"
|
|
44
|
+
[[env.staging.queues.producers]]
|
|
45
|
+
binding = "INBOUND_EMAIL_QUEUE"
|
|
46
|
+
queue = "cloudflare-email-inbound-staging"
|
|
47
|
+
[[env.staging.queues.consumers]]
|
|
48
|
+
queue = "cloudflare-email-inbound-staging"
|
|
49
|
+
max_batch_size = 5
|
|
50
|
+
max_batch_timeout = 5
|
|
51
|
+
max_retries = 10
|
|
52
|
+
retry_delay = 300
|
|
53
|
+
[env.staging.triggers]
|
|
54
|
+
crons = ["* * * * *"]
|
|
55
|
+
|
|
56
|
+
[[env.production.r2_buckets]]
|
|
57
|
+
binding = "INBOUND_EMAIL_STORE"
|
|
58
|
+
bucket_name = "cloudflare-email-inbound-production"
|
|
59
|
+
[[env.production.queues.producers]]
|
|
60
|
+
binding = "INBOUND_EMAIL_QUEUE"
|
|
61
|
+
queue = "cloudflare-email-inbound-production"
|
|
62
|
+
[[env.production.queues.consumers]]
|
|
63
|
+
queue = "cloudflare-email-inbound-production"
|
|
64
|
+
max_batch_size = 5
|
|
65
|
+
max_batch_timeout = 5
|
|
66
|
+
max_retries = 10
|
|
67
|
+
retry_delay = 300
|
|
68
|
+
[env.production.triggers]
|
|
69
|
+
crons = ["* * * * *"]
|
metadata
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cloudflare-email
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cole
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.2'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '1.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '0.2'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '1.0'
|
|
12
32
|
- !ruby/object:Gem::Dependency
|
|
13
33
|
name: minitest
|
|
14
34
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -57,7 +77,7 @@ dependencies:
|
|
|
57
77
|
requirements:
|
|
58
78
|
- - ">="
|
|
59
79
|
- !ruby/object:Gem::Version
|
|
60
|
-
version:
|
|
80
|
+
version: 7.2.3.2
|
|
61
81
|
- - "<"
|
|
62
82
|
- !ruby/object:Gem::Version
|
|
63
83
|
version: '9.0'
|
|
@@ -67,7 +87,7 @@ dependencies:
|
|
|
67
87
|
requirements:
|
|
68
88
|
- - ">="
|
|
69
89
|
- !ruby/object:Gem::Version
|
|
70
|
-
version:
|
|
90
|
+
version: 7.2.3.2
|
|
71
91
|
- - "<"
|
|
72
92
|
- !ruby/object:Gem::Version
|
|
73
93
|
version: '9.0'
|
|
@@ -85,6 +105,26 @@ dependencies:
|
|
|
85
105
|
- - "~>"
|
|
86
106
|
- !ruby/object:Gem::Version
|
|
87
107
|
version: '2.1'
|
|
108
|
+
- !ruby/object:Gem::Dependency
|
|
109
|
+
name: json
|
|
110
|
+
requirement: !ruby/object:Gem::Requirement
|
|
111
|
+
requirements:
|
|
112
|
+
- - ">="
|
|
113
|
+
- !ruby/object:Gem::Version
|
|
114
|
+
version: '2.0'
|
|
115
|
+
- - "<"
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: '3.0'
|
|
118
|
+
type: :development
|
|
119
|
+
prerelease: false
|
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - ">="
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: '2.0'
|
|
125
|
+
- - "<"
|
|
126
|
+
- !ruby/object:Gem::Version
|
|
127
|
+
version: '3.0'
|
|
88
128
|
description: A Ruby client and Rails integration for Cloudflare's Email Service. Send
|
|
89
129
|
mail via an ActionMailer delivery method, receive mail via an ActionMailbox ingress
|
|
90
130
|
backed by a shipped Cloudflare Email Worker template.
|
|
@@ -97,35 +137,142 @@ files:
|
|
|
97
137
|
- CHANGELOG.md
|
|
98
138
|
- LICENSE.txt
|
|
99
139
|
- README.md
|
|
140
|
+
- SECURITY.md
|
|
100
141
|
- app/controllers/cloudflare/email/ingress_controller.rb
|
|
142
|
+
- app/controllers/cloudflare/email/management/mailboxes_controller.rb
|
|
143
|
+
- app/controllers/cloudflare/email/management/styles_controller.rb
|
|
144
|
+
- app/views/cloudflare/email/management/mailboxes/index.html.erb
|
|
145
|
+
- app/views/cloudflare/email/management/mailboxes/message.html.erb
|
|
146
|
+
- app/views/cloudflare/email/management/mailboxes/show.html.erb
|
|
147
|
+
- app/views/layouts/cloudflare/email/management.html.erb
|
|
148
|
+
- docs/activerecord-tenanted.md
|
|
149
|
+
- docs/architecture.md
|
|
150
|
+
- docs/custom-ingress.md
|
|
151
|
+
- docs/delivery-events.md
|
|
152
|
+
- docs/features.md
|
|
153
|
+
- docs/getting-started.md
|
|
154
|
+
- docs/mailboxes.md
|
|
155
|
+
- docs/management-engine.md
|
|
156
|
+
- docs/outbox.md
|
|
157
|
+
- docs/routing-deliveries.md
|
|
158
|
+
- docs/routing-diagnostics.md
|
|
159
|
+
- docs/tenant-mailboxes-plan.md
|
|
160
|
+
- docs/thread-correlation.md
|
|
161
|
+
- docs/troubleshooting.md
|
|
162
|
+
- docs/upgrading-0.2.md
|
|
163
|
+
- docs/upgrading-0.3.md
|
|
164
|
+
- docs/verification/2026-09-10-followup.md
|
|
165
|
+
- docs/verification/2026-09-10-install-upgrade.md
|
|
166
|
+
- docs/verification/2026-09-10-live.md
|
|
167
|
+
- docs/verification/2026-09-10.md
|
|
168
|
+
- docs/verification/2026-09-11-custom-ingress.md
|
|
169
|
+
- docs/verification/2026-09-11-extraction.md
|
|
170
|
+
- docs/verification/2026-09-11-outbound-ledger.md
|
|
171
|
+
- docs/verification/2026-09-11-security.md
|
|
172
|
+
- docs/verification/2026-09-11-tenant-mailboxes.md
|
|
173
|
+
- docs/verification/2026-09-12-destination-context.md
|
|
174
|
+
- docs/verification/2026-09-13-durable-inbound-and-routing.md
|
|
175
|
+
- examples/plain_ruby.rb
|
|
101
176
|
- lib/cloudflare-email.rb
|
|
177
|
+
- lib/cloudflare/email/active_record.rb
|
|
178
|
+
- lib/cloudflare/email/active_record/base.rb
|
|
179
|
+
- lib/cloudflare/email/active_record/delivery_events.rb
|
|
180
|
+
- lib/cloudflare/email/active_record/event_inbox.rb
|
|
181
|
+
- lib/cloudflare/email/active_record/event_receipt.rb
|
|
182
|
+
- lib/cloudflare/email/active_record/mail_snapshot.rb
|
|
183
|
+
- lib/cloudflare/email/active_record/outbound_delivery.rb
|
|
184
|
+
- lib/cloudflare/email/active_record/outbound_recipient.rb
|
|
185
|
+
- lib/cloudflare/email/active_record/outbound_reconciliation.rb
|
|
186
|
+
- lib/cloudflare/email/active_record/outbox.rb
|
|
187
|
+
- lib/cloudflare/email/active_record/outbox_notifications.rb
|
|
188
|
+
- lib/cloudflare/email/active_record/routing_deliveries.rb
|
|
189
|
+
- lib/cloudflare/email/check_route_task.rb
|
|
102
190
|
- lib/cloudflare/email/client.rb
|
|
191
|
+
- lib/cloudflare/email/consume_events_task.rb
|
|
103
192
|
- lib/cloudflare/email/credentials.rb
|
|
193
|
+
- lib/cloudflare/email/delivery_event.rb
|
|
104
194
|
- lib/cloudflare/email/delivery_method.rb
|
|
105
195
|
- lib/cloudflare/email/deploy_worker_task.rb
|
|
196
|
+
- lib/cloudflare/email/dev_ingress_guard.rb
|
|
106
197
|
- lib/cloudflare/email/dev_tunnel.rb
|
|
107
198
|
- lib/cloudflare/email/doctor.rb
|
|
199
|
+
- lib/cloudflare/email/endpoint.rb
|
|
108
200
|
- lib/cloudflare/email/engine.rb
|
|
201
|
+
- lib/cloudflare/email/envelope.rb
|
|
109
202
|
- lib/cloudflare/email/error.rb
|
|
203
|
+
- lib/cloudflare/email/event_consumer.rb
|
|
204
|
+
- lib/cloudflare/email/ingress.rb
|
|
205
|
+
- lib/cloudflare/email/mailboxes.rb
|
|
206
|
+
- lib/cloudflare/email/mailboxes/configuration.rb
|
|
207
|
+
- lib/cloudflare/email/mailboxes/events.rb
|
|
208
|
+
- lib/cloudflare/email/mailboxes/inbound_retention.rb
|
|
209
|
+
- lib/cloudflare/email/mailboxes/jobs.rb
|
|
210
|
+
- lib/cloudflare/email/mailboxes/models.rb
|
|
211
|
+
- lib/cloudflare/email/mailboxes/provider_correlation.rb
|
|
212
|
+
- lib/cloudflare/email/mailboxes/service.rb
|
|
213
|
+
- lib/cloudflare/email/mailboxes/shared_event_receipt.rb
|
|
214
|
+
- lib/cloudflare/email/management.rb
|
|
215
|
+
- lib/cloudflare/email/management/adapter.rb
|
|
216
|
+
- lib/cloudflare/email/management/configuration.rb
|
|
217
|
+
- lib/cloudflare/email/management/engine.rb
|
|
218
|
+
- lib/cloudflare/email/management/management.css
|
|
219
|
+
- lib/cloudflare/email/management/routes.rb
|
|
220
|
+
- lib/cloudflare/email/message_id.rb
|
|
221
|
+
- lib/cloudflare/email/provider_metadata.rb
|
|
110
222
|
- lib/cloudflare/email/provision_catchall_task.rb
|
|
111
223
|
- lib/cloudflare/email/provision_route_task.rb
|
|
224
|
+
- lib/cloudflare/email/replay_events_job.rb
|
|
112
225
|
- lib/cloudflare/email/response.rb
|
|
226
|
+
- lib/cloudflare/email/routing_analytics.rb
|
|
227
|
+
- lib/cloudflare/email/routing_diagnostics.rb
|
|
113
228
|
- lib/cloudflare/email/routing_provisioner.rb
|
|
114
|
-
- lib/cloudflare/email/
|
|
229
|
+
- lib/cloudflare/email/send_job.rb
|
|
115
230
|
- lib/cloudflare/email/send_test.rb
|
|
116
231
|
- lib/cloudflare/email/signing.rb
|
|
117
232
|
- lib/cloudflare/email/task_base.rb
|
|
233
|
+
- lib/cloudflare/email/tenancy.rb
|
|
234
|
+
- lib/cloudflare/email/tenant_job_context.rb
|
|
118
235
|
- lib/cloudflare/email/verification.rb
|
|
119
236
|
- lib/cloudflare/email/version.rb
|
|
120
237
|
- lib/cloudflare/email/worker_deployer.rb
|
|
121
238
|
- lib/generators/cloudflare/email/install_generator.rb
|
|
239
|
+
- lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb
|
|
240
|
+
- lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb
|
|
241
|
+
- lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb
|
|
242
|
+
- lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb
|
|
243
|
+
- lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb
|
|
244
|
+
- lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb
|
|
245
|
+
- lib/generators/cloudflare/email/outbox/outbox_generator.rb
|
|
246
|
+
- lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb
|
|
247
|
+
- lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb
|
|
248
|
+
- lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb
|
|
122
249
|
- lib/generators/cloudflare/email/templates/initializer.rb
|
|
123
250
|
- lib/generators/cloudflare/email/templates/main_mailbox.rb
|
|
251
|
+
- lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb
|
|
252
|
+
- lib/generators/cloudflare/email/tracking/tracking_generator.rb
|
|
124
253
|
- lib/tasks/cloudflare_email.rake
|
|
254
|
+
- lib/tasks/cloudflare_email_diagnostics.rake
|
|
255
|
+
- templates/deploy-to-cloudflare/.dev.vars.example
|
|
256
|
+
- templates/deploy-to-cloudflare/.gitignore
|
|
257
|
+
- templates/deploy-to-cloudflare/LICENSE.txt
|
|
258
|
+
- templates/deploy-to-cloudflare/README.md
|
|
259
|
+
- templates/deploy-to-cloudflare/package-lock.json
|
|
260
|
+
- templates/deploy-to-cloudflare/package.json
|
|
261
|
+
- templates/deploy-to-cloudflare/src/index.js
|
|
262
|
+
- templates/deploy-to-cloudflare/test/durable.test.ts
|
|
263
|
+
- templates/deploy-to-cloudflare/test/index.test.ts
|
|
264
|
+
- templates/deploy-to-cloudflare/test/relay.test.ts
|
|
265
|
+
- templates/deploy-to-cloudflare/vitest.config.ts
|
|
266
|
+
- templates/deploy-to-cloudflare/wrangler.toml
|
|
125
267
|
- templates/worker/README.md
|
|
268
|
+
- templates/worker/docs/durable-inbound.md
|
|
269
|
+
- templates/worker/package-lock.json
|
|
126
270
|
- templates/worker/package.json
|
|
271
|
+
- templates/worker/scripts/wrangler.mjs
|
|
127
272
|
- templates/worker/src/index.js
|
|
273
|
+
- templates/worker/test/durable.test.ts
|
|
128
274
|
- templates/worker/test/index.test.ts
|
|
275
|
+
- templates/worker/test/relay.test.ts
|
|
129
276
|
- templates/worker/vitest.config.ts
|
|
130
277
|
- templates/worker/wrangler.toml
|
|
131
278
|
homepage: https://github.com/cole-robertson/cloudflare-email
|
|
@@ -142,14 +289,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
142
289
|
requirements:
|
|
143
290
|
- - ">="
|
|
144
291
|
- !ruby/object:Gem::Version
|
|
145
|
-
version: '3.
|
|
292
|
+
version: '3.2'
|
|
146
293
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
147
294
|
requirements:
|
|
148
295
|
- - ">="
|
|
149
296
|
- !ruby/object:Gem::Version
|
|
150
297
|
version: '0'
|
|
151
298
|
requirements: []
|
|
152
|
-
rubygems_version: 3.6.
|
|
299
|
+
rubygems_version: 3.6.9
|
|
153
300
|
specification_version: 4
|
|
154
301
|
summary: Cloudflare Email Service client for Ruby and Rails (ActionMailer + ActionMailbox).
|
|
155
302
|
test_files: []
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
require "json"
|
|
2
|
-
require "cloudflare/email/signing"
|
|
3
|
-
|
|
4
|
-
module Cloudflare
|
|
5
|
-
module Email
|
|
6
|
-
# Signed outbound Message-IDs for reply authentication.
|
|
7
|
-
#
|
|
8
|
-
# Sign the outbound Message-ID with HMAC-SHA256. The recipient's reply
|
|
9
|
-
# naturally carries the signed id in `In-Reply-To:`, which your mailbox
|
|
10
|
-
# verifies and decodes to recover the original thread state.
|
|
11
|
-
#
|
|
12
|
-
# See README's "Signed replies" section for a full usage example.
|
|
13
|
-
module SecureMessageId
|
|
14
|
-
InvalidToken = Class.new(Cloudflare::Email::Error)
|
|
15
|
-
|
|
16
|
-
DEFAULT_PREFIX = "msg".freeze
|
|
17
|
-
DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 # 30 days
|
|
18
|
-
EPOCH_OFFSET = Time.utc(2026, 1, 1).to_i.freeze
|
|
19
|
-
|
|
20
|
-
class << self
|
|
21
|
-
# Build a signed Message-ID carrying `payload`. Returns the bare
|
|
22
|
-
# Message-ID without angle brackets — SMTP/Mail adds them.
|
|
23
|
-
def encode(payload:, domain:, secret:, prefix: DEFAULT_PREFIX, now: Time.now.to_i)
|
|
24
|
-
raise ArgumentError, "secret must not be empty" if secret.to_s.empty?
|
|
25
|
-
raise ArgumentError, "domain must not be empty" if domain.to_s.empty?
|
|
26
|
-
|
|
27
|
-
iat_offset = now.to_i - EPOCH_OFFSET
|
|
28
|
-
raise ArgumentError, "timestamp out of 32-bit range" if iat_offset.negative? || iat_offset >= (1 << 32)
|
|
29
|
-
|
|
30
|
-
packed = [iat_offset].pack("N") + JSON.generate(payload)
|
|
31
|
-
b64 = Signing.base64url_encode(packed)
|
|
32
|
-
mac = Signing.hmac_hex(secret, b64)
|
|
33
|
-
|
|
34
|
-
"#{prefix}.#{b64}.#{mac}@#{domain}"
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Decode a Message-ID produced by encode. Accepts `<bracketed>` form
|
|
38
|
-
# too. Returns parsed payload or raises InvalidToken.
|
|
39
|
-
def decode(message_id, secret:, max_age: DEFAULT_MAX_AGE, now: Time.now.to_i)
|
|
40
|
-
raise InvalidToken, "message-id is empty" if message_id.to_s.empty?
|
|
41
|
-
|
|
42
|
-
id = strip_brackets(message_id.to_s).strip
|
|
43
|
-
local, domain = id.split("@", 2)
|
|
44
|
-
raise InvalidToken, "missing @ in message-id" unless local && domain
|
|
45
|
-
|
|
46
|
-
_prefix, b64, mac = local.split(".", 3)
|
|
47
|
-
raise InvalidToken, "malformed message-id" unless b64 && mac && !b64.empty? && !mac.empty?
|
|
48
|
-
|
|
49
|
-
expected = Signing.hmac_hex(secret, b64)
|
|
50
|
-
raise InvalidToken, "signature mismatch" unless Signing.secure_compare(expected, mac)
|
|
51
|
-
|
|
52
|
-
packed = begin
|
|
53
|
-
Signing.base64url_decode(b64)
|
|
54
|
-
rescue StandardError
|
|
55
|
-
raise InvalidToken, "base64 decode failed"
|
|
56
|
-
end
|
|
57
|
-
raise InvalidToken, "truncated packed bytes" if packed.bytesize < 4
|
|
58
|
-
|
|
59
|
-
iat_offset = packed.byteslice(0, 4).unpack1("N")
|
|
60
|
-
iat = iat_offset + EPOCH_OFFSET
|
|
61
|
-
payload_json = packed.byteslice(4..)
|
|
62
|
-
|
|
63
|
-
raise InvalidToken, "token expired" if now - iat > max_age
|
|
64
|
-
raise InvalidToken, "token timestamp in the future" if iat - now > 5 * 60
|
|
65
|
-
|
|
66
|
-
JSON.parse(payload_json.to_s)
|
|
67
|
-
rescue JSON::ParserError
|
|
68
|
-
raise InvalidToken, "payload not valid JSON"
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Cheap heuristic — does this look like one of our signed Message-IDs?
|
|
72
|
-
def match?(message_id, prefix: DEFAULT_PREFIX)
|
|
73
|
-
id = strip_brackets(message_id.to_s).strip
|
|
74
|
-
local, domain = id.split("@", 2)
|
|
75
|
-
return false unless local && domain
|
|
76
|
-
p, b64, mac = local.split(".", 3)
|
|
77
|
-
return false unless p && b64 && mac
|
|
78
|
-
p == prefix && !b64.empty? && mac.match?(/\A[0-9a-f]{64}\z/)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
private
|
|
82
|
-
|
|
83
|
-
def strip_brackets(s)
|
|
84
|
-
s.start_with?("<") && s.end_with?(">") ? s[1..-2] : s
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
end
|