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,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cloudflare-email-ingress",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
|
|
8
|
+
},
|
|
9
|
+
"description": "Cloudflare Email Worker that forwards inbound mail to a Rails ActionMailbox ingress (HMAC-signed).",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"deploy": "wrangler deploy",
|
|
12
|
+
"dev": "wrangler dev",
|
|
13
|
+
"check": "wrangler deploy --dry-run",
|
|
14
|
+
"test": "vitest run"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"vitest": "^5.0.0",
|
|
18
|
+
"wrangler": "^4.131.0"
|
|
19
|
+
},
|
|
20
|
+
"cloudflare": {
|
|
21
|
+
"bindings": {
|
|
22
|
+
"RAILS_INGRESS_URL": {
|
|
23
|
+
"description": "Your deployed Rails HTTPS ingress URL, normally `https://your-app.com/rails/action_mailbox/cloudflare/inbound_emails`. Deploy cloudflare-email 0.3+ in Rails first."
|
|
24
|
+
},
|
|
25
|
+
"INGRESS_SECRET": {
|
|
26
|
+
"description": "Paste the same random secret configured as `cloudflare.ingress_secret` in Rails credentials (or `CLOUDFLARE_INGRESS_SECRET`). Generate it with `openssl rand -hex 32`."
|
|
27
|
+
},
|
|
28
|
+
"INBOUND_EMAIL_STORE": {
|
|
29
|
+
"description": "Private storage for pending email while Rails is unavailable. Use a new bucket for this app; never expire pending objects."
|
|
30
|
+
},
|
|
31
|
+
"INBOUND_EMAIL_QUEUE": {
|
|
32
|
+
"description": "Delivery pointers for this app. The scheduled recovery pass also recovers retained mail after queue retries are exhausted."
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Email Worker → Rails ActionMailbox ingress.
|
|
3
|
+
*
|
|
4
|
+
* Receives mail via Cloudflare Email Routing, signs the raw RFC822 with
|
|
5
|
+
* HMAC-SHA256 over "v2.{timestamp}.{encoded_envelope}.{raw_body}", and POSTs it to the Rails
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* Required environment variables (set via `wrangler secret put` OR the
|
|
13
|
+
* `cloudflare:email:deploy_worker` rake task shipped with this gem):
|
|
14
|
+
*
|
|
15
|
+
* RAILS_INGRESS_URL e.g. https://your-app.example.com/rails/action_mailbox/cloudflare/inbound_emails
|
|
16
|
+
* INGRESS_SECRET shared secret (same as cloudflare.ingress_secret in Rails credentials)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
function toHex(buf) {
|
|
20
|
+
const bytes = new Uint8Array(buf);
|
|
21
|
+
let out = "";
|
|
22
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
23
|
+
out += bytes[i].toString(16).padStart(2, "0");
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function sign(secret, data) {
|
|
29
|
+
const key = await crypto.subtle.importKey(
|
|
30
|
+
"raw",
|
|
31
|
+
new TextEncoder().encode(secret),
|
|
32
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
33
|
+
false,
|
|
34
|
+
["sign"],
|
|
35
|
+
);
|
|
36
|
+
const sig = await crypto.subtle.sign("HMAC", key, data);
|
|
37
|
+
return toHex(sig);
|
|
38
|
+
}
|
|
39
|
+
|
|
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 } = {}) {
|
|
257
|
+
if (!env.RAILS_INGRESS_URL || !env.INGRESS_SECRET) {
|
|
258
|
+
message.setReject("worker missing RAILS_INGRESS_URL or INGRESS_SECRET");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
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
|
+
}
|
|
294
|
+
|
|
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
|
+
}
|
|
300
|
+
|
|
301
|
+
const PENDING_PREFIX = "cloudflare-email/pending/";
|
|
302
|
+
const CURSOR_KEY = "cloudflare-email/state/sweep";
|
|
303
|
+
|
|
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");
|
|
336
|
+
return;
|
|
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
|
+
}
|
|
412
|
+
|
|
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));
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
};
|
|
@@ -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
|
+
});
|