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
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloudflare-email-ingress",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": { "node": "^22.12.0 || ^24.0.0 || >=26.0.0" },
|
|
5
7
|
"description": "Cloudflare Email Worker that forwards inbound mail to a Rails ActionMailbox ingress (HMAC-signed).",
|
|
6
8
|
"scripts": {
|
|
7
|
-
"deploy": "wrangler deploy",
|
|
8
|
-
"dev": "wrangler dev",
|
|
9
|
+
"deploy": "node scripts/wrangler.mjs deploy",
|
|
10
|
+
"dev": "node scripts/wrangler.mjs dev",
|
|
11
|
+
"check": "wrangler deploy --env development --dry-run",
|
|
9
12
|
"test": "vitest run"
|
|
10
13
|
},
|
|
11
14
|
"devDependencies": {
|
|
12
|
-
"vitest": "^
|
|
13
|
-
"wrangler": "^4.
|
|
15
|
+
"vitest": "^5.0.0",
|
|
16
|
+
"wrangler": "^4.131.0"
|
|
14
17
|
}
|
|
15
18
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
const [command, ...args] = process.argv.slice(2);
|
|
5
|
+
const envIndex = args.indexOf("--env");
|
|
6
|
+
const environment = args[envIndex + 1];
|
|
7
|
+
if (envIndex < 0 || !["development", "staging", "production"].includes(environment)) {
|
|
8
|
+
console.error("Specify --env development, --env staging, or --env production.");
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
const result = spawnSync(process.execPath, [
|
|
12
|
+
fileURLToPath(new URL("../node_modules/wrangler/bin/wrangler.js", import.meta.url)),
|
|
13
|
+
command, ...args,
|
|
14
|
+
], { stdio: "inherit" });
|
|
15
|
+
if (result.error) console.error(result.error.message);
|
|
16
|
+
process.exit(result.status ?? 1);
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
* Cloudflare Email Worker → Rails ActionMailbox ingress.
|
|
3
3
|
*
|
|
4
4
|
* Receives mail via Cloudflare Email Routing, signs the raw RFC822 with
|
|
5
|
-
* HMAC-SHA256 over "{timestamp}.{raw_body}", and POSTs it to the Rails
|
|
5
|
+
* HMAC-SHA256 over "v2.{timestamp}.{encoded_envelope}.{raw_body}", and POSTs it to the Rails
|
|
6
6
|
* ingress controller shipped with the cloudflare-email gem.
|
|
7
|
+
* Custom integrations can opt into v3, binding provider metadata into the signature.
|
|
8
|
+
* The default handler commits to INBOUND_EMAIL_STORE (R2), then queues delivery
|
|
9
|
+
* through INBOUND_EMAIL_QUEUE. Keep a minute cron for recovery after queue loss.
|
|
10
|
+
* INBOUND_DELIVERY_MODE=direct explicitly selects the single-attempt fallback.
|
|
7
11
|
*
|
|
8
12
|
* Required environment variables (set via `wrangler secret put` OR the
|
|
9
13
|
* `cloudflare:email:deploy_worker` rake task shipped with this gem):
|
|
@@ -33,41 +37,409 @@ async function sign(secret, data) {
|
|
|
33
37
|
return toHex(sig);
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
function validAddress(address, allowEmpty = false) {
|
|
41
|
+
if (typeof address !== "string" || address.length > 254 || /[^\x21-\x7E]/.test(address)) return false;
|
|
42
|
+
if (allowEmpty && address === "") return true;
|
|
43
|
+
const parts = address.split("@");
|
|
44
|
+
if (parts.length !== 2) return false;
|
|
45
|
+
const [local, domain] = parts;
|
|
46
|
+
return local.length <= 64 && /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(local) &&
|
|
47
|
+
!local.startsWith(".") && !local.endsWith(".") && !local.includes("..") &&
|
|
48
|
+
domain.split(".").every(label => /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(label));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validIngressUrl(value) {
|
|
52
|
+
try {
|
|
53
|
+
const url = new URL(value);
|
|
54
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
55
|
+
return !url.username && !url.password && !url.hash &&
|
|
56
|
+
(url.protocol === "https:" || (url.protocol === "http:" && loopback));
|
|
57
|
+
} catch { return false; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Generic relays preserve host address semantics; signed gem ingress is stricter.
|
|
61
|
+
function validTransportEnvelope(from, to) {
|
|
62
|
+
return [from, to].every(value => typeof value === "string" && value.length <= 254 &&
|
|
63
|
+
!/[\x00-\x1f\x7f]/.test(value)) && to.length > 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readBounded(stream, limit) {
|
|
67
|
+
const reader = stream.getReader();
|
|
68
|
+
const chunks = [];
|
|
69
|
+
let size = 0;
|
|
70
|
+
try {
|
|
71
|
+
while (true) {
|
|
72
|
+
const { done, value } = await reader.read();
|
|
73
|
+
if (done) break;
|
|
74
|
+
if (!(value instanceof Uint8Array)) throw new TypeError("invalid message chunk");
|
|
75
|
+
size += value.byteLength;
|
|
76
|
+
if (size > limit) {
|
|
77
|
+
const error = new Error("message exceeds size limit");
|
|
78
|
+
error.code = "EMAIL_TOO_LARGE";
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
chunks.push(value);
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
void reader.cancel().catch(() => {});
|
|
85
|
+
throw error;
|
|
86
|
+
} finally { reader.releaseLock(); }
|
|
87
|
+
const raw = new Uint8Array(size);
|
|
88
|
+
let offset = 0;
|
|
89
|
+
for (const chunk of chunks) { raw.set(chunk, offset); offset += chunk.byteLength; }
|
|
90
|
+
return raw;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function plainObject(value) {
|
|
94
|
+
return value !== null && typeof value === "object" &&
|
|
95
|
+
[Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validateJson(value, depth = 1) {
|
|
99
|
+
if (typeof value === "string") {
|
|
100
|
+
if (/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value)) {
|
|
101
|
+
throw new TypeError("metadata strings must be valid Unicode");
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (value === null || typeof value === "boolean" ||
|
|
106
|
+
(typeof value === "number" && Number.isFinite(value))) return;
|
|
107
|
+
if (depth > 8 || (!Array.isArray(value) && !plainObject(value))) {
|
|
108
|
+
throw new TypeError("metadata must contain JSON values with at most 8 container levels");
|
|
109
|
+
}
|
|
110
|
+
for (const [key, item] of Object.entries(value)) {
|
|
111
|
+
validateJson(key, depth + 1);
|
|
112
|
+
validateJson(item, depth + 1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function base64url(value) {
|
|
117
|
+
const bytes = new TextEncoder().encode(value);
|
|
118
|
+
let binary = "";
|
|
119
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
120
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function encodeMetadata(metadata) {
|
|
124
|
+
if (!plainObject(metadata) || Object.keys(metadata).sort().join(",") !== "data,source" ||
|
|
125
|
+
typeof metadata.source !== "string" || !/^[a-z][a-z0-9_.-]{0,127}$/.test(metadata.source) ||
|
|
126
|
+
!plainObject(metadata.data)) throw new TypeError("invalid provider metadata");
|
|
127
|
+
validateJson(metadata);
|
|
128
|
+
const encoded = base64url(JSON.stringify({ source: metadata.source, data: metadata.data }));
|
|
129
|
+
if (encoded.length > 16384) throw new TypeError("encoded metadata exceeds 16384 characters");
|
|
130
|
+
return encoded;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Custom transports own their body limits, destination, retries and error handling.
|
|
134
|
+
// Metadata is a host assertion; never derive trusted facts from MIME headers.
|
|
135
|
+
export async function signedEmailHeaders({ secret, raw, from, to, metadata, timestamp = Math.floor(Date.now() / 1000).toString() }) {
|
|
136
|
+
if (typeof secret !== "string" || secret.length === 0) throw new TypeError("missing ingress secret");
|
|
137
|
+
if (!(raw instanceof Uint8Array)) throw new TypeError("raw must be a Uint8Array");
|
|
138
|
+
if (!validAddress(from, true) || !validAddress(to)) throw new TypeError("invalid SMTP envelope");
|
|
139
|
+
const ts = String(timestamp);
|
|
140
|
+
if (!/^[0-9]{1,12}$/.test(ts)) throw new TypeError("invalid timestamp");
|
|
141
|
+
const envelope = base64url(JSON.stringify({ from, to }));
|
|
142
|
+
const encodedMetadata = metadata === undefined ? undefined : encodeMetadata(metadata);
|
|
143
|
+
const version = encodedMetadata === undefined ? "2" : "3";
|
|
144
|
+
const prefix = new TextEncoder().encode(`v${version}.${ts}.${envelope}.${encodedMetadata === undefined ? "" : `${encodedMetadata}.`}`);
|
|
145
|
+
const signedPayload = new Uint8Array(prefix.length + raw.length);
|
|
146
|
+
signedPayload.set(prefix);
|
|
147
|
+
signedPayload.set(raw, prefix.length);
|
|
148
|
+
const headers = {
|
|
149
|
+
"Content-Type": "message/rfc822",
|
|
150
|
+
"X-CF-Email-Timestamp": ts,
|
|
151
|
+
"X-CF-Email-Signature": await sign(secret, signedPayload),
|
|
152
|
+
"X-CF-Email-Signature-Version": version,
|
|
153
|
+
"X-CF-Email-Envelope": envelope,
|
|
154
|
+
};
|
|
155
|
+
if (encodedMetadata !== undefined) headers["X-CF-Email-Metadata"] = encodedMetadata;
|
|
156
|
+
return headers;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Stores only transport metadata. The key and retention policy belong to the host.
|
|
160
|
+
export async function archiveEmail({ bucket, key, raw, from, to }) {
|
|
161
|
+
if (!bucket || typeof bucket.put !== "function" || typeof key !== "string" ||
|
|
162
|
+
key.length === 0 || new TextEncoder().encode(key).length > 1024 || /[\x00-\x1f\x7f]/.test(key) ||
|
|
163
|
+
!(raw instanceof Uint8Array) || !validTransportEnvelope(from, to)) {
|
|
164
|
+
throw new TypeError("invalid email archive arguments");
|
|
165
|
+
}
|
|
166
|
+
await bucket.put(key, raw, {
|
|
167
|
+
httpMetadata: { contentType: "message/rfc822" },
|
|
168
|
+
customMetadata: { from: from.replace(/[^\x20-\x7e]/g, "?"),
|
|
169
|
+
to: to.replace(/[^\x20-\x7e]/g, "?"), size: String(raw.byteLength) },
|
|
170
|
+
});
|
|
171
|
+
return { key };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function postEmail(url, headers, raw, timeoutMs) {
|
|
175
|
+
const controller = new AbortController();
|
|
176
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
177
|
+
let response;
|
|
178
|
+
try {
|
|
179
|
+
response = await fetch(url, { method: "POST", headers, body: raw,
|
|
180
|
+
redirect: "manual", signal: controller.signal });
|
|
181
|
+
} catch {
|
|
182
|
+
return { reason: controller.signal.aborted ? "timeout" : "fetch_failed" };
|
|
183
|
+
} finally { clearTimeout(timeout); }
|
|
184
|
+
// Never read or return backend error bodies; they can contain mail or secrets.
|
|
185
|
+
try { void response.body?.cancel().catch(() => {}); } catch { /* cancellation is best effort */ }
|
|
186
|
+
return { reason: response.ok ? "delivered" : "http_status", httpStatus: response.status };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function archiveWithin(saveArchive, args, timeoutMs) {
|
|
190
|
+
let timeout;
|
|
191
|
+
try {
|
|
192
|
+
return await Promise.race([
|
|
193
|
+
Promise.resolve().then(() => saveArchive(args)),
|
|
194
|
+
new Promise((_, reject) => {
|
|
195
|
+
timeout = setTimeout(() => reject(new Error("archive timed out")), timeoutMs);
|
|
196
|
+
}),
|
|
197
|
+
]);
|
|
198
|
+
} finally { clearTimeout(timeout); }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// A host-controlled relay: no SMTP rejection, sender policy, fallback or retries.
|
|
202
|
+
// Callback exceptions are deliberately reduced to fixed, non-sensitive reasons.
|
|
203
|
+
export async function relayEmail(message, options = {}) {
|
|
204
|
+
let archiveFailed = false;
|
|
205
|
+
const result = (status, reason, httpStatus) => ({ status, reason, archiveFailed,
|
|
206
|
+
...(httpStatus === undefined ? {} : { httpStatus }) });
|
|
207
|
+
if (!options || typeof options !== "object") return result("failed", "invalid_options");
|
|
208
|
+
const { maxEmailBytes = 25 * 1024 * 1024, timeoutMs = 15_000, archiveTimeoutMs = 10_000,
|
|
209
|
+
resolveBackend, headers: buildHeaders, archive: saveArchive, accepts } = options;
|
|
210
|
+
if (!Number.isSafeInteger(maxEmailBytes) || maxEmailBytes <= 0 ||
|
|
211
|
+
!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647 ||
|
|
212
|
+
!Number.isSafeInteger(archiveTimeoutMs) || archiveTimeoutMs <= 0 || archiveTimeoutMs > 2_147_483_647 ||
|
|
213
|
+
typeof resolveBackend !== "function" || typeof buildHeaders !== "function" ||
|
|
214
|
+
(saveArchive !== undefined && typeof saveArchive !== "function") ||
|
|
215
|
+
(accepts !== undefined && typeof accepts !== "function")) return result("failed", "invalid_options");
|
|
216
|
+
if (!message || !validTransportEnvelope(message.from, message.to)) {
|
|
217
|
+
return result("rejected", "invalid_envelope");
|
|
218
|
+
}
|
|
219
|
+
const { from, to } = message;
|
|
220
|
+
if (message.rawSize > maxEmailBytes) return result("rejected", "too_large");
|
|
221
|
+
let raw;
|
|
222
|
+
try { raw = await readBounded(message.raw, maxEmailBytes); }
|
|
223
|
+
catch (error) {
|
|
224
|
+
return error?.code === "EMAIL_TOO_LARGE" ? result("rejected", "too_large") : result("failed", "unreadable");
|
|
225
|
+
}
|
|
226
|
+
let archive;
|
|
227
|
+
if (saveArchive) {
|
|
228
|
+
try { archive = await archiveWithin(saveArchive, { raw, from, to }, archiveTimeoutMs); }
|
|
229
|
+
catch { archiveFailed = true; }
|
|
230
|
+
}
|
|
231
|
+
if (accepts) {
|
|
232
|
+
try {
|
|
233
|
+
if (await accepts({ from, to }) !== true) return result("rejected", "not_accepted");
|
|
234
|
+
} catch { return result("failed", "acceptance_failed"); }
|
|
235
|
+
}
|
|
236
|
+
let backend;
|
|
237
|
+
let url;
|
|
238
|
+
try {
|
|
239
|
+
backend = await resolveBackend({ from, to });
|
|
240
|
+
url = backend?.url;
|
|
241
|
+
}
|
|
242
|
+
catch { return result("failed", "backend_failed"); }
|
|
243
|
+
if (typeof url !== "string" || !validIngressUrl(url)) {
|
|
244
|
+
return result("failed", "invalid_backend");
|
|
245
|
+
}
|
|
246
|
+
let headers;
|
|
247
|
+
try {
|
|
248
|
+
const supplied = await buildHeaders({ raw, from, to, backend, archive });
|
|
249
|
+
if (!supplied || typeof supplied !== "object") return result("failed", "headers_failed");
|
|
250
|
+
headers = new Headers(supplied);
|
|
251
|
+
} catch { return result("failed", "headers_failed"); }
|
|
252
|
+
const delivery = await postEmail(url, headers, raw, timeoutMs);
|
|
253
|
+
return result(delivery.reason === "delivered" ? "delivered" : "failed", delivery.reason, delivery.httpStatus);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function forwardEmail(message, env, { metadata } = {}) {
|
|
38
257
|
if (!env.RAILS_INGRESS_URL || !env.INGRESS_SECRET) {
|
|
39
258
|
message.setReject("worker missing RAILS_INGRESS_URL or INGRESS_SECRET");
|
|
40
259
|
return;
|
|
41
260
|
}
|
|
42
261
|
|
|
43
|
-
|
|
44
|
-
|
|
262
|
+
if (!validIngressUrl(env.RAILS_INGRESS_URL)) {
|
|
263
|
+
message.setReject("worker requires an HTTPS ingress URL (HTTP allowed only for loopback)");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const limit = env.MAX_EMAIL_BYTES === undefined ? 25 * 1024 * 1024 : Number(env.MAX_EMAIL_BYTES);
|
|
267
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
268
|
+
message.setReject("worker MAX_EMAIL_BYTES must be a positive integer");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (message.rawSize > limit) {
|
|
272
|
+
message.setReject("message exceeds size limit");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!validAddress(message.from, true) || !validAddress(message.to)) {
|
|
277
|
+
message.setReject("worker received invalid SMTP envelope");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
let raw;
|
|
281
|
+
try {
|
|
282
|
+
raw = await readBounded(message.raw, limit);
|
|
283
|
+
} catch {
|
|
284
|
+
message.setReject("message exceeds size limit or could not be read");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
let headers;
|
|
288
|
+
try {
|
|
289
|
+
headers = await signedEmailHeaders({ secret: env.INGRESS_SECRET, raw, from: message.from, to: message.to, metadata });
|
|
290
|
+
} catch {
|
|
291
|
+
message.setReject("worker could not sign envelope or provider metadata");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
45
294
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
295
|
+
const delivery = await postEmail(env.RAILS_INGRESS_URL, headers, raw, 15_000);
|
|
296
|
+
if (delivery.reason === "timeout") message.setReject("upstream fetch timed out");
|
|
297
|
+
else if (delivery.reason === "fetch_failed") message.setReject("upstream fetch failed");
|
|
298
|
+
else if (delivery.reason === "http_status") message.setReject(`upstream returned ${delivery.httpStatus}`);
|
|
299
|
+
}
|
|
50
300
|
|
|
51
|
-
|
|
301
|
+
const PENDING_PREFIX = "cloudflare-email/pending/";
|
|
302
|
+
const CURSOR_KEY = "cloudflare-email/state/sweep";
|
|
52
303
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
304
|
+
function durableConfig(env) {
|
|
305
|
+
if (!env.INBOUND_EMAIL_STORE || !env.INBOUND_EMAIL_QUEUE ||
|
|
306
|
+
!validIngressUrl(env.RAILS_INGRESS_URL) || !env.INGRESS_SECRET) {
|
|
307
|
+
throw new Error("durable inbound configuration invalid");
|
|
308
|
+
}
|
|
309
|
+
const limit = Number(env.MAX_EMAIL_BYTES ?? 25 * 1024 * 1024);
|
|
310
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("invalid inbound size limit");
|
|
311
|
+
return limit;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function durableLog(reason, key, httpStatus) {
|
|
315
|
+
console.warn(JSON.stringify({ component: "cloudflare_email_inbound", reason, key,
|
|
316
|
+
...(httpStatus === undefined ? {} : { httpStatus }) }));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// One atomic R2 object holds both the original bytes and stable signed context.
|
|
320
|
+
// Queue messages are disposable wakeups; pending objects are the source of truth.
|
|
321
|
+
export async function retainEmail(message, env, { metadata, archive, archiveTimeoutMs = 10_000 } = {}) {
|
|
322
|
+
const limit = durableConfig(env);
|
|
323
|
+
if (archive !== undefined && typeof archive !== "function") throw new Error("invalid inbound archive callback");
|
|
324
|
+
if (!Number.isSafeInteger(archiveTimeoutMs) || archiveTimeoutMs <= 0 || archiveTimeoutMs > 120_000) {
|
|
325
|
+
throw new Error("inbound archive timeout must be an integer from 1 to 120000 milliseconds");
|
|
326
|
+
}
|
|
327
|
+
if (!validAddress(message.from, true) || !validAddress(message.to)) {
|
|
328
|
+
message.setReject("worker received invalid SMTP envelope");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (metadata !== undefined) encodeMetadata(metadata);
|
|
332
|
+
let raw;
|
|
333
|
+
try {
|
|
334
|
+
if (message.rawSize > limit) {
|
|
335
|
+
message.setReject("message exceeds size limit");
|
|
66
336
|
return;
|
|
67
337
|
}
|
|
338
|
+
raw = await readBounded(message.raw, limit);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (error?.code !== "EMAIL_TOO_LARGE") throw new Error("inbound message read failed");
|
|
341
|
+
message.setReject("message exceeds size limit");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const key = `${PENDING_PREFIX}${crypto.randomUUID()}`;
|
|
345
|
+
const context = new TextEncoder().encode(JSON.stringify({ version: 1,
|
|
346
|
+
from: message.from, to: message.to, metadata, receivedAt: new Date().toISOString() }));
|
|
347
|
+
const stored = new Uint8Array(4 + context.length + raw.length);
|
|
348
|
+
new DataView(stored.buffer).setUint32(0, context.length);
|
|
349
|
+
stored.set(context, 4);
|
|
350
|
+
stored.set(raw, 4 + context.length);
|
|
351
|
+
try {
|
|
352
|
+
// Deliberately awaited, never waitUntil or a best-effort archive.
|
|
353
|
+
await env.INBOUND_EMAIL_STORE.put(key, stored,
|
|
354
|
+
{ httpMetadata: { contentType: "application/octet-stream" } });
|
|
355
|
+
} catch {
|
|
356
|
+
durableLog("storage_failed", key);
|
|
357
|
+
throw new Error("inbound durable storage unavailable");
|
|
358
|
+
}
|
|
359
|
+
// A secondary archive receives the public raw/context contract, never the
|
|
360
|
+
// private storage frame. Its failure cannot undo the primary durable write.
|
|
361
|
+
if (archive) {
|
|
362
|
+
try { await archiveWithin(archive, { raw, from: message.from, to: message.to, key }, archiveTimeoutMs); }
|
|
363
|
+
catch { durableLog("archive_failed_retained", key); }
|
|
364
|
+
}
|
|
365
|
+
try { await env.INBOUND_EMAIL_QUEUE.send({ version: 1, key }); }
|
|
366
|
+
catch { durableLog("enqueue_failed_retained", key); }
|
|
367
|
+
return { key };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function deliverRetainedEmail(key, env) {
|
|
371
|
+
const limit = durableConfig(env);
|
|
372
|
+
if (typeof key !== "string" || !/^cloudflare-email\/pending\/[0-9a-f-]{36}$/.test(key)) {
|
|
373
|
+
throw new Error("invalid retained email key");
|
|
374
|
+
}
|
|
375
|
+
const bucket = env.INBOUND_EMAIL_STORE;
|
|
376
|
+
// A previous successful attempt may have deleted the pending object already.
|
|
377
|
+
const object = await bucket.get(key);
|
|
378
|
+
if (!object) return;
|
|
379
|
+
if (object.size > limit + 32772) throw new Error("retained email exceeds configured size");
|
|
380
|
+
const stored = await readBounded(object.body, limit + 32772);
|
|
381
|
+
if (stored.length < 4) throw new Error("invalid retained email");
|
|
382
|
+
const length = new DataView(stored.buffer, stored.byteOffset).getUint32(0);
|
|
383
|
+
if (length > 32768 || length > stored.length - 4) throw new Error("invalid retained email context");
|
|
384
|
+
const context = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(stored.subarray(4, 4 + length)));
|
|
385
|
+
const raw = stored.subarray(4 + length);
|
|
386
|
+
if (context.version !== 1 || raw.length > limit) throw new Error("invalid retained email version or size");
|
|
387
|
+
const headers = await signedEmailHeaders({ secret: env.INGRESS_SECRET, raw,
|
|
388
|
+
from: context.from, to: context.to, metadata: context.metadata });
|
|
389
|
+
const result = await postEmail(env.RAILS_INGRESS_URL, headers, raw, 15_000);
|
|
390
|
+
if (result.reason !== "delivered") {
|
|
391
|
+
durableLog(result.reason, key, result.httpStatus);
|
|
392
|
+
throw new Error("inbound handoff incomplete");
|
|
393
|
+
}
|
|
394
|
+
// Delete only after Rails durable acceptance. A lost response or failed delete
|
|
395
|
+
// leaves the identical object available for idempotent replay, never rewrites it.
|
|
396
|
+
await bucket.delete(key);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function consumeRetainedEmails(batch, env) {
|
|
400
|
+
for (const message of batch.messages) {
|
|
401
|
+
try {
|
|
402
|
+
if (message.body?.version !== 1) throw new Error("invalid queue pointer");
|
|
403
|
+
await deliverRetainedEmail(message.body.key, env);
|
|
404
|
+
message.ack();
|
|
405
|
+
} catch {
|
|
406
|
+
durableLog("queue_retry_retained", typeof message.body?.key === "string" &&
|
|
407
|
+
/^cloudflare-email\/pending\/[0-9a-f-]{36}$/.test(message.body.key) ? message.body.key : undefined);
|
|
408
|
+
message.retry({ delaySeconds: 300 });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
68
412
|
|
|
69
|
-
|
|
70
|
-
|
|
413
|
+
export async function sweepRetainedEmails(env) {
|
|
414
|
+
durableConfig(env);
|
|
415
|
+
const bucket = env.INBOUND_EMAIL_STORE;
|
|
416
|
+
const state = await bucket.get(CURSOR_KEY);
|
|
417
|
+
const cursor = state ? await state.text() : undefined;
|
|
418
|
+
// Five attempts fit comfortably within the scheduled handler's wall-time cap.
|
|
419
|
+
// Advance even over poison entries; the next traversal retries them again.
|
|
420
|
+
const page = await bucket.list({ prefix: PENDING_PREFIX, limit: 5,
|
|
421
|
+
...(cursor ? { cursor } : {}) });
|
|
422
|
+
for (const object of page.objects) {
|
|
423
|
+
try { await deliverRetainedEmail(object.key, env); }
|
|
424
|
+
catch { durableLog("sweep_retry_retained", object.key); }
|
|
425
|
+
}
|
|
426
|
+
if (page.truncated) await bucket.put(CURSOR_KEY, page.cursor);
|
|
427
|
+
else await bucket.delete(CURSOR_KEY);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export default {
|
|
431
|
+
email(message, env) {
|
|
432
|
+
const mode = env.INBOUND_DELIVERY_MODE ?? "durable";
|
|
433
|
+
if (mode === "direct") return forwardEmail(message, env);
|
|
434
|
+
if (mode !== "durable") throw new Error("INBOUND_DELIVERY_MODE must be durable or direct");
|
|
435
|
+
return retainEmail(message, env);
|
|
436
|
+
},
|
|
437
|
+
queue(batch, env) { return consumeRetainedEmails(batch, env); },
|
|
438
|
+
scheduled(_event, env, ctx) {
|
|
439
|
+
// Keep draining retained mail during a direct-mode rollback. A direct-only
|
|
440
|
+
// deployment has no schedule or storage; partial durable config must fail.
|
|
441
|
+
if (env.INBOUND_DELIVERY_MODE !== "direct" || env.INBOUND_EMAIL_STORE || env.INBOUND_EMAIL_QUEUE) {
|
|
442
|
+
ctx.waitUntil(sweepRetainedEmails(env));
|
|
71
443
|
}
|
|
72
444
|
},
|
|
73
445
|
};
|