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
@@ -55,12 +55,20 @@ module Cloudflare
55
55
 
56
56
  # Uploads/updates the Worker script. Accepts either `script_path:` (a
57
57
  # path to a .js file) or `source:` (the JS source string directly).
58
- def deploy(script_path: nil, source: nil)
58
+ # The Rails deploy task passes delivery_mode: "durable" and validates
59
+ # existing infrastructure. Low-level callers managing custom Workers may
60
+ # omit it to preserve their configured transport without changing it.
61
+ def deploy(script_path: nil, source: nil, delivery_mode: nil)
59
62
  source ||= File.read(script_path) if script_path
60
63
  raise ArgumentError, "must pass script_path: or source:" if source.nil?
61
64
 
65
+ if delivery_mode
66
+ raise ArgumentError, "delivery_mode must be durable or direct" unless %w[durable direct].include?(delivery_mode)
67
+ verify_durable_infrastructure! if delivery_mode == "durable"
68
+ end
69
+
62
70
  boundary = "----cf-email-#{SecureRandom.hex(16)}"
63
- body = build_multipart(boundary, source)
71
+ body = build_multipart(boundary, source, delivery_mode)
64
72
 
65
73
  request(
66
74
  method: :put,
@@ -108,11 +116,34 @@ module Cloudflare
108
116
 
109
117
  private
110
118
 
111
- def build_multipart(boundary, source)
112
- metadata = JSON.generate({
119
+ def verify_durable_infrastructure!
120
+ settings = request(method: :get, path: "/accounts/#{@account_id}/workers/scripts/#{@script_name}/settings")
121
+ bindings = Array(settings.dig("result", "bindings"))
122
+ configured = bindings.any? { |b| b["name"] == "INBOUND_EMAIL_STORE" && b["type"] == "r2_bucket" } &&
123
+ bindings.any? { |b| b["name"] == "INBOUND_EMAIL_QUEUE" && b["type"] == "queue" }
124
+ schedules = request(method: :get, path: "/accounts/#{@account_id}/workers/scripts/#{@script_name}/schedules")
125
+ configured &&= Array(schedules.dig("result", "schedules")).any? { |s| s["cron"] == "* * * * *" }
126
+ return if configured
127
+
128
+ raise ConfigurationError, "Provision durable R2, Queue producer/consumer and minute schedule using the generated Worker docs/durable-inbound.md before deploying; use INBOUND_DELIVERY_MODE=direct only for explicit direct fallback"
129
+ rescue Error => error
130
+ raise unless error.status == 404
131
+
132
+ raise ConfigurationError, "Provision the durable Worker with Wrangler first (see Worker docs/durable-inbound.md); use INBOUND_DELIVERY_MODE=direct only for explicit direct fallback"
133
+ end
134
+
135
+ def build_multipart(boundary, source, delivery_mode)
136
+ settings = {
113
137
  main_module: "index.js",
114
138
  compatibility_date: @compatibility_date,
115
- })
139
+ # API script uploads must not remove provisioned storage, queue or
140
+ # secrets. In particular a direct rollback still drains pending mail.
141
+ keep_bindings: %w[secret_text plain_text r2_bucket queue],
142
+ }
143
+ if delivery_mode
144
+ settings[:bindings] = [{ type: "plain_text", name: "INBOUND_DELIVERY_MODE", text: delivery_mode }]
145
+ end
146
+ metadata = JSON.generate(settings)
116
147
 
117
148
  parts = []
118
149
  parts << "--#{boundary}"
@@ -1,3 +1,9 @@
1
+ require "mailbox-kit"
2
+ module Cloudflare
3
+ module Email
4
+ Mailboxes = MailboxKit::Mailboxes
5
+ end
6
+ end
1
7
  require "cloudflare/email/version"
2
8
  require "cloudflare/email/error"
3
9
  require "cloudflare/email/response"
@@ -102,8 +102,8 @@ module Cloudflare
102
102
  should_deploy = options[:deploy_worker]
103
103
  if should_deploy.nil?
104
104
  say ""
105
- say " The Worker can be deployed via the Cloudflare API (pure Ruby, no wrangler/Node)"
106
- say " once you've set cloudflare.account_id and cloudflare.api_token in Rails credentials."
105
+ say " First provision durable storage, Queue and cron using the generated Worker docs/durable-inbound.md."
106
+ say " After the initial Wrangler deploy, Ruby uploads preserve those infrastructure bindings."
107
107
  say " Run `RAILS_ENV=production bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument} URL=https://yourapp.com#{ingress_path}`"
108
108
  say " after `bin/rails credentials:edit`."
109
109
  say ""
@@ -121,6 +121,7 @@ module Cloudflare
121
121
  @ingress_secret = SecureRandom.hex(32)
122
122
 
123
123
  inside options[:worker_dir] do
124
+ say "Create the R2 bucket and Queue in docs/durable-inbound.md before deploying. Customize wrangler.toml names if needed."
124
125
  run "npm ci", abort_on_failure: true
125
126
 
126
127
  ingress_url = ask("Rails ingress URL? (e.g. https://yourapp.com#{ingress_path})")
@@ -168,10 +169,10 @@ module Cloudflare
168
169
 
169
170
  if options[:inbound] && !@worker_deployed
170
171
  say " 4. Deploy the Worker (pick one):"
171
- say " # Pure Ruby (recommended — no wrangler/Node required):"
172
+ say " # First follow Worker docs/durable-inbound.md to provision R2, Queue and cron."
172
173
  say " RAILS_ENV=production bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument} URL=https://yourapp.com#{ingress_path}"
173
174
  say ""
174
- say " # Or via wrangler if you have it installed:"
175
+ say " # Initial infrastructure deploy (after provisioning resources):"
175
176
  say " cd #{options[:worker_dir]}"
176
177
  say " npm ci"
177
178
  say " npm run deploy -- --env production"
@@ -182,7 +183,7 @@ module Cloudflare
182
183
 
183
184
  if options[:inbound]
184
185
  say " 5. For local dev (tunnels cloudflared to your Worker):"
185
- say " RAILS_ENV=development bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument}"
186
+ say " INBOUND_DELIVERY_MODE=direct RAILS_ENV=development bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument}"
186
187
  say " RAILS_ENV=development bin/rails cloudflare:email:dev"
187
188
  say " Route a separate test address to cloudflare-email-ingress-development."
188
189
  say ""
@@ -0,0 +1,21 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ module Generators
7
+ class CatchAllGenerator < ::Rails::Generators::Base
8
+ include ::ActiveRecord::Generators::Migration
9
+ namespace "cloudflare:email:mailboxes:catch_all"
10
+ source_root File.expand_path("templates", __dir__)
11
+ class_option :tenant_migrations_path, type: :string, default: "db/migrate",
12
+ desc: "Migration directory applied to every tenant database"
13
+
14
+ def copy_migration
15
+ migration_template "add_cloudflare_email_catch_all.rb",
16
+ File.join(options[:tenant_migrations_path], "add_cloudflare_email_catch_all.rb")
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ class AddCloudflareEmailCatchAll < ActiveRecord::Migration[7.1]
2
+ def up
3
+ unless column_exists?(:cloudflare_email_addresses, :catch_all)
4
+ add_column :cloudflare_email_addresses, :catch_all, :boolean, null: false, default: false
5
+ end
6
+ unless column_exists?(:cloudflare_email_addresses, :catch_all_evidence)
7
+ add_column :cloudflare_email_addresses, :catch_all_evidence, :text
8
+ end
9
+ unless index_exists?(:cloudflare_email_addresses, :receiving_domain_id, name: "idx_cf_email_domain_catch_all")
10
+ add_index :cloudflare_email_addresses, :receiving_domain_id, unique: true,
11
+ where: "catch_all = TRUE AND state = 'active'", name: "idx_cf_email_domain_catch_all"
12
+ end
13
+ end
14
+
15
+ def down
16
+ raise ActiveRecord::IrreversibleMigration, "Disable catch-all receiving before an explicit forward schema change"
17
+ end
18
+ end
@@ -19,10 +19,14 @@ class CreateCloudflareEmailMailboxes < ActiveRecord::Migration[7.1]
19
19
  t.string :address, null: false
20
20
  t.string :state, null: false, default: "pending"
21
21
  t.text :provisioning_evidence
22
+ t.boolean :catch_all, null: false, default: false
23
+ t.text :catch_all_evidence
22
24
  t.timestamps
23
25
  end
24
26
  add_index :cloudflare_email_addresses, :address, unique: true, name: "idx_cf_email_mailbox_address"
25
27
  add_index :cloudflare_email_addresses, :mailbox_id, name: "idx_cf_email_address_mailbox"
28
+ add_index :cloudflare_email_addresses, :receiving_domain_id, unique: true,
29
+ where: "catch_all = TRUE AND state = 'active'", name: "idx_cf_email_domain_catch_all"
26
30
 
27
31
  create_table :cloudflare_email_mailbox_messages do |t|
28
32
  t.string :tenant_key, null: false
@@ -36,6 +40,7 @@ class CreateCloudflareEmailMailboxes < ActiveRecord::Migration[7.1]
36
40
  t.timestamps
37
41
  end
38
42
  add_index :cloudflare_email_mailbox_messages, [:mailbox_id, :inbound_email_id], unique: true, name: "idx_cf_email_mailbox_inbound"
43
+ add_index :cloudflare_email_mailbox_messages, :inbound_email_id, name: "idx_cf_email_message_inbound"
39
44
  add_index :cloudflare_email_mailbox_messages, [:tenant_key, :mailbox_id, :archived_at, :id], name: "idx_cf_email_mailbox_inbox"
40
45
 
41
46
  create_table :cloudflare_email_mailbox_outbound_messages do |t|
@@ -3,7 +3,7 @@ class CreateCloudflareEmailReceivingDomains < ActiveRecord::Migration[7.1]
3
3
  create_table :cloudflare_email_receiving_domains do |t|
4
4
  t.string :domain, null: false
5
5
  t.string :tenant_key, null: false
6
- t.string :account_id, null: false
6
+ t.string :account_id # Required by the sending adapter only.
7
7
  t.string :state, null: false, default: "pending"
8
8
  t.boolean :sending_enabled, null: false, default: false
9
9
  t.text :provisioning_evidence
@@ -0,0 +1,28 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ module Generators
7
+ class RoutingTrackingGenerator < ::Rails::Generators::Base
8
+ include ::ActiveRecord::Generators::Migration
9
+ namespace "cloudflare:email:routing_tracking"
10
+ source_root File.expand_path("templates", __dir__)
11
+ class_option :migrations_path, type: :string, default: "db/migrate"
12
+
13
+ def copy_tracking_migration
14
+ migration_template "create_cloudflare_email_routing_delivery_receipts.rb",
15
+ File.join(options[:migrations_path], "create_cloudflare_email_routing_delivery_receipts.rb")
16
+ end
17
+
18
+ def create_initializer
19
+ create_file "config/initializers/cloudflare_email_routing_tracking.rb", <<~RUBY
20
+ # Requires the outbox migration and Rails Active Record encryption keys.
21
+ # This loads the API only; your application schedules analytics polling.
22
+ require "cloudflare/email/active_record/routing_deliveries"
23
+ RUBY
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,22 @@
1
+ class CreateCloudflareEmailRoutingDeliveryReceipts < ActiveRecord::Migration[7.1]
2
+ def up
3
+ create_table :cloudflare_email_routing_delivery_receipts do |t|
4
+ t.references :outbound_delivery, null: false, index: {name: "idx_cf_routing_delivery"},
5
+ foreign_key: {to_table: :cloudflare_email_outbound_deliveries}
6
+ t.string :account_id, null: false
7
+ t.string :zone_id, null: false
8
+ t.string :event_key, null: false
9
+ t.text :payload_json, null: false
10
+ t.string :state, null: false, default: "pending"
11
+ t.datetime :received_at, null: false
12
+ t.datetime :applied_at
13
+ t.timestamps
14
+ end
15
+ add_index :cloudflare_email_routing_delivery_receipts, [:account_id, :event_key], unique: true, name: "idx_cf_routing_identity"
16
+ add_index :cloudflare_email_routing_delivery_receipts, [:account_id, :state, :id], name: "idx_cf_routing_replay"
17
+ end
18
+
19
+ def down
20
+ raise ActiveRecord::IrreversibleMigration, "Preserve authentic delivery evidence; use a forward fix or a reconciled backup"
21
+ end
22
+ end
@@ -15,7 +15,8 @@ namespace :cloudflare do
15
15
  desc "Deploy the Worker via Cloudflare API. URL=https://... sets RAILS_INGRESS_URL; SCRIPT=path overrides the Worker source"
16
16
  task deploy_worker: :environment do
17
17
  require "cloudflare/email/deploy_worker_task"
18
- exit Cloudflare::Email::DeployWorkerTask.call(ingress_url: ENV["URL"], script_path: ENV["SCRIPT"])
18
+ exit Cloudflare::Email::DeployWorkerTask.call(ingress_url: ENV["URL"], script_path: ENV["SCRIPT"],
19
+ delivery_mode: ENV.fetch("INBOUND_DELIVERY_MODE", "durable"))
19
20
  end
20
21
 
21
22
  desc "Create Cloudflare Email Routing rule: ADDRESS=addr@domain → env-scoped Worker (WORKER=name to override)"
@@ -0,0 +1,11 @@
1
+ namespace :cloudflare do
2
+ namespace :email do
3
+ desc "Inspect routing without changes (ADDRESS=addr [WORKER_NAME=name] [ACCOUNT_ID=id]); nonzero if failed or unverified"
4
+ task check_route: :environment do
5
+ require "cloudflare/email/check_route_task"
6
+ exit Cloudflare::Email::CheckRouteTask.call(
7
+ address: ENV["ADDRESS"], worker_name: ENV["WORKER_NAME"] || ENV["WORKER"], account_id: ENV["ACCOUNT_ID"],
8
+ )
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ # Supply these in the Cloudflare deployment form. Never commit real values.
2
+ RAILS_INGRESS_URL=
3
+ INGRESS_SECRET=
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ .wrangler/
3
+ .dev.vars*
4
+ !.dev.vars.example
5
+ .env*
6
+ !.env.example
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cole
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,104 @@
1
+ # Cloudflare Email for Rails
2
+
3
+ Receive email in Rails with the [cloudflare-email gem](https://github.com/cole-robertson/cloudflare-email).
4
+ This template stores inbound mail in private R2, delivers it through a Queue,
5
+ and retries from storage when Rails is unavailable. It includes scheduled
6
+ recovery and Worker logs. No React app or extra server is needed.
7
+
8
+ [![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)
9
+
10
+ ## 1. Prepare Rails
11
+
12
+ Install `gem "cloudflare-email", "~> 0.3.0"` and follow the
13
+ [Rails setup guide](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/getting-started.md).
14
+ Deploy Rails with Action Mailbox storage and routing configured before connecting
15
+ real email. Generate a secret with `openssl rand -hex 32` and configure it as
16
+ `cloudflare.ingress_secret` in Rails credentials or `CLOUDFLARE_INGRESS_SECRET`.
17
+ Keep the same value for the deployment form.
18
+
19
+ ## 2. Deploy the Worker
20
+
21
+ Click the button and sign in to Cloudflare and GitHub/GitLab as prompted.
22
+ Cloudflare copies this directory into your own repository, provisions the R2
23
+ bucket and Queue, and builds the Worker. Enter:
24
+
25
+ - **RAILS_INGRESS_URL:** your public HTTPS endpoint, normally
26
+ `https://your-app.com/rails/action_mailbox/cloudflare/inbound_emails`.
27
+ - **INGRESS_SECRET:** the exact secret you configured in Rails.
28
+
29
+ Choose distinct Worker, bucket, and Queue names for each app/environment. This
30
+ template has one environment per deployment; deploy separate copies for staging
31
+ and production. R2 and Queues must be available on your account and their usage
32
+ can incur charges. Review Cloudflare's resource and billing prompts.
33
+
34
+ The form uses `npm run deploy`; no `--env` argument is required for this template.
35
+ After deployment, confirm both bindings, the Queue consumer, and the once-per-minute
36
+ cron are present. Keep the bucket private with no lifecycle rule expiring
37
+ `cloudflare-email/pending/` objects. The Worker has no public email-submit HTTP API.
38
+
39
+ ## 3. Connect an email address
40
+
41
+ **Choose your address shape:** `acme@in.example.com` on one receiving domain,
42
+ or `invoices@acme.in.example.com` with dynamic organization subdomains.
43
+ Use the [shared domain setup guide](docs/domain-setup.md) for the setup worksheet,
44
+ Cloudflare DNS/routing steps, cited Rebulk example, and verification commands.
45
+ The same Worker serves both; there is no per-organization Worker allowlist.
46
+
47
+ In Cloudflare, enable Email Routing for the intended receiving domain/subdomain
48
+ and complete its required DNS setup. Add an Email Routing rule for your receiving
49
+ address with **Send to a Worker**, selecting the Worker you just deployed. For
50
+ many local parts, configure the intended domain's catch-all to this Worker once.
51
+ The button does not change your MX records, provision addresses in Rails, or
52
+ replace your existing mailbox provider. Use a receiving subdomain when your apex
53
+ domain's email belongs to Google Workspace or Microsoft 365.
54
+
55
+ If using the gem's managed mailboxes, register the receiving mailbox/address in
56
+ Rails too. Tenant support and domain catch-all receiving remain explicit choices.
57
+ After verifying a dynamic namespace as described in the guide, register new
58
+ organization domains in Rails without routinely adding provider rules per org.
59
+ Wildcard DNS behavior must be verified on your Cloudflare account; the guide
60
+ distinguishes working Rebulk evidence from Cloudflare's explicit onboarding docs.
61
+ Outbound Email Sending credentials and delivery-event subscriptions are separate
62
+ from this inbound Worker setup.
63
+
64
+ ## 4. Verify and operate
65
+
66
+ For dynamic organization subdomains, start with the read-only DNS check:
67
+
68
+ ```sh
69
+ npm run check:subdomains -- --base in.example.com --labels acme,globex
70
+ ```
71
+
72
+ It checks named and fresh labels without API credentials or configuration changes.
73
+ It reports DNS observations only; finish with the real-email checks below.
74
+
75
+ Send to the configured address and verify that Rails retains and routes it.
76
+ In staging, stop Rails, send another message, confirm it remains in R2, restore
77
+ Rails, and confirm one inbox record and removal of the pending object. Verify a
78
+ retry does not duplicate application work. Monitor Worker errors and pending R2
79
+ count/age; Queue depth alone misses mail whose queue retries are exhausted.
80
+
81
+ Read the [durable delivery and recovery guide](https://github.com/cole-robertson/cloudflare-email/blob/main/templates/worker/docs/durable-inbound.md)
82
+ for recovery limits, retention, failure drills, and the explicit direct fallback.
83
+ In this standalone template, configure bindings at the top level and run
84
+ `npm run deploy` without `--env`. Do not remove storage or cron while mail is pending.
85
+ Delivery is at least once, with Rails deduplication; application handlers must
86
+ also tolerate retries. Configure operational alerts in your monitoring service.
87
+
88
+ ## Updates and local development
89
+
90
+ Your copied repository belongs to you; gem updates do not update its Worker
91
+ automatically. Review upstream release notes, deploy Rails first, then port Worker
92
+ changes into your copy while preserving your resource names, secrets, and pending
93
+ storage. Keep production resources separate from test resources.
94
+
95
+ Use Node 22.12+ (or a supported newer version): `npm ci`, `npm test`, and
96
+ `npm run check`. For local development, copy `.dev.vars.example` to `.dev.vars`,
97
+ set local values, and run `npm run dev`. Never commit secrets.
98
+
99
+ Upstream maintainers: `templates/worker` is the canonical runtime. Run
100
+ `node script/sync_deploy_template.mjs` from the gem repository after modifying its
101
+ runtime, tests, dependencies, or production bindings. CI rejects drift and tests
102
+ this directory after copying it outside the repository, matching Cloudflare's
103
+ subdirectory deployment model. Edit this README directly; generated files are
104
+ marked by the sync script's file list.
@@ -0,0 +1,198 @@
1
+ # Set up once, create mailboxes in Rails
2
+
3
+ Use the same Worker for either address shape:
4
+
5
+ | Pattern | Example | What changes when a customer joins |
6
+ | --- | --- | --- |
7
+ | One receiving domain | `acme@in.example.com` | Create a mailbox/address in Rails |
8
+ | Organization subdomains | `invoices@acme.in.example.com` | Register the exact organization domain and its mailboxes in Rails, within your verified receiving namespace |
9
+
10
+ Neither pattern needs a recipient allowlist in the Worker. Rails owns accepted
11
+ addresses and organization membership. Database multi-tenancy remains optional.
12
+ The [Rails hello-world template](https://github.com/cole-robertson/cloudflare-email-rails-starter)
13
+ demonstrates the first pattern with a normal SQLite database and generated login.
14
+
15
+ ## What is verified, and what Cloudflare documents
16
+
17
+ **Rebulk's existing deployment uses the second pattern:**
18
+ `<site>@<organization>.rebulk.com`, wildcard MX, a catch-all Worker rule, and Rails
19
+ organization/mailbox lookup. New organizations do not need a Worker enrollment
20
+ list or a routine per-organization Cloudflare approval. Its September 2026
21
+ rehearsal checked exact-domain public MX answers and archived SMTP-envelope
22
+ delivery evidence, alongside the global catch-all rule. The apex keeps its
23
+ separate mail provider. Sources (Rebulk repository access required):
24
+
25
+ - [Worker architecture and organization setup](https://github.com/Rebulk/rebulk-system/blob/1cc26e0076ba0e9157e89f5879604df8c94430bc/cloudflare/README.md).
26
+ - [Wildcard MX observations and limits of zone-level API evidence](https://github.com/Rebulk/rebulk-system/blob/1cc26e0076ba0e9157e89f5879604df8c94430bc/docs/cloudflare-mailbox-onboarding-rehearsal-2026-09-12.md).
27
+
28
+ This is deployment evidence, not a Cloudflare-wide service guarantee. Cloudflare's
29
+ [subdomain onboarding guide](https://developers.cloudflare.com/email-service/configuration/subdomains/)
30
+ describes adding subdomains explicitly and currently lists a 30-domain combined
31
+ Routing/Sending limit. It does not document arbitrary wildcard Email Routing as
32
+ a way to bypass that limit. Cloudflare separately documents
33
+ [wildcard DNS and exact-record precedence](https://developers.cloudflare.com/dns/manage-dns-records/reference/wildcard-dns-records/).
34
+ Working wildcard DNS alone does not establish Email Routing acceptance. If your
35
+ account does not accept unlisted subdomains, use documented explicit onboarding
36
+ within its limits, or the single-domain pattern. Do not assume that an existing
37
+ Rebulk setup proves a fresh account's behavior.
38
+
39
+ A fresh [public DNS observation](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/verification/2026-09-13-wildcard-dns.json)
40
+ also records Cloudflare MX answers for `test.rebulk.com` and two random subdomains
41
+ without creating DNS records or adding those labels in Cloudflare. That report
42
+ is public and deliberately marks `delivery_verified: false`; no email was sent
43
+ by this DNS-only check.
44
+
45
+ ## 1. Fill in your setup worksheet
46
+
47
+ Use a dedicated receiving namespace when the apex already serves Workspace or
48
+ Microsoft 365. Example values:
49
+
50
+ | Setting | Your example value |
51
+ | --- | --- |
52
+ | Cloudflare zone | `example.com` |
53
+ | Receiving base | `in.example.com` |
54
+ | Organization domain | `acme.in.example.com` |
55
+ | Worker | `cloudflare-email-ingress` |
56
+ | Rails ingress URL | `https://app.example.com/rails/action_mailbox/cloudflare/inbound_emails` |
57
+ | Shared ingress secret | Generate with `openssl rand -hex 32`; save privately in Rails and the Worker |
58
+
59
+ Keep staging and production Workers, buckets, queues, secrets, and receiving
60
+ namespaces separate. The standalone deploy-button template takes one environment
61
+ per copy; the regular CLI template uses `--env production` or another explicit env.
62
+
63
+ ## 2. Deploy the shared infrastructure
64
+
65
+ [![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)
66
+
67
+ Deploy Rails with gem 0.3+ first. The button copies the standalone Worker and
68
+ prompts for `RAILS_INGRESS_URL` and `INGRESS_SECRET`. Its configuration includes
69
+ private R2 storage, a Queue producer/consumer, once-per-minute recovery, and logs.
70
+ Cloudflare documents [automatic R2/Queue provisioning and secret prompts](https://developers.cloudflare.com/workers/platform/deploy-buttons/).
71
+ The button does not create email DNS, claim a domain, or register Rails mailboxes.
72
+
73
+ ## 3. Connect DNS and the Worker once
74
+
75
+ For **one domain**, follow Cloudflare's documented
76
+ [Email Routing onboarding](https://developers.cloudflare.com/email-service/get-started/route-emails/)
77
+ and configure its [catch-all action](https://developers.cloudflare.com/email-service/configuration/email-routing-addresses/#catch-all-rule)
78
+ as **Send to a Worker**, selecting your deployed Worker. This avoids one provider
79
+ rule per local part. Review the actual zone/domain scope and any explicit rules
80
+ that take precedence before saving.
81
+
82
+ For a **Rebulk-style dynamic namespace**, first establish receiving on your account
83
+ and retain the exact MX targets/priorities assigned by Cloudflare. Then, when
84
+ reproducing and verifying wildcard receiving, the DNS template is:
85
+
86
+ | DNS type | Name in zone `example.com` | Value |
87
+ | --- | --- | --- |
88
+ | MX | `*.in` | Each Cloudflare-assigned receiving MX target, with its assigned priority |
89
+
90
+ Create one record per assigned target. Do not invent MX targets, point MX at a
91
+ Worker URL, or replace the apex's existing MX records. Complete the service's
92
+ other required DNS records through its onboarding instructions; do not blindly
93
+ duplicate SPF records. Inspect the account's catch-all **Send to a Worker** rule
94
+ and verify that it actually handles your intended namespace. A Cloudflare Worker
95
+ HTTP route such as `*.example.com/*` is unrelated to email routing.
96
+
97
+ An explicit DNS name can stop wildcard inheritance **even if its record is TXT
98
+ or A rather than MX**. For example, an existing `acme.in.example.com` record may
99
+ require its own receiving MX records. Test exact customer names as well as fresh
100
+ ones. A wildcard also does not supply MX for the receiving base itself.
101
+
102
+ ## 4. Check without modifying infrastructure
103
+
104
+ From either current Worker template, run:
105
+
106
+ ```sh
107
+ npm run check:subdomains -- --base in.example.com --labels acme,globex
108
+ ```
109
+
110
+ This requires only Node and DNS access, not a Cloudflare API key. It resolves MX
111
+ for your chosen labels plus two fresh random labels. DNS lookups have bounded
112
+ timeouts. JSON output distinguishes observed Cloudflare MX, other/missing MX, and
113
+ lookup uncertainty. Exit zero means only that all queried names resolved to
114
+ Cloudflare MX; `delivery_verified` remains false. Compare returned targets with
115
+ your account's assigned targets. Existing explicit records can explain differences.
116
+ The command ships in the repository templates; gem 0.3.0's already-published
117
+ template predates it, so refresh your template copy to use it.
118
+
119
+ For provider rule inspection, the gem also offers:
120
+
121
+ ```sh
122
+ bin/rails cloudflare:email:check_route \
123
+ ADDRESS=invoices@acme.in.example.com \
124
+ WORKER_NAME=cloudflare-email-ingress \
125
+ ACCOUNT_ID=your-cloudflare-account-id
126
+ ```
127
+
128
+ See [diagnostic credentials and limits](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/routing-diagnostics.md).
129
+ That diagnostic inspects exact-domain configured records; it does not resolve
130
+ wildcard DNS inheritance. A working inherited-MX setup can therefore report
131
+ missing exact records. Keep public DNS, provider rule inspection, and actual
132
+ delivery evidence separate; do not force every organization through Cloudflare
133
+ onboarding merely to make that diagnostic green.
134
+
135
+ ## 5. Prove the dynamic path before adopting it
136
+
137
+ Use two new organization labels that have not been added individually in the
138
+ Cloudflare dashboard:
139
+
140
+ 1. Check their public MX answers with the command above.
141
+ 2. Through trusted provisioning code, register both **exact** domains in Rails,
142
+ each against the correct stable organization key. Create a test mailbox for
143
+ each. Record your independently checked configuration evidence and activate
144
+ those test addresses so the gem can receive the verification messages.
145
+ 3. Send real email to both addresses from an external mailbox. Confirm the
146
+ Worker receives each, Rails retains the original message, and each appears
147
+ only in the correct organization's inbox. Record the message IDs and result.
148
+ 4. Repeat with a newly generated label without changing Cloudflare. If delivery
149
+ fails before the Worker, resolve the Cloudflare acceptance/onboarding issue;
150
+ changing Rails cannot fix a provider SMTP rejection.
151
+ 5. Rehearse Rails being unavailable, then verify retained R2 mail drains after
152
+ recovery without duplicate app records. Follow the
153
+ [durable recovery guide](https://github.com/cole-robertson/cloudflare-email/blob/main/templates/worker/docs/durable-inbound.md).
154
+
155
+ These are deliberate test addresses; no real-email test is sent by the DNS checker.
156
+ Successful tests establish evidence for your receiving namespace, not a guarantee
157
+ that every future DNS change or provider policy will preserve it.
158
+
159
+ ## 6. Create organizations in Rails
160
+
161
+ Once your namespace policy is verified, use the same gem APIs on organization
162
+ creation. For example, from trusted app code after authorizing the organization:
163
+
164
+ ```ruby
165
+ registry = Cloudflare::Email::Mailboxes
166
+ organization_key = "organization-123" # Trusted stable app identity, not request input.
167
+ domain = registry.register_domain(
168
+ domain: "acme.in.example.com",
169
+ tenant_key: organization_key,
170
+ account_id: Cloudflare::Email::Credentials.account_id
171
+ )
172
+ registry.activate_domain!(domain.id, evidence: verified_namespace_evidence)
173
+ registry.for_tenant(organization_key) do |inboxes|
174
+ mailbox = inboxes.create(name: "Invoices", address: "invoices@acme.in.example.com",
175
+ owner_ref: "Organization:123")
176
+ inboxes.activate_address!(inboxes.addresses(mailbox.id).first.id,
177
+ evidence: verified_namespace_evidence)
178
+ end
179
+ ```
180
+
181
+ `verified_namespace_evidence` is your stored operator evidence from the setup and
182
+ delivery checks, not a hard-coded claim of success. Make provisioning idempotent
183
+ in your app, validate/reserve slugs, and resolve organization identity from trusted
184
+ records. The gem stores exact domain registrations; it does not accept a `*`
185
+ domain registration or silently authorize an arbitrary recipient from a web form.
186
+ The management engine must obtain allowed domains from your host adapter. The
187
+ hello-world starter intentionally stays single-domain; this recipe is the next
188
+ step for an organization-aware app.
189
+
190
+ A **Cloudflare routing catch-all** gets mail to the Worker. The gem's optional
191
+ **mailbox catch-all** decides whether unknown local parts enter a mailbox. They
192
+ are separate. Without the latter, unknown/suspended recipients return non-2xx and
193
+ remain in durable pending storage until resolved. Monitor that backlog.
194
+
195
+ Receiving on dynamic domains does not authorize sending from them. Cloudflare
196
+ [Email Sending onboarding](https://developers.cloudflare.com/email-service/configuration/subdomains/#add-a-subdomain-to-email-sending)
197
+ is separate; keep a verified shared sending domain unless you have explicitly
198
+ onboarded the customer sending domain.