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
data/templates/worker/README.md
CHANGED
|
@@ -1,37 +1,252 @@
|
|
|
1
1
|
# cloudflare-email-ingress
|
|
2
2
|
|
|
3
3
|
A Cloudflare Email Worker that forwards inbound mail to a Rails ActionMailbox
|
|
4
|
-
ingress shipped with the [`cloudflare-email`](https://github.com/cole/cloudflare-email)
|
|
4
|
+
ingress shipped with the [`cloudflare-email`](https://github.com/cole-robertson/cloudflare-email)
|
|
5
5
|
gem.
|
|
6
6
|
|
|
7
7
|
## Deploy
|
|
8
8
|
|
|
9
|
+
For a guided dashboard deployment that provisions resources and prompts for
|
|
10
|
+
secrets, use the [Deploy to Cloudflare template](../deploy-to-cloudflare/README.md).
|
|
11
|
+
It uses this same Worker with a standalone configuration. The CLI setup below
|
|
12
|
+
is for the explicitly named development/staging/production environments.
|
|
13
|
+
|
|
9
14
|
```sh
|
|
10
|
-
npm
|
|
11
|
-
wrangler
|
|
12
|
-
wrangler
|
|
13
|
-
wrangler
|
|
15
|
+
npm ci
|
|
16
|
+
npx wrangler r2 bucket create cloudflare-email-inbound-production
|
|
17
|
+
npx wrangler queues create cloudflare-email-inbound-production
|
|
18
|
+
npx wrangler secret put INGRESS_SECRET --env production
|
|
19
|
+
npx wrangler secret put RAILS_INGRESS_URL --env production
|
|
20
|
+
npm run deploy -- --env production
|
|
14
21
|
```
|
|
15
22
|
|
|
23
|
+
Use Node 22 (at least 22.12), 24, or 26+. `INGRESS_SECRET` must match Rails' ingress secret;
|
|
24
|
+
`RAILS_INGRESS_URL` is your public HTTPS endpoint, for example
|
|
25
|
+
`https://your-app.com/rails/action_mailbox/cloudflare/inbound_emails`.
|
|
26
|
+
The deploy and dev scripts require an explicit `development`, `staging`, or
|
|
27
|
+
`production` environment. Each has its own Worker and secrets; set both secrets
|
|
28
|
+
for each environment you use. For local development, run
|
|
29
|
+
`npm run dev -- --env development`, with secrets in `.dev.vars.development`.
|
|
30
|
+
Never put secrets into `wrangler.toml` or version control.
|
|
31
|
+
|
|
32
|
+
The Worker refuses non-HTTPS destinations, URL credentials, and fragments.
|
|
33
|
+
HTTP is allowed only for `localhost`, `127.0.0.1`, or `[::1]` local verification.
|
|
34
|
+
The Worker and Rails ingress each default to a 25 MiB MIME size limit. Set
|
|
35
|
+
`MAX_EMAIL_BYTES` to the same positive integer in both environments to change
|
|
36
|
+
that limit. Both check actual body bytes; declared sizes alone are not trusted.
|
|
37
|
+
Rails returns HTTP 413 for oversized mail and rejects malformed or stale signing
|
|
38
|
+
headers before reading the body. Configure request size/rate limits and read
|
|
39
|
+
timeouts at your reverse proxy as well: application limits do not bound earlier
|
|
40
|
+
server buffering or the time spent receiving an HTTP request.
|
|
41
|
+
|
|
16
42
|
Then in the Cloudflare dashboard:
|
|
17
43
|
|
|
18
44
|
1. **Email Routing → Routes**
|
|
19
45
|
2. Add a route for the address you want to receive on (e.g. `support@yourdomain.com`)
|
|
20
|
-
3. Action: **Send to a Worker** → `cloudflare-email-ingress`
|
|
46
|
+
3. Action: **Send to a Worker** → `cloudflare-email-ingress-production`
|
|
47
|
+
|
|
48
|
+
Select the corresponding suffix when deploying another environment. Configure
|
|
49
|
+
Email Routing DNS for the exact domain/subdomain first. Deploying this Worker
|
|
50
|
+
does not change DNS or create routing rules.
|
|
21
51
|
|
|
22
52
|
## How it works
|
|
23
53
|
|
|
24
54
|
For each inbound message, the Worker:
|
|
25
55
|
|
|
26
|
-
1.
|
|
27
|
-
2.
|
|
28
|
-
3. POSTs the
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
56
|
+
1. Validates and stores the exact RFC822 bytes and envelope in private R2.
|
|
57
|
+
2. Queues a small pointer after storage succeeds.
|
|
58
|
+
3. Signs and POSTs the stored message to Rails; only HTTP 2xx allows deleting it.
|
|
59
|
+
4. Retries failures through the Queue and a minute-by-minute scheduled recovery
|
|
60
|
+
pass. Even exhausted queue retries leave the original message recoverable.
|
|
61
|
+
|
|
62
|
+
The signed request uses HMAC-SHA256 and authenticates the original SMTP envelope
|
|
63
|
+
alongside the MIME bytes. See [durable delivery setup and recovery](./docs/durable-inbound.md).
|
|
64
|
+
`INBOUND_DELIVERY_MODE=direct` selects an explicit single-attempt fallback that
|
|
65
|
+
calls `message.setReject` on non-2xx, timeout or network failure. Existing pending
|
|
66
|
+
mail keeps draining through Queue and cron while this fallback is selected.
|
|
35
67
|
|
|
36
68
|
The Rails controller verifies the signature in constant time and rejects
|
|
37
|
-
timestamps
|
|
69
|
+
timestamps outside its 5-minute acceptance window. A signature authenticates the
|
|
70
|
+
Worker request, not the original email sender. Captured requests remain valid
|
|
71
|
+
inside that window; Rails deduplicates messages through Action Mailbox.
|
|
72
|
+
|
|
73
|
+
Deploy the Rails gem and this Worker together with ingress paused during the
|
|
74
|
+
transition. Rails accepts v2 and v3 signatures and rejects v1/missing versions. It stores authenticated
|
|
75
|
+
SMTP metadata separately from MIME; applications read it using
|
|
76
|
+
`Cloudflare::Email::Envelope.for(inbound_email)`. Duplicate detection includes the
|
|
77
|
+
exact SMTP recipient, preserving separate To/Cc/Bcc deliveries. The format accepts
|
|
78
|
+
ASCII dot-atom addresses up to 254 bytes (local part up to 64 bytes), plus an empty
|
|
79
|
+
sender for bounce messages. Other address forms are rejected before forwarding.
|
|
80
|
+
|
|
81
|
+
## Reuse an existing Worker
|
|
82
|
+
|
|
83
|
+
Keep your existing archiving, sender checks, and fallback policy. Copy this
|
|
84
|
+
template's `src/index.js` into your Worker project and import either helper.
|
|
85
|
+
`forwardEmail(message, env)` uses the same bounded reading, URL checks, timeout,
|
|
86
|
+
and rejection policy as the explicit direct fallback. Optional metadata selects v3 signing:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
import { forwardEmail } from "./cloudflare-ingress.js";
|
|
90
|
+
|
|
91
|
+
export default {
|
|
92
|
+
async email(message, env) {
|
|
93
|
+
// Supplied by your integration after validating its trusted provider context.
|
|
94
|
+
// This is your adapter function, not a Cloudflare EmailMessage API.
|
|
95
|
+
const providerData = await yourValidatedProviderContext(message, env);
|
|
96
|
+
return forwardEmail(message, env, {
|
|
97
|
+
metadata: { source: "your-provider", data: providerData },
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The bundled Worker does not collect provider authentication results. Never treat
|
|
104
|
+
`Authentication-Results`, `Received-SPF`, or other sender-supplied MIME headers as
|
|
105
|
+
trusted provider context. Signing metadata authenticates your Worker's assertion;
|
|
106
|
+
it does not prove DMARC passed or authorize the original sender. Your application
|
|
107
|
+
still owns sender policy, organization resolution, review, and document processing.
|
|
108
|
+
|
|
109
|
+
If your Worker already owns transport and error handling, use the header builder:
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
import { signedEmailHeaders } from "./cloudflare-ingress.js";
|
|
113
|
+
|
|
114
|
+
const headers = await signedEmailHeaders({
|
|
115
|
+
secret: env.INGRESS_SECRET,
|
|
116
|
+
raw, // Original Uint8Array, obtained using your own bounded reader.
|
|
117
|
+
from: message.from,
|
|
118
|
+
to: message.to,
|
|
119
|
+
metadata: { source: "your-provider", data: providerData },
|
|
120
|
+
});
|
|
121
|
+
// Send these headers with exactly `raw`, using your existing transport.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The builder performs no network calls. You own destination validation, body size
|
|
125
|
+
limits, timeouts, redirects, retries, and response handling. An optional `timestamp`
|
|
126
|
+
in Unix seconds supports deterministic testing; production normally uses the
|
|
127
|
+
default current time. Omitting `metadata` produces unchanged v2 headers. Invalid
|
|
128
|
+
metadata throws from the builder; the forwarding helper rejects instead of
|
|
129
|
+
silently falling back to v2.
|
|
130
|
+
|
|
131
|
+
Metadata must contain exactly `source` and `data`. Source matches
|
|
132
|
+
`[a-z][a-z0-9_.-]{0,127}`; data is a JSON object, with only JSON values and at most
|
|
133
|
+
eight container levels including the outer metadata object. The UTF8 JSON is
|
|
134
|
+
encoded as unpadded base64url, limited to 16,384 encoded characters, and sent as
|
|
135
|
+
`X-CF-Email-Metadata`. Version 3 signs the exact bytes:
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
HMAC-SHA256(secret, "v3.{timestamp}.{encoded_envelope}.{encoded_metadata}.{raw_body}")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Neither the MIME nor its headers are rewritten. Upgrade Rails to a version that
|
|
142
|
+
supports v3 before enabling metadata in a custom Worker. Keep sensitive provider
|
|
143
|
+
data out of metadata unless your application's retention and access policies
|
|
144
|
+
permit storing it with email records.
|
|
145
|
+
|
|
146
|
+
## Reuse the transport in an existing Worker
|
|
147
|
+
|
|
148
|
+
`relayEmail` provides bounded reading, an optional archive, backend selection and
|
|
149
|
+
one HTTP delivery attempt. Your Worker decides how a result maps to SMTP
|
|
150
|
+
rejection, retry, fallback forwarding or logging. It does not call `setReject`
|
|
151
|
+
or `forward` and does not extract or interpret sender authentication evidence.
|
|
152
|
+
`forwardEmail` remains the explicit direct helper; the default export retains mail durably.
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
import { relayEmail, archiveEmail, signedEmailHeaders } from "./cloudflare-ingress.js";
|
|
156
|
+
|
|
157
|
+
export default {
|
|
158
|
+
async email(message, env) {
|
|
159
|
+
const outcome = await relayEmail(message, {
|
|
160
|
+
maxEmailBytes: 25 * 1024 * 1024,
|
|
161
|
+
timeoutMs: 15_000,
|
|
162
|
+
archiveTimeoutMs: 10_000,
|
|
163
|
+
// Omit archive when no bucket is configured. Key policy belongs to your app.
|
|
164
|
+
archive: env.EMAIL_ARCHIVE ? (args) => archiveEmail({
|
|
165
|
+
...args, bucket: env.EMAIL_ARCHIVE,
|
|
166
|
+
key: `email/${crypto.randomUUID()}.eml`,
|
|
167
|
+
}) : undefined,
|
|
168
|
+
accepts: async ({ to }) => to.endsWith("@inbound.example.com"),
|
|
169
|
+
// Select only host-configured backends; never use a URL from an email header.
|
|
170
|
+
resolveBackend: async ({ from, to }) => ({
|
|
171
|
+
url: env.RAILS_INGRESS_URL, secret: env.INGRESS_SECRET,
|
|
172
|
+
}),
|
|
173
|
+
headers: ({ raw, from, to, backend }) => signedEmailHeaders({
|
|
174
|
+
raw, from, to, secret: backend.secret,
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
// Example host policy: permanent rejection only for invalid/unaccepted mail;
|
|
178
|
+
// Throwing surfaces failure; do not assume Cloudflare retries SMTP delivery.
|
|
179
|
+
if (outcome.status === "rejected") message.setReject("email not accepted");
|
|
180
|
+
if (outcome.status === "failed") throw new Error("email relay unavailable");
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The relay reads the original stream once, enforcing the actual byte limit even
|
|
186
|
+
when `rawSize` underreports it. It archives before checking `accepts` and before
|
|
187
|
+
resolving the backend, so an archive can retain mail rejected by host policy.
|
|
188
|
+
Invalid envelopes, unreadable streams and oversized messages are not archived.
|
|
189
|
+
An archive exception or timeout does not prevent delivery: the result has
|
|
190
|
+
`archiveFailed: true`. The archive deadline defaults to 10 seconds and can be
|
|
191
|
+
changed with `archiveTimeoutMs`. It stops waiting but cannot cancel an R2 write:
|
|
192
|
+
a timed-out archive may still finish later if the runtime remains alive. Monitor
|
|
193
|
+
that flag if the archive is your outage recovery mechanism. Other callback
|
|
194
|
+
implementations must finish within the Worker's runtime limits; the `timeoutMs`
|
|
195
|
+
option applies to the HTTP request only.
|
|
196
|
+
|
|
197
|
+
`accepts` must return exactly `true` to accept. `resolveBackend` returns an object
|
|
198
|
+
with `url` and any additional host configuration needed by `headers`. Backend
|
|
199
|
+
URLs must use HTTPS, or HTTP on localhost, `127.0.0.1` or `[::1]`; embedded
|
|
200
|
+
credentials and fragments are rejected. Generic relay envelope checks enforce
|
|
201
|
+
bounded strings without control characters, leaving address shape to the host.
|
|
202
|
+
`signedEmailHeaders` retains the gem ingress's stricter ASCII address validation.
|
|
203
|
+
|
|
204
|
+
The `headers` callback receives `{ raw, from, to, backend, archive }`, where
|
|
205
|
+
`archive` is the archive callback's return value, or undefined on omission or
|
|
206
|
+
failure. It can return a header object or `Headers`, including Basic credentials
|
|
207
|
+
for an existing custom endpoint. The relay sends exactly `raw`, follows no
|
|
208
|
+
redirects, cancels response bodies without reading them, and never includes
|
|
209
|
+
backend response content or callback exception text in results. Callbacks are
|
|
210
|
+
trusted host code: preserve the supplied bytes and keep secrets out of logs.
|
|
211
|
+
Do not put an archive key or other per-attempt value into signed provider metadata;
|
|
212
|
+
doing so changes the persisted message identity on retries.
|
|
213
|
+
|
|
214
|
+
Every result has `status`, `reason` and `archiveFailed`. HTTP responses also add
|
|
215
|
+
`httpStatus`:
|
|
216
|
+
|
|
217
|
+
| Status | Reasons |
|
|
218
|
+
| --- | --- |
|
|
219
|
+
| `delivered` | `delivered` (HTTP 2xx) |
|
|
220
|
+
| `rejected` | `invalid_envelope`, `too_large`, `not_accepted` |
|
|
221
|
+
| `failed` | `unreadable`, `invalid_options`, `acceptance_failed`, `backend_failed`, `invalid_backend`, `headers_failed`, `timeout`, `fetch_failed`, `http_status` |
|
|
222
|
+
|
|
223
|
+
All non-2xx responses, including redirects and permanent client errors, return
|
|
224
|
+
`failed`/`http_status`; your host chooses which are permanent, retryable or eligible
|
|
225
|
+
for fallback. The relay never retries on its own.
|
|
226
|
+
|
|
227
|
+
`archiveEmail({ bucket, key, raw, from, to })` writes raw RFC822 bytes to an R2
|
|
228
|
+
binding and returns `{ key }`. Keys must be nonempty, at most 1,024 UTF8 bytes and
|
|
229
|
+
contain no ASCII control characters. It stores `message/rfc822` content type and
|
|
230
|
+
ASCII-safe `from`, `to` and `size` custom metadata; non-ASCII envelope characters
|
|
231
|
+
become `?` in this display metadata only. Retention, unique keys, archive browsing
|
|
232
|
+
and recovery authorization belong to the host. Rails email persistence starts
|
|
233
|
+
after delivery and does not replace this optional outage archive.
|
|
234
|
+
|
|
235
|
+
## Durable inbound delivery
|
|
236
|
+
|
|
237
|
+
[Durable inbound delivery](./docs/durable-inbound.md) is the default. Provision the
|
|
238
|
+
configured private R2 bucket, Queue producer/consumer and cron before deploying.
|
|
239
|
+
There is no rollout enable flag or automatic downgrade when bindings are missing.
|
|
240
|
+
The guide covers upgrades, monitoring, recovery tests and explicit direct fallback.
|
|
241
|
+
|
|
242
|
+
## Validate changes
|
|
243
|
+
|
|
244
|
+
```sh
|
|
245
|
+
npm ci
|
|
246
|
+
npm test
|
|
247
|
+
npm run check
|
|
248
|
+
npm audit
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Tooling dependencies are development-only; the deployed Worker imports no npm
|
|
252
|
+
packages. `check` bundles a development deployment locally without publishing.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Durable inbound delivery
|
|
2
|
+
|
|
3
|
+
When Rails is down, keep the email at Cloudflare and deliver it when Rails returns.
|
|
4
|
+
The bundled Worker uses R2 storage, Queue delivery and scheduled recovery by
|
|
5
|
+
default. No enable flag, Rails model, database, or tenancy change is needed.
|
|
6
|
+
Missing infrastructure fails visibly; it never silently downgrades to direct delivery.
|
|
7
|
+
|
|
8
|
+
Upgrade the Rails gem before deploying this Worker. New messages without a parseable
|
|
9
|
+
Message-ID use a deterministic fallback for deduplication across Rails hosts.
|
|
10
|
+
Older records created before this change use ActionMailbox's hostname-dependent
|
|
11
|
+
fallback; replaying those historical messages after upgrading can create a new
|
|
12
|
+
record. Reconcile any existing archive backlog before replaying it automatically.
|
|
13
|
+
|
|
14
|
+
## Set it up
|
|
15
|
+
|
|
16
|
+
Create a private R2 bucket and a Queue for each environment:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npx wrangler r2 bucket create cloudflare-email-inbound-production
|
|
20
|
+
npx wrangler queues create cloudflare-email-inbound-production
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The bundled `wrangler.toml` already configures these resources for each environment.
|
|
24
|
+
Customize their names if needed. Configure **all three** pieces: the `INBOUND_EMAIL_STORE` R2
|
|
25
|
+
binding, `INBOUND_EMAIL_QUEUE` producer and consumer, and the once-per-minute
|
|
26
|
+
scheduled trigger. Keep `RAILS_INGRESS_URL` and `INGRESS_SECRET` configured as
|
|
27
|
+
before. Deploy with `npm run deploy -- --env production`.
|
|
28
|
+
|
|
29
|
+
Use separate buckets, queues and secrets for staging and production. Do not add
|
|
30
|
+
an R2 lifecycle rule that expires `cloudflare-email/pending/` objects. Pending
|
|
31
|
+
mail has no automatic expiration and can incur storage costs during a long
|
|
32
|
+
outage. Do not publish the bucket or expose an unauthenticated recovery endpoint.
|
|
33
|
+
Review storage access and retention as you would the Rails email database.
|
|
34
|
+
Provision and deploy the infrastructure with Wrangler first; retain that configuration
|
|
35
|
+
in your infrastructure repository. Subsequent `cloudflare:email:deploy_worker` Rails
|
|
36
|
+
task uploads preserve R2/Queue bindings and check the bucket binding, queue producer
|
|
37
|
+
binding, and minute schedule before changing code or secrets. This check does not
|
|
38
|
+
validate the bucket lifecycle or queue consumer: verify those in your infrastructure
|
|
39
|
+
configuration and live drill. The Ruby task does not provision resources.
|
|
40
|
+
|
|
41
|
+
## Direct fallback and upgrades
|
|
42
|
+
|
|
43
|
+
Set `INBOUND_DELIVERY_MODE = "direct"` in the environment's Wrangler vars only when
|
|
44
|
+
you deliberately want a single HTTP handoff. With the Ruby task, pass
|
|
45
|
+
`INBOUND_DELIVERY_MODE=direct bin/rails cloudflare:email:deploy_worker`.
|
|
46
|
+
Direct mode permanently rejects on HTTP/network failure and cannot buffer an outage.
|
|
47
|
+
Keep the existing R2/Queue bindings and cron during fallback: queue and scheduled
|
|
48
|
+
recovery continue draining previously retained mail. To return to durable delivery,
|
|
49
|
+
remove the Wrangler override or pass `INBOUND_DELIVERY_MODE=durable` to the Ruby task.
|
|
50
|
+
Unknown values fail rather than choosing a transport implicitly.
|
|
51
|
+
|
|
52
|
+
The old `DURABLE_INBOUND_ENABLED` rollout flag has been removed. Existing direct
|
|
53
|
+
installations must provision the resources before upgrading the Worker, or explicitly
|
|
54
|
+
select `INBOUND_DELIVERY_MODE=direct` before deploying. Upgrading the Ruby gem alone
|
|
55
|
+
does not redeploy a Worker or change live routing.
|
|
56
|
+
|
|
57
|
+
## What happens to a message
|
|
58
|
+
|
|
59
|
+
1. The Worker validates the envelope and actual MIME size. Invalid or oversized
|
|
60
|
+
mail is permanently rejected, as in direct mode.
|
|
61
|
+
2. It writes one R2 object containing the exact raw bytes, SMTP envelope, original
|
|
62
|
+
receive time, and optional provider metadata. It waits for the write before
|
|
63
|
+
returning from the Email Worker handler.
|
|
64
|
+
3. It queues a small `{ version: 1, key }` pointer. If enqueueing fails, the stored
|
|
65
|
+
object remains discoverable by the scheduled sweep.
|
|
66
|
+
4. The consumer signs the original bytes and metadata with a fresh timestamp and
|
|
67
|
+
POSTs to Rails. Only HTTP 2xx permits deleting the pending object; the queue
|
|
68
|
+
message is acknowledged only after that deletion completes.
|
|
69
|
+
5. Timeout, connection failure, redirect, 401, 413 and all other non-2xx responses
|
|
70
|
+
retain the object and retry. Fix credentials or size configuration for errors
|
|
71
|
+
that cannot resolve themselves. There is no automatic discard on client errors.
|
|
72
|
+
|
|
73
|
+
If Rails saved a message but its response was lost, replay sends the same message
|
|
74
|
+
identity. Rails ingress deduplicates using the original bytes and envelope (and
|
|
75
|
+
provider metadata for v3); changing the signing timestamp does not change that
|
|
76
|
+
identity. A failed R2 deletion may also cause replay. A queue pointer to an
|
|
77
|
+
already deleted object is a successful no-op and never resends mail. Do not reuse
|
|
78
|
+
pending keys, alter saved context, or replace the ingress with an endpoint that
|
|
79
|
+
acknowledges before durable ingestion. Retain Rails deduplication records for at
|
|
80
|
+
least your maximum recovery horizon; manual restore/replay after Rails incineration
|
|
81
|
+
can produce another message. Application processing must tolerate duplicates too.
|
|
82
|
+
|
|
83
|
+
The scheduled handler scans five pending objects per minute and attempts their
|
|
84
|
+
handoff directly. It persists an R2 pagination cursor and advances past failed
|
|
85
|
+
objects, wrapping back to the beginning at the end. Poison messages therefore do
|
|
86
|
+
not permanently block later pages. This sweep handles both enqueue failure and
|
|
87
|
+
exhausted Queue retries without a separate dead-letter queue. During recovery,
|
|
88
|
+
Queue consumers provide the primary throughput; scheduled-only recovery takes
|
|
89
|
+
at least `ceil(pending_count / 5)` minutes per full pass. Overlapping cron runs
|
|
90
|
+
may repeat a page; delivery remains idempotent. Scale and exercise this recovery
|
|
91
|
+
rate against your expected backlog before depending on it.
|
|
92
|
+
|
|
93
|
+
The template buffers MIME to sign it, and the durable path also builds a framed
|
|
94
|
+
storage payload. Test messages near your configured size limit with realistic
|
|
95
|
+
concurrent traffic against the Worker's memory limit before production rollout.
|
|
96
|
+
Lower `MAX_EMAIL_BYTES` in both Worker and Rails if your workload requires it;
|
|
97
|
+
lowering it below already retained message sizes leaves those messages pending
|
|
98
|
+
until the limit is restored or they are recovered through an authorized process.
|
|
99
|
+
|
|
100
|
+
## Operate and verify
|
|
101
|
+
|
|
102
|
+
Enable Worker logs and alerts for the structured
|
|
103
|
+
`component: "cloudflare_email_inbound"` events. They include a random storage key
|
|
104
|
+
and fixed reason, never raw messages, addresses, secrets or HTTP response bodies.
|
|
105
|
+
Alert on `storage_failed`, `enqueue_failed_retained`, repeated `queue_retry_retained`
|
|
106
|
+
or `sweep_retry_retained`, Worker exceptions, and growth/age of objects under the
|
|
107
|
+
pending prefix. `receivedAt` is inside the private stored frame; R2 object upload
|
|
108
|
+
time is also available to infrastructure monitoring. Queue backlog alone is not
|
|
109
|
+
sufficient: exhausted pointers may disappear while retained mail remains in R2.
|
|
110
|
+
This template emits logs; configure your monitoring service to page the operator.
|
|
111
|
+
|
|
112
|
+
In staging, stop Rails, send mail, confirm pending R2 objects, restore Rails, then
|
|
113
|
+
verify one inbound message and removal of its pending object. Also verify recovery
|
|
114
|
+
with the queue consumer disabled or retries exhausted, a lost HTTP response after
|
|
115
|
+
Rails saved the message, invalid ingress credentials, and a failed R2 write.
|
|
116
|
+
The repository includes unit coverage and a local Worker/Rails integration harness.
|
|
117
|
+
Before changing ingress URLs, remember retained messages use the current configured
|
|
118
|
+
URL and secret. Drain existing retained mail before repointing a bucket to another
|
|
119
|
+
application or disabling the scheduled recovery trigger.
|
|
120
|
+
|
|
121
|
+
For a custom Worker, import `retainEmail(message, env, { metadata })`,
|
|
122
|
+
`consumeRetainedEmails(batch, env)` and `sweepRetainedEmails(env)` from `src/index.js`.
|
|
123
|
+
Wire the latter two to your queue and scheduled handlers as in the default export.
|
|
124
|
+
For a secondary raw archive, pass `archive: async ({ raw, from, to, key }) => ...`
|
|
125
|
+
to `retainEmail`. It runs after the primary R2 commit with the original MIME bytes;
|
|
126
|
+
`key` is the pending object identity. Its errors or default 10-second timeout are
|
|
127
|
+
logged as `archive_failed_retained` and do not prevent queue delivery. A timed-out
|
|
128
|
+
callback is not canceled. Set `archiveTimeoutMs` to an integer from 1 to 120,000
|
|
129
|
+
milliseconds to fit your host's runtime budget. Treat bytes as read-only and never parse the private
|
|
130
|
+
pending frame to build an archive. The primary R2 write is always mandatory.
|
|
131
|
+
Trusted provider metadata is captured once at receipt, never recomputed on replay.
|
|
132
|
+
The existing `relayEmail` archive callback is best effort and does not enable this
|
|
133
|
+
durable protocol automatically.
|
|
134
|
+
|
|
135
|
+
## Guarantee boundary
|
|
136
|
+
|
|
137
|
+
After a successful R2 write, Rails outages and finite Queue retention do not
|
|
138
|
+
discard the stored payload. This is at-least-once handoff, with Rails deduplication;
|
|
139
|
+
it is not an absolute guarantee against all loss. R2 write failures, upstream
|
|
140
|
+
SMTP acceptance behavior, account suspension, deleted infrastructure, misconfigured
|
|
141
|
+
lifecycle rules and manual intervention remain outside that promise. A failed
|
|
142
|
+
initial durable write throws and emits an error; it must not be mistaken for
|
|
143
|
+
successful storage. Monitor that failure separately.
|
|
144
|
+
|
|
145
|
+
Cloudflare documents [`setReject`](https://developers.cloudflare.com/email-service/api/route-emails/email-handler/)
|
|
146
|
+
as a permanent SMTP rejection. Throwing an Email Worker error is not treated here
|
|
147
|
+
as a documented durable SMTP retry contract. Cloudflare Queues has a
|
|
148
|
+
[128 KB message limit and finite retention/retries](https://developers.cloudflare.com/queues/platform/limits/),
|
|
149
|
+
which is why the queue stores only pointers and R2 retains the message independently.
|