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.
Files changed (139) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +287 -385
  4. data/SECURITY.md +51 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +33 -21
  6. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
  7. data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
  8. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
  9. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
  10. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
  11. data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
  12. data/docs/activerecord-tenanted.md +181 -0
  13. data/docs/architecture.md +105 -0
  14. data/docs/custom-ingress.md +207 -0
  15. data/docs/delivery-events.md +186 -0
  16. data/docs/features.md +138 -0
  17. data/docs/getting-started.md +308 -0
  18. data/docs/mailboxes.md +363 -0
  19. data/docs/management-engine.md +190 -0
  20. data/docs/outbox.md +175 -0
  21. data/docs/routing-deliveries.md +94 -0
  22. data/docs/routing-diagnostics.md +68 -0
  23. data/docs/tenant-mailboxes-plan.md +149 -0
  24. data/docs/thread-correlation.md +27 -0
  25. data/docs/troubleshooting.md +104 -0
  26. data/docs/upgrading-0.2.md +101 -0
  27. data/docs/upgrading-0.3.md +55 -0
  28. data/docs/verification/2026-09-10-followup.md +49 -0
  29. data/docs/verification/2026-09-10-install-upgrade.md +86 -0
  30. data/docs/verification/2026-09-10-live.md +102 -0
  31. data/docs/verification/2026-09-10.md +175 -0
  32. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  33. data/docs/verification/2026-09-11-extraction.md +32 -0
  34. data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
  35. data/docs/verification/2026-09-11-security.md +76 -0
  36. data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
  37. data/docs/verification/2026-09-12-destination-context.md +46 -0
  38. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  39. data/examples/plain_ruby.rb +24 -0
  40. data/lib/cloudflare/email/active_record/base.rb +60 -0
  41. data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
  42. data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
  43. data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
  44. data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
  45. data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
  46. data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
  47. data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
  48. data/lib/cloudflare/email/active_record/outbox.rb +219 -0
  49. data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
  50. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  51. data/lib/cloudflare/email/active_record.rb +5 -0
  52. data/lib/cloudflare/email/check_route_task.rb +21 -0
  53. data/lib/cloudflare/email/client.rb +93 -18
  54. data/lib/cloudflare/email/consume_events_task.rb +22 -0
  55. data/lib/cloudflare/email/delivery_event.rb +70 -0
  56. data/lib/cloudflare/email/delivery_method.rb +5 -0
  57. data/lib/cloudflare/email/deploy_worker_task.rb +6 -3
  58. data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
  59. data/lib/cloudflare/email/dev_tunnel.rb +31 -5
  60. data/lib/cloudflare/email/doctor.rb +16 -38
  61. data/lib/cloudflare/email/endpoint.rb +24 -0
  62. data/lib/cloudflare/email/engine.rb +33 -0
  63. data/lib/cloudflare/email/envelope.rb +58 -0
  64. data/lib/cloudflare/email/event_consumer.rb +95 -0
  65. data/lib/cloudflare/email/ingress.rb +115 -0
  66. data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
  67. data/lib/cloudflare/email/mailboxes/events.rb +112 -0
  68. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
  69. data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
  70. data/lib/cloudflare/email/mailboxes/models.rb +165 -0
  71. data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
  72. data/lib/cloudflare/email/mailboxes/service.rb +372 -0
  73. data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
  74. data/lib/cloudflare/email/mailboxes.rb +11 -0
  75. data/lib/cloudflare/email/management/adapter.rb +31 -0
  76. data/lib/cloudflare/email/management/configuration.rb +17 -0
  77. data/lib/cloudflare/email/management/engine.rb +15 -0
  78. data/lib/cloudflare/email/management/management.css +68 -0
  79. data/lib/cloudflare/email/management/routes.rb +14 -0
  80. data/lib/cloudflare/email/management.rb +7 -0
  81. data/lib/cloudflare/email/message_id.rb +12 -0
  82. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  83. data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
  84. data/lib/cloudflare/email/provision_route_task.rb +2 -1
  85. data/lib/cloudflare/email/replay_events_job.rb +18 -0
  86. data/lib/cloudflare/email/response.rb +21 -3
  87. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  88. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  89. data/lib/cloudflare/email/routing_provisioner.rb +71 -45
  90. data/lib/cloudflare/email/send_job.rb +41 -0
  91. data/lib/cloudflare/email/send_test.rb +5 -29
  92. data/lib/cloudflare/email/signing.rb +2 -2
  93. data/lib/cloudflare/email/tenancy.rb +78 -0
  94. data/lib/cloudflare/email/tenant_job_context.rb +90 -0
  95. data/lib/cloudflare/email/verification.rb +38 -8
  96. data/lib/cloudflare/email/version.rb +1 -1
  97. data/lib/cloudflare/email/worker_deployer.rb +41 -8
  98. data/lib/cloudflare-email.rb +4 -1
  99. data/lib/generators/cloudflare/email/install_generator.rb +53 -37
  100. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  101. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  102. data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
  103. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +58 -0
  104. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
  105. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
  106. data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
  107. data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
  108. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  109. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  110. data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
  111. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
  112. data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
  113. data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
  114. data/lib/tasks/cloudflare_email.rake +42 -4
  115. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  116. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  117. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  118. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  119. data/templates/deploy-to-cloudflare/README.md +84 -0
  120. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  121. data/templates/deploy-to-cloudflare/package.json +36 -0
  122. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  123. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  124. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  125. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  126. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  127. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  128. data/templates/worker/README.md +231 -16
  129. data/templates/worker/docs/durable-inbound.md +149 -0
  130. data/templates/worker/package-lock.json +2627 -0
  131. data/templates/worker/package.json +8 -5
  132. data/templates/worker/scripts/wrangler.mjs +16 -0
  133. data/templates/worker/src/index.js +397 -25
  134. data/templates/worker/test/durable.test.ts +193 -0
  135. data/templates/worker/test/index.test.ts +183 -13
  136. data/templates/worker/test/relay.test.ts +226 -0
  137. data/templates/worker/wrangler.toml +63 -4
  138. metadata +154 -7
  139. data/lib/cloudflare/email/secure_message_id.rb +0 -89
@@ -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 install
11
- wrangler secret put INGRESS_SECRET # same value as cloudflare.ingress_secret in Rails credentials
12
- wrangler secret put RAILS_INGRESS_URL # e.g. https://your-app.com/rails/action_mailbox/cloudflare/inbound_emails
13
- wrangler deploy
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. Reads the raw RFC822 bytes from `message.raw`.
27
- 2. Computes `HMAC-SHA256(INGRESS_SECRET, "{unix_timestamp}.{raw_body}")`.
28
- 3. POSTs the raw bytes to `RAILS_INGRESS_URL` with:
29
- - `Content-Type: message/rfc822`
30
- - `X-CF-Email-Timestamp: <unix seconds>`
31
- - `X-CF-Email-Signature: <hex digest>`
32
- 4. If the Rails app responds non-2xx, the Worker calls `message.setReject` so
33
- Cloudflare returns a delivery failure to the sender (the message is not
34
- silently dropped).
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 older than 5 minutes (replay protection).
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.