cloudflare-email 0.2.0 → 0.4.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +109 -0
  3. data/README.md +44 -9
  4. data/app/controllers/cloudflare/email/ingress_controller.rb +15 -60
  5. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +9 -0
  6. data/app/controllers/cloudflare/email/management/styles_controller.rb +9 -0
  7. data/docs/activerecord-tenanted.md +46 -0
  8. data/docs/architecture.md +13 -3
  9. data/docs/custom-ingress.md +211 -0
  10. data/docs/delivery-events.md +9 -0
  11. data/docs/features.md +36 -5
  12. data/docs/getting-started.md +19 -8
  13. data/docs/mailboxes.md +74 -3
  14. data/docs/management-engine.md +190 -0
  15. data/docs/routing-deliveries.md +94 -0
  16. data/docs/routing-diagnostics.md +75 -0
  17. data/docs/troubleshooting.md +5 -4
  18. data/docs/upgrading-0.2.md +5 -0
  19. data/docs/upgrading-0.3.md +55 -0
  20. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  21. data/docs/verification/2026-09-12-destination-context.md +46 -0
  22. data/docs/verification/2026-09-13-action-mailbox-core.md +96 -0
  23. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  24. data/lib/cloudflare/email/active_record/base.rb +3 -41
  25. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  26. data/lib/cloudflare/email/check_route_task.rb +21 -0
  27. data/lib/cloudflare/email/client.rb +45 -10
  28. data/lib/cloudflare/email/delivery_method.rb +2 -0
  29. data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
  30. data/lib/cloudflare/email/engine.rb +6 -13
  31. data/lib/cloudflare/email/envelope.rb +4 -14
  32. data/lib/cloudflare/email/error.rb +5 -12
  33. data/lib/cloudflare/email/event_consumer.rb +12 -3
  34. data/lib/cloudflare/email/ingress.rb +126 -0
  35. data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
  36. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
  37. data/lib/cloudflare/email/mailboxes/models.rb +8 -130
  38. data/lib/cloudflare/email/mailboxes/service.rb +7 -174
  39. data/lib/cloudflare/email/mailboxes.rb +1 -0
  40. data/lib/cloudflare/email/management/adapter.rb +9 -0
  41. data/lib/cloudflare/email/management/configuration.rb +10 -0
  42. data/lib/cloudflare/email/management/engine.rb +17 -0
  43. data/lib/cloudflare/email/management/routes.rb +14 -0
  44. data/lib/cloudflare/email/management.rb +7 -0
  45. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  46. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  47. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  48. data/lib/cloudflare/email/tenancy.rb +2 -73
  49. data/lib/cloudflare/email/tenant_job_context.rb +2 -85
  50. data/lib/cloudflare/email/verification.rb +17 -9
  51. data/lib/cloudflare/email/version.rb +1 -1
  52. data/lib/cloudflare/email/worker_deployer.rb +36 -5
  53. data/lib/cloudflare-email.rb +6 -0
  54. data/lib/generators/cloudflare/email/install_generator.rb +6 -5
  55. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  56. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  57. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +5 -0
  58. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
  59. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  60. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  61. data/lib/tasks/cloudflare_email.rake +2 -1
  62. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  63. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  64. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  65. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  66. data/templates/deploy-to-cloudflare/README.md +104 -0
  67. data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
  68. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  69. data/templates/deploy-to-cloudflare/package.json +37 -0
  70. data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
  71. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  72. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  73. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  74. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  75. data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
  76. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  77. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  78. data/templates/worker/README.md +186 -15
  79. data/templates/worker/docs/domain-setup.md +198 -0
  80. data/templates/worker/docs/durable-inbound.md +149 -0
  81. data/templates/worker/package-lock.json +2 -2
  82. data/templates/worker/package.json +2 -1
  83. data/templates/worker/scripts/check-subdomains.mjs +54 -0
  84. data/templates/worker/src/index.js +331 -38
  85. data/templates/worker/test/durable.test.ts +193 -0
  86. data/templates/worker/test/index.test.ts +78 -20
  87. data/templates/worker/test/relay.test.ts +226 -0
  88. data/templates/worker/test/subdomains.test.ts +51 -0
  89. data/templates/worker/wrangler.toml +50 -0
  90. metadata +65 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5188438b941345c4164baca6ab1400e2fc74259c5fdd209a6d2ed9ac618147b5
4
- data.tar.gz: 5e3a6ff25c460c0a73ffb3fdf44dcc62f87679f4278e328c605291d62de35fa9
3
+ metadata.gz: c65b29d22af39c25cb2affb776984bc9a9086bcad75c7388c2017692f61eb9ef
4
+ data.tar.gz: 2c0d1f15f4159e780257260af62cca8d414c7cc00d7c1c8b2677ac243cf81b04
5
5
  SHA512:
6
- metadata.gz: 77fe9d9c571311065e9d10d00e2c021156113ff63dec5a970f63456a6cee9d3413bfe157dcfd8e37992e5b747e1ab957d342bfc01a399d41e2c3838462067aa3
7
- data.tar.gz: bf580baaec84aa3dc8894f0fbc9b770ca4fe2521a1534b248fe75bd67812a40adaf4538d321a7740b911b1fc42b80e5fd468418c4d804d635f5496710591add6
6
+ metadata.gz: 4b76b7c08c99da504f7c90bbfb79f6f3130345e128c0fad9988a1449270d79019ffa5af8fb62a0759ad881c56019ffe6d9f6b967d13136e988b9e98ef6d7043e
7
+ data.tar.gz: d3f2d2573c6d41140ceff2395c4aa84194eb10ad2c56379df133f1fa1687424b009513800e4c67d1077584750146c2f5b9e0dfedfb53d2b9a351c5f207f80636
data/CHANGELOG.md CHANGED
@@ -1,5 +1,114 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-09-14
4
+
5
+ Released alongside Mailbox Kit 0.1.0. Cloudflare installs the core automatically.
6
+ Existing installations should follow the [core upgrade guide](mailbox-kit/docs/upgrading.md).
7
+
8
+ - Allow receiving domains without a sending account. The upgrade generator
9
+ preserves existing directory records while making account ownership optional.
10
+
11
+ - Index inbox memberships by inbound email for recipient isolation checks,
12
+ retention and purge. New schemas include the index; `mailbox_kit:upgrade`
13
+ generates a data-preserving migration for existing installations.
14
+ - Delegate shared configuration to Mailbox Kit and isolate Cloudflare session
15
+ additions in an integration module. Provide a short core quickstart plus
16
+ packaged integration and upgrade guides.
17
+
18
+ - Lean on Action Mailbox for original email records, processing and configurable
19
+ retention. Add idempotent attachment of existing Rails records and source-based
20
+ receiving that resolves duplicates for inbox membership. Share the persistence
21
+ bridge with Cloudflare while preserving its signed delivery identity and legacy
22
+ duplicate return value. Reuse Rails' model load hook for selective retention.
23
+
24
+ - Extract provider-neutral mailbox storage, recipient routing, optional tenancy,
25
+ raw-message retention, Rails job context and the server-rendered UI into the
26
+ sibling `mailbox-kit` gem. Cloudflare APIs, Worker transport, sending outbox and
27
+ delivery feedback remain in `cloudflare-email`. Preserve existing table names,
28
+ public constants, job payload keys and mounted Cloudflare engine routes.
29
+ See [Mailbox Kit](mailbox-kit/README.md) for standalone setup and release order.
30
+
31
+ - Add a cited one-time domain setup guide for both receiving domains and dynamic
32
+ organization subdomains, shared between Worker templates. Add a read-only
33
+ `npm run check:subdomains` DNS helper with named/fresh probes and explicit limits
34
+ on what DNS can verify. The Worker transport and gem routing behavior are unchanged.
35
+
36
+ ## 0.3.0 — 2026-09-13
37
+
38
+ See [upgrading from 0.2](docs/upgrading-0.3.md) before deploying the updated Worker.
39
+
40
+ - Add a standalone Deploy to Cloudflare template with R2/Queue provisioning,
41
+ secret prompts, scheduled recovery, and logs. CI checks runtime/configuration
42
+ synchronization with the gem's Worker and tests an isolated template copy.
43
+
44
+ - Make durable inbound delivery the Worker default, with explicit direct fallback: commit original mail and envelope context
45
+ to R2 before Worker acceptance, deliver queue pointers with fresh signatures,
46
+ and recover pending mail through a bounded scheduled sweep. Rails errors,
47
+ lost responses, enqueue failures and exhausted queue retries retain pending
48
+ mail. Provisioning, protected storage and monitoring remain explicit.
49
+ - Make missing Message-ID fallback deterministic across Rails hosts so replay
50
+ after a deployment does not create another inbound record. Original MIME and
51
+ parseable provider Message-IDs remain unchanged.
52
+ - Add optional authenticated Routing analytics delivery confirmation with
53
+ encrypted immutable receipts, strict single-envelope-recipient correlation,
54
+ replay and transactional callbacks. Sending acceptance remains unchanged;
55
+ missing or ambiguous evidence never authorizes a resend.
56
+ - Bound shared sending/queue HTTP responses by streamed bytes and a total
57
+ per-attempt deadline; disable implicit HTTP retries and sanitize transport
58
+ failures. Sending and queue acknowledgement ambiguity stays safe to replay
59
+ through durable receipts rather than resending accepted email.
60
+ - Document verified Routing destinations, separate analytics credentials and
61
+ explicit system-mail storage with optional SQLite tenancy.
62
+
63
+ - Add optional domain catch-all receiving on an existing active mailbox address,
64
+ with explicit verification evidence and a unique active catch-all constraint.
65
+ Exact registered addresses always win, including rejected inactive addresses;
66
+ unknown local parts retain their actual envelope recipient without new aliases.
67
+ Destination snapshots identify fallback, while sending stays exact-address only.
68
+ A separate generator upgrades existing mailbox schemas; catch-all is never
69
+ enabled automatically.
70
+
71
+ - Allow production Rails schema preloading to boot without a selected tenant.
72
+ Tenant model pool access still fails closed with
73
+ `Cloudflare::Email::ActiveRecord::TenantConnectionUnavailable`, an
74
+ `ActiveRecord::ConnectionNotEstablished` error retaining the original
75
+ `ConfigurationError` as its cause. No default tenant is selected.
76
+
77
+ - Add a reusable Worker `relayEmail` pipeline with host backend/header policies,
78
+ bounded raw reads, optional fail-open archive hooks, safe HTTP outcomes and
79
+ deadlines. `archiveEmail` writes raw bytes and envelope metadata to an optional
80
+ R2 bucket. Default and custom forwarding share the same HTTP transport.
81
+ - Keep provider authentication interpretation, archive keys, retry decisions and
82
+ fallback destinations with the host; existing default v2/v3 forwarding remains
83
+ compatible. Archive deadlines bound waiting but cannot cancel an in-flight write.
84
+
85
+ - Add `Mailboxes.with_recipient` for active-address resolution and immutable
86
+ destination context with host-owned persistence, without ActionMailbox.
87
+ - Yield the same destination from `Mailboxes.receive` so apps can link their
88
+ business records while retaining gem-managed raw email, metadata and membership
89
+ persistence. Host exceptions propagate instead of being reported as missing
90
+ mailbox records; existing no-argument receive blocks continue to work.
91
+
92
+ - Add a bounded, Rails-independent `Ingress.verify` API for existing ingestion
93
+ pipelines, with optional ActionMailbox persistence and tenant registry routing.
94
+ - Add opt-in v3 signatures for provider metadata supplied by custom Workers.
95
+ Metadata is authenticated transport evidence, not proof of sender authenticity.
96
+ Default Worker forwarding remains v2; upgrade Rails before enabling v3.
97
+ - Verify signatures against exact timestamp header bytes; decimal parsing is
98
+ used only for freshness. The bundled Worker's timestamp format is unchanged.
99
+ - Add GET-only `cloudflare:email:check_route` diagnostics for exact receiving-domain
100
+ DNS and Worker routing. Reports uncertainty without changing configuration or
101
+ claiming live delivery is verified.
102
+
103
+ - Add an opt-in server-rendered mailbox management engine with host authentication,
104
+ mailbox/domain scoping and per-action permissions. Manage mailboxes and aliases,
105
+ suspend/resume, preview plain-text messages and mark read/archive without
106
+ JavaScript or asset pipeline dependencies. The engine is never mounted by default.
107
+
108
+ - Fix managed domain and address activation in Rails apps with strict readonly
109
+ attributes enabled. Normalize immutable identities only when creating records;
110
+ later lifecycle updates retain the original domain and address.
111
+
3
112
  ## 0.2.0 — 2026-09-11
4
113
 
5
114
  - Add optional managed mailboxes: domain directory, addresses/aliases, lifecycle,
data/README.md CHANGED
@@ -2,10 +2,21 @@
2
2
 
3
3
  Ruby client for [Cloudflare Email Service](https://developers.cloudflare.com/email-service/), with ActionMailer, authenticated ActionMailbox ingress, a forwarding Worker, and optional durable Rails sending and delivery-event tracking.
4
4
 
5
- Version **0.2.0**. Ruby 3.2+, Rails 7.2–8.1; Ruby 4.0 is tested with Rails 8.1. Supported test floors are Rails 7.2.3.2, 8.0.5.1, and 8.1.3.1. Prefer a maintained Ruby/Rails release for new applications. The plain Ruby client uses Ruby's standard libraries plus the Base64 gem. Node is optional: Worker deployment also works through the included Ruby deployer. See [security guidance](SECURITY.md) for deployment responsibilities.
5
+ Version **0.4.0**, with **Mailbox Kit 0.1.0**. Ruby 3.2+, Rails 7.2–8.1; Ruby 4.0 is tested with Rails 8.1. Supported test floors are Rails 7.2.3.2, 8.0.5.1, and 8.1.3.1. Prefer a maintained Ruby/Rails release for new applications. Rails and database dependencies remain optional for the plain Ruby client. Node is optional: Worker deployment also works through the included Ruby deployer. See [security guidance](SECURITY.md) for deployment responsibilities.
6
+
7
+ [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cole-robertson/cloudflare-email/tree/main/templates/deploy-to-cloudflare)
8
+
9
+ Deploy the inbound Worker with guided R2/Queue provisioning and secret setup. [Prepare Rails and connect your email address](templates/deploy-to-cloudflare/README.md).
6
10
 
7
11
  ## Start here
8
12
 
13
+ **Starting in 0.4.0:** provider-neutral mailbox functionality lives in
14
+ the sibling [Mailbox Kit](mailbox-kit/README.md) gem. Existing Cloudflare setup
15
+ continues to work; the core requires no Cloudflare account. Installing
16
+ `cloudflare-email` also installs `mailbox-kit` 0.1.x.
17
+ Existing mailbox installations should use the [core upgrade guide](mailbox-kit/docs/upgrading.md)
18
+ to add the inbound lookup index without recreating their tables.
19
+
9
20
  **Sending email?** Start with the [step-by-step Rails guide](docs/getting-started.md).
10
21
  **Building a mailbox?** Follow the same guide through receiving, the SQLite-compatible outbox, and delivery tracking.
11
22
  **Using plain Ruby?** Jump to [Plain Ruby](#plain-ruby); Rails and a database are optional.
@@ -14,21 +25,30 @@ Version **0.2.0**. Ruby 3.2+, Rails 7.2–8.1; Ruby 4.0 is tested with Rails 8.1
14
25
  | --- | --- |
15
26
  | [Features at a glance](docs/features.md) | Everything the gem handles, and what your app supplies |
16
27
  | [Getting started](docs/getting-started.md) | Install, send your first email, receive replies, and save reliable send operations |
28
+ | [Cloudflare domain setup](templates/worker/docs/domain-setup.md) | Configure once for many mailboxes or dynamic organization subdomains, with a Rebulk example and DNS checks |
29
+ | [Hello-world Rails template](https://github.com/cole-robertson/cloudflare-email-rails-starter) | Clone a minimal SQLite mailbox app using the published gem and its management UI |
17
30
  | [Troubleshooting](docs/troubleshooting.md) | What to check when mail or delivery updates do not arrive |
18
31
  | [Managed mailboxes](docs/mailboxes.md) | Create inboxes and aliases, read/archive mail, and send from a mailbox |
32
+ | [Management engine](docs/management-engine.md) | Mount an optional server-rendered mailbox UI using your app's authentication |
33
+ | [Custom ingress](docs/custom-ingress.md) | Reuse authentication, signed metadata, and tenant routing in your existing ingestion pipeline |
34
+ | [Routing diagnostics](docs/routing-diagnostics.md) | Inspect exact-domain DNS and Worker routes without changing infrastructure |
35
+ | [Reusable Worker pipeline](templates/worker/README.md#reuse-the-transport-in-an-existing-worker) | Keep custom backend and archive policies while sharing bounded email forwarding |
36
+ | [Durable inbound delivery](templates/worker/README.md#durable-inbound-delivery) | Store incoming mail in R2 and recover Rails outages with queued retries and scheduled recovery |
37
+ | [Routing delivery confirmation](docs/routing-deliveries.md) | Confirm qualifying normal-address deliveries using authenticated Routing analytics |
19
38
  | [SQLite tenant databases](docs/activerecord-tenanted.md) | Give each organization its own SQLite database with `activerecord-tenanted` |
20
39
  | [Durable outbox](docs/outbox.md) | Detailed setup, callbacks, retries, and recovery |
21
40
  | [Delivery events](docs/delivery-events.md) | Cloudflare Queue setup and recipient status tracking |
22
- | [Upgrading to 0.2](docs/upgrading-0.2.md) | Changes needed for an existing installation |
41
+ | [Upgrading to 0.4](mailbox-kit/docs/upgrading.md) | Core extraction and data-preserving migrations for existing mailboxes |
42
+ | [Upgrading from 0.2](docs/upgrading-0.3.md) | Additional changes before deploying the durable Worker |
23
43
 
24
44
  The sections below are the configuration and API reference.
25
45
 
26
46
  ## Install and send from Rails
27
47
 
28
- Add version 0.2 to your Gemfile. Existing 0.1 users should follow the [upgrade guide](docs/upgrading-0.2.md), including the coordinated Rails/Worker update:
48
+ Add version 0.4 to your Gemfile. Existing mailbox users should follow the [core upgrade guide](mailbox-kit/docs/upgrading.md). When upgrading from 0.2, also follow the [durable Worker upgrade guide](docs/upgrading-0.3.md):
29
49
 
30
50
  ```ruby
31
- gem "cloudflare-email", "~> 0.2.0"
51
+ gem "cloudflare-email", "~> 0.4.0"
32
52
  ```
33
53
 
34
54
  ```sh
@@ -209,6 +229,11 @@ Equivalent environment names are `CLOUDFLARE_MANAGEMENT_TOKEN` and `CLOUDFLARE_I
209
229
  1. Add the receiving subdomain, for example `in.example.com`, in **Email Routing → apex domain → Settings → Subdomains**. This is a separate onboarding step from sending.
210
230
  2. Deploy the environment's Worker and create its route:
211
231
 
232
+ First create the private R2 bucket and Queue, then deploy the generated Wrangler
233
+ configuration using the [durable setup guide](templates/worker/docs/durable-inbound.md).
234
+ The Ruby task below updates that provisioned Worker while preserving its bindings.
235
+ It refuses a missing bucket/queue binding or recovery schedule before changing code.
236
+
212
237
  ```sh
213
238
  RAILS_ENV=production bin/rails cloudflare:email:deploy_worker URL=https://app.example.com/rails/action_mailbox/cloudflare/inbound_emails
214
239
  RAILS_ENV=production bin/rails cloudflare:email:provision_route ADDRESS=support@in.example.com
@@ -228,18 +253,18 @@ end
228
253
 
229
254
  `Cloudflare::Email::Envelope.for(inbound_email)` returns a string-keyed `{"from" => "sender@example.com", "to" => "support@example.com"}` hash, or `nil` when the record has no authenticated envelope (for example, another ingress). The metadata is stored on the raw-email blob before routing jobs enqueue. It does not modify the MIME source. Envelope sender information records the SMTP reverse path; it does not authenticate the human sender. An empty `from` is valid for bounces.
230
255
 
231
- Version 0.2 requires the bundled v2 Worker; missing or v1 signatures are rejected. Coordinate Rails and Worker deployment while ingress is paused. The Worker requires ASCII dot-atom addresses, at most 254 bytes with a 64-byte local part. Quoted local parts, address literals, and internationalized addresses are not supported by this envelope format.
256
+ The bundled Worker uses v2 signatures by default; missing or v1 signatures are rejected. The custom-ingress API also supports opt-in v3 signatures carrying authenticated Worker metadata. See [custom ingestion](docs/custom-ingress.md) and upgrade the Rails receiver before enabling v3 in a custom Worker. When upgrading from v1, coordinate Rails and Worker deployment while ingress is paused. Both envelope versions require ASCII dot-atom addresses, at most 254 bytes with a 64-byte local part. Quoted local parts, address literals, and internationalized addresses are not supported by this envelope format.
232
257
 
233
258
  Successful ingress storage returns HTTP 200; duplicate storage returns 200 too. The timestamp window limits request age, but is not a one-time replay ledger. Version 2 deduplication includes the exact SMTP recipient, so identical MIME delivered to separate To/Cc/Bcc recipients creates separate inbound records while a retry for the same recipient creates none.
234
259
 
235
- The Worker has a 15-second Rails request timeout and rejects redirects. Non-2xx responses, timeouts, and network failures call `message.setReject`. There is no durable buffering of inbound email: an application outage can reject mail. Storage acceptance does not guarantee later mailbox-job success. Monitor Rails jobs and Cloudflare Worker logs.
260
+ The Worker has a 15-second Rails request timeout and rejects redirects. Its default [durable inbound path](templates/worker/README.md#durable-inbound-delivery) saves messages in R2 before acceptance, then retries Rails handoffs through Queues and scheduled recovery. Provision the resources before deploying. `INBOUND_DELIVERY_MODE=direct` selects the single-attempt fallback, which can reject mail during an outage; existing retained mail keeps recovering. Rails storage acceptance does not guarantee later mailbox-job success. Monitor Rails jobs and Cloudflare Worker logs.
236
261
 
237
262
  ### Local development and deployment
238
263
 
239
- Start Rails, then:
264
+ For a simple development tunnel, use the explicit direct fallback. Start Rails, then:
240
265
 
241
266
  ```sh
242
- bin/rails cloudflare:email:deploy_worker
267
+ INBOUND_DELIVERY_MODE=direct bin/rails cloudflare:email:deploy_worker
243
268
  bin/rails cloudflare:email:dev
244
269
  ```
245
270
 
@@ -247,7 +272,7 @@ The dev task requires `cloudflared`, refuses environments other than development
247
272
 
248
273
  For a custom installer `--worker-dir`, pass `SCRIPT=custom-directory/src/index.js` to the Ruby `deploy_worker` task. The installer prints the corresponding command.
249
274
 
250
- Ruby deployment and Wrangler use matching names: `cloudflare-email-ingress-development`, `-staging`, and `-production`. The optional Wrangler path requires Node 22.12+ (or a supported newer version):
275
+ Ruby deployment and Wrangler use matching names: `cloudflare-email-ingress-development`, `-staging`, and `-production`. Initial durable infrastructure deployment uses Wrangler and requires Node 22.12+ (or a supported newer version):
251
276
 
252
277
  ```sh
253
278
  cd cloudflare-worker
@@ -312,6 +337,8 @@ Cloudflare's current [header documentation](https://developers.cloudflare.com/em
312
337
  | `account_id`, `api_token` | Required |
313
338
  | `base_url` | `https://api.cloudflare.com/client/v4` |
314
339
  | `timeout` | 30 seconds for open/read/write |
340
+ | `total_timeout` | Defaults to `timeout`; bounds one complete HTTP attempt, including headers and streamed body |
341
+ | `max_response_bytes` | 1 MiB for `Client`; 32 MiB for `EventConsumer` queue batches |
315
342
  | `retries` | 3 additional attempts |
316
343
  | `initial_backoff` | 0.5 seconds, doubling |
317
344
  | `max_retry_after` | 60 seconds |
@@ -320,6 +347,14 @@ Cloudflare's current [header documentation](https://developers.cloudflare.com/em
320
347
 
321
348
  By default, only 429 responses and pre-send connection failures retry. Numeric and HTTP-date `Retry-After` values are honored up to the cap. Read/write timeouts, connection resets, and 5xx responses may occur after acceptance; they raise without automatically resending. Setting `retry_ambiguous: true` restores retries for those failures and can send duplicates.
322
349
 
350
+ Responses are streamed with a byte limit, including decompressed content and
351
+ error responses. A total deadline or response-size failure after transmission
352
+ does not prove a send failed: the outbox preserves the ambiguous claim and blocks
353
+ automatic resend. Net::HTTP's implicit retries are disabled. Explicit retries
354
+ and backoff can make a logical call longer than `total_timeout`; queue handlers,
355
+ database transactions and a complete polling run have separate runtime budgets.
356
+ An uncertain queue acknowledgement permits receipt replay, never email resending.
357
+
323
358
  No idempotency key is sent. Reusing Message-ID does not guarantee deduplication or exactly-once delivery. Account for ActiveJob's retry policy too: retrying the whole mailer job can resend even when this client's retries are disabled.
324
359
 
325
360
  Errors inherit from `Cloudflare::Email::Error`: `ConfigurationError`, `AuthenticationError`, `ValidationError`, `RateLimitError`, `ServerError`, and `NetworkError`. API errors expose `status` and parsed `response`.
@@ -1,16 +1,12 @@
1
- require "cloudflare/email/verification"
1
+ require "cloudflare/email/ingress"
2
2
 
3
3
  module Cloudflare
4
4
  module Email
5
5
  # ActionMailbox ingress for Cloudflare Email Worker forwards.
6
6
  #
7
- # The shipped Worker template signs each forwarded message with HMAC-SHA256
8
- # over "v2.{timestamp}.{encoded_envelope}.{raw_body}" and sends:
9
- # X-CF-Email-Timestamp: <unix seconds>
10
- # X-CF-Email-Signature: <hex digest>
11
- # X-CF-Email-Signature-Version: 2
12
- # X-CF-Email-Envelope: <unpadded base64url JSON from/to>
13
- # Requests must include the v2 signature and authenticated SMTP envelope.
7
+ # The default Worker uses v2 authenticated SMTP envelopes; custom Workers
8
+ # can opt into v3 to authenticate additional provider metadata. Both use
9
+ # the shared, bounded Ingress verifier before any tenant lookup or storage.
14
10
  #
15
11
  # Set the shared secret in Rails credentials under cloudflare.ingress_secret
16
12
  # (or in the CLOUDFLARE_INGRESS_SECRET env var) and as the Worker secret
@@ -24,45 +20,29 @@ module Cloudflare
24
20
  "cloudflare_email.ingress",
25
21
  bytes: 0,
26
22
  ) do |payload|
27
- preflight = Cloudflare::Email::Verification.verify_headers(secret: secret,
28
- timestamp: request.headers["X-CF-Email-Timestamp"],
29
- signature: request.headers["X-CF-Email-Signature"],
30
- version: request.headers["X-CF-Email-Signature-Version"],
31
- envelope: request.headers["X-CF-Email-Envelope"])
32
- unless preflight == :ok
33
- payload[:result] = preflight
34
- next head(preflight == :stale ? :request_timeout : :unauthorized)
35
- end
36
- if request.content_length.to_i > max_email_bytes || raw_body.bytesize > max_email_bytes
37
- payload[:result] = :too_large
38
- next head(:payload_too_large)
39
- end
40
- payload[:bytes] = raw_body.bytesize
41
- case Cloudflare::Email::Verification.verify(
42
- secret: secret,
43
- body: raw_body,
44
- timestamp: request.headers["X-CF-Email-Timestamp"],
45
- signature: request.headers["X-CF-Email-Signature"],
46
- version: request.headers["X-CF-Email-Signature-Version"],
47
- envelope: request.headers["X-CF-Email-Envelope"],
48
- )
23
+ request.body.rewind if request.body.respond_to?(:rewind)
24
+ result = Cloudflare::Email::Ingress.verify(secret: secret,
25
+ headers: request.headers, body: request.body,
26
+ content_length: request.content_length, max_email_bytes: max_email_bytes)
27
+ payload[:bytes] = result.bytes.to_i
28
+ payload[:result] = result.status
29
+ case result.status
49
30
  when :stale
50
- payload[:result] = :stale
51
31
  head :request_timeout
52
32
  when :bad_signature
53
- payload[:result] = :bad_signature
54
33
  head :unauthorized
34
+ when :too_large
35
+ head :payload_too_large
55
36
  when :ok
56
37
  inbound = if defined?(Cloudflare::Email::Mailboxes) && Cloudflare::Email::Mailboxes.enabled?
57
- recipient = Cloudflare::Email::Envelope.decode(request.headers["X-CF-Email-Envelope"]).fetch("to")
58
38
  begin
59
- Cloudflare::Email::Mailboxes.receive(recipient: recipient) { persist_inbound }
39
+ result.message.receive_into_mailbox!
60
40
  rescue Cloudflare::Email::Mailboxes::Unavailable
61
41
  payload[:result] = :unavailable_mailbox
62
42
  next head(:unprocessable_entity)
63
43
  end
64
44
  else
65
- persist_inbound
45
+ result.message.persist_action_mailbox!
66
46
  end
67
47
  payload[:result] = inbound ? :ok : :duplicate
68
48
  payload[:message_id] = inbound&.message_id
@@ -73,37 +53,12 @@ module Cloudflare
73
53
 
74
54
  private
75
55
 
76
- def persist_inbound
77
- envelope = Cloudflare::Email::Envelope.decode(request.headers["X-CF-Email-Envelope"])
78
-
79
- # Commit trusted routing metadata before ActionMailbox's after_create_commit
80
- # enqueues routing. Identical MIME for To/Cc/Bcc recipients is independent.
81
- checksum = OpenSSL::Digest::SHA256.hexdigest("v2\0#{envelope.fetch('to')}\0".b + raw_body.b)
82
- ActionMailbox::InboundEmail.transaction do
83
- inbound = ActionMailbox::InboundEmail.create_and_extract_message_id!(raw_body, message_checksum: checksum)
84
- if inbound
85
- blob = inbound.raw_email.blob
86
- blob.update!(metadata: blob.metadata.merge(
87
- Cloudflare::Email::Envelope::METADATA_KEY => envelope.merge("version" => 2),
88
- ))
89
- end
90
- inbound
91
- end
92
- end
93
-
94
56
  # Override ActionMailbox::BaseController's default name inference so
95
57
  # `config.action_mailbox.ingress = :cloudflare` gates this controller.
96
58
  def ingress_name
97
59
  :cloudflare
98
60
  end
99
61
 
100
- def raw_body
101
- @raw_body ||= begin
102
- request.body.rewind if request.body.respond_to?(:rewind)
103
- request.body.read(max_email_bytes + 1).to_s
104
- end
105
- end
106
-
107
62
  def max_email_bytes
108
63
  @max_email_bytes ||= begin
109
64
  value = Integer(ENV.fetch("MAX_EMAIL_BYTES", DEFAULT_MAX_EMAIL_BYTES.to_s), 10)
@@ -0,0 +1,9 @@
1
+ require File.join(MailboxKit::ROOT, "app/controllers/mailbox_kit/management/mailboxes_controller.rb")
2
+ module Cloudflare
3
+ module Email
4
+ module Management
5
+ class MailboxesController < MailboxKit::Management::MailboxesController
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ require File.join(MailboxKit::ROOT, "app/controllers/mailbox_kit/management/styles_controller.rb")
2
+ module Cloudflare
3
+ module Email
4
+ module Management
5
+ class StylesController < MailboxKit::Management::StylesController
6
+ end
7
+ end
8
+ end
9
+ end
@@ -123,6 +123,52 @@ end
123
123
 
124
124
  See the [mailbox guide](mailboxes.md) for domain activation, address provisioning, sending, and event replay. Your application still authorizes which organization, mailbox, and message each user can access.
125
125
 
126
+ ## Account emails without an organization
127
+
128
+ Password resets and portal invitations can belong to the application rather
129
+ than an organization. If you enabled separate tenant databases, give those
130
+ outbox records an explicitly provisioned, host-reserved database too. Keep the
131
+ business record's organization empty; a storage key is not an organization or
132
+ an authorization grant.
133
+
134
+ For example, after configuring the adapter above, provision and migrate a
135
+ reserved database through your deployment workflow:
136
+
137
+ ```ruby
138
+ # Run once through trusted provisioning, not during sending or event replay.
139
+ TenantRecord.create_tenant("_system_outbound")
140
+ ```
141
+
142
+ Your own service can select storage consistently for sending and feedback:
143
+
144
+ ```ruby
145
+ module EmailStorage
146
+ SYSTEM_KEY = "_system_outbound".freeze
147
+
148
+ def self.with_outbound_storage(organization_key, &block)
149
+ # The caller must supply an already-authorized organization key.
150
+ key = organization_key.nil? ? SYSTEM_KEY : organization_key
151
+ Cloudflare::Email::Tenancy.with(key, &block)
152
+ end
153
+ end
154
+
155
+ EmailStorage.with_outbound_storage(nil) do
156
+ # Prepare/read/reconcile the system email's saved outbox operation here.
157
+ end
158
+ ```
159
+
160
+ Reserve the key against customer selection, include this database in migrations,
161
+ backups and restoration drills, and use the same selection for receipt replay.
162
+ If your shared business records use another database, retain their own durable
163
+ receipt/projection boundary: a tenant commit and a shared commit are not atomic.
164
+ Replay a committed gem operation rather than sending it again.
165
+
166
+ Never modify the tenancy adapter to treat every missing context as the system
167
+ database. Missing context must continue to fail closed. Your organization's job
168
+ fan-out should enumerate real organizations, not every database file. Applications
169
+ without configured multi-tenancy can keep ordinary system and organization mail
170
+ in their existing single database; this recipe does not enable tenancy by default.
171
+
126
172
  ## What is verified
127
173
 
128
174
  The optional CI job boots a real Rails application with `activerecord-tenanted`, runs the gem's tenant migrations in two SQLite databases, and checks identical numeric IDs remain isolated. It also verifies context restoration, refusal to create unknown tenant databases, and GlobalID's missing/wrong-tenant rejection. Separate integration tests exercise mailbox ingress, sending, jobs, and delivery-event replay.
data/docs/architecture.md CHANGED
@@ -14,7 +14,7 @@ durable infrastructure without copying the reference inbox's models and services
14
14
  | Shared event intake, tenant correlation and tenant-aware jobs | Durable queue workers, scheduled recovery and schema rollout to every tenant |
15
15
  | Structured/raw sending and ActionMailer transport | Compose UI, recipients and send authorization |
16
16
  | `Response#accepted?`, plus individual recipient outcomes | Handle partial acceptance without resending accepted recipients |
17
- | Authenticated v2 ingress and recipient-scoped deduplication | Map trusted recipient to an authorized mailbox |
17
+ | Authenticated ingress (default v2; opt-in v3 [metadata](custom-ingress.md)) and context-scoped deduplication | Map trusted recipient to an authorized mailbox; apply sender and document policy |
18
18
  | `MessageId.normalize` and returned provider IDs | Store conversation membership and scope reply lookups |
19
19
  | Queue decoding, validation and ACK | Queue/subscription setup and recurring execution |
20
20
  | Optional ActiveRecord event receipts, deduplication and indexed replay | Configure queue polling and receipt retention |
@@ -75,7 +75,15 @@ does not automatically provision domains or deploy infrastructure.
75
75
 
76
76
  Current limitations are deliberate and visible: no provider exactly-once API,
77
77
  no automatic resend of uncertain or rejected operations, no multi-provider-ID
78
- operation (conflicting IDs require review), and no durable inbound Worker buffer.
78
+ operation (conflicting IDs require review). Direct inbound forwarding can reject
79
+ mail during a Rails outage. The default [durable Worker path](../templates/worker/README.md#durable-inbound-delivery)
80
+ commits raw mail and stable envelope context to R2 before returning successfully.
81
+ Queue handoffs and a scheduled recovery pass retry pending mail; a Rails 2xx
82
+ allows removal from the pending store. Queue expiration does not remove R2 mail.
83
+ This boundary requires durable Rails persistence before 2xx, idempotent replay,
84
+ private R2 storage without expiration of pending objects, and operational
85
+ monitoring. It cannot guarantee acceptance when Cloudflare itself cannot store
86
+ the incoming message.
79
87
  A prepared operation can be dispatched using its existing identity after a job
80
88
  enqueue failure; sending/unknown operations require evidence-based reconciliation.
81
89
  Notifications indicate method execution and may run inside an outer transaction;
@@ -92,4 +100,6 @@ silently impose a marketing subscription model on transactional mail.
92
100
  The optional [mailbox module](mailboxes.md) now packages mailbox persistence and
93
101
  address management over the existing transport/receipt APIs. It includes a
94
102
  [tenant adapter](activerecord-tenanted.md), without adding a tenant library to
95
- the plain Ruby client. A future inbox UI or starter should consume these APIs.
103
+ the plain Ruby client. The optional [management engine](management-engine.md)
104
+ consumes these same APIs with host-supplied authentication and authorization.
105
+ Full inbox products can continue using them through their own frontends.