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
@@ -2,8 +2,11 @@ require "cloudflare/email/version"
2
2
  require "cloudflare/email/error"
3
3
  require "cloudflare/email/response"
4
4
  require "cloudflare/email/signing"
5
+ require "cloudflare/email/envelope"
5
6
  require "cloudflare/email/credentials"
6
7
  require "cloudflare/email/client"
7
- require "cloudflare/email/secure_message_id"
8
+ require "cloudflare/email/message_id"
9
+ require "cloudflare/email/delivery_event"
10
+ require "cloudflare/email/event_consumer"
8
11
 
9
12
  require "cloudflare/email/engine" if defined?(::Rails::Engine)
@@ -1,3 +1,4 @@
1
+ require "rails/generators"
1
2
  require "rails/generators/base"
2
3
  require "securerandom"
3
4
 
@@ -16,7 +17,7 @@ module Cloudflare
16
17
  desc: "Directory to copy the Cloudflare Worker template into"
17
18
 
18
19
  class_option :all_envs, type: :boolean, default: false,
19
- desc: "Also configure action_mailbox.ingress in development.rb and test.rb (not just production.rb)"
20
+ desc: "Also configure action_mailbox.ingress in test.rb (development and production are configured by default)"
20
21
 
21
22
  class_option :deploy_worker, type: :boolean, default: nil,
22
23
  desc: "Deploy the Worker via wrangler after setup (nil = interactive prompt)"
@@ -74,8 +75,8 @@ module Cloudflare
74
75
  def configure_action_mailbox_ingress
75
76
  return unless options[:inbound]
76
77
 
77
- envs = ["production"]
78
- envs += ["development", "test"] if options[:all_envs]
78
+ envs = ["development", "production"]
79
+ envs << "test" if options[:all_envs]
79
80
 
80
81
  envs.each do |env|
81
82
  file = "config/environments/#{env}.rb"
@@ -92,7 +93,7 @@ module Cloudflare
92
93
  return unless options[:inbound]
93
94
 
94
95
  worker_src = File.expand_path("../../../../templates/worker", __dir__)
95
- directory worker_src, options[:worker_dir]
96
+ directory worker_src, options[:worker_dir], exclude_pattern: %r{/(node_modules|\.wrangler|\.dev\.vars(?:\.[^/]*)?|\.env(?:\.[^/]*)?)(/|$)}
96
97
  end
97
98
 
98
99
  def maybe_deploy_worker
@@ -101,37 +102,45 @@ module Cloudflare
101
102
  should_deploy = options[:deploy_worker]
102
103
  if should_deploy.nil?
103
104
  say ""
104
- say " The Worker can be deployed via the Cloudflare API (pure Ruby, no wrangler/Node)"
105
- say " once you've set cloudflare.account_id and cloudflare.api_token in Rails credentials."
106
- say " Run `bin/rails cloudflare:email:deploy_worker URL=https://yourapp.com#{ingress_path}`"
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
+ say " Run `RAILS_ENV=production bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument} URL=https://yourapp.com#{ingress_path}`"
107
108
  say " after `bin/rails credentials:edit`."
108
109
  say ""
109
110
  say " Alternatively, deploy now via wrangler if it's installed locally." if wrangler_installed?
110
111
  end
111
112
 
112
- if should_deploy && wrangler_installed?
113
+ if should_deploy
114
+ raise Thor::Error, "Install wrangler or use the cloudflare:email:deploy_worker Rails task" unless wrangler_installed?
113
115
  wrangler_deploy
114
116
  end
115
117
  end
116
118
 
117
- def wrangler_deploy
118
- @ingress_secret = SecureRandom.hex(32)
119
+ no_tasks do
120
+ def wrangler_deploy
121
+ @ingress_secret = SecureRandom.hex(32)
119
122
 
120
- inside options[:worker_dir] do
121
- run "npm install --legacy-peer-deps", abort_on_failure: true
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."
125
+ run "npm ci", abort_on_failure: true
122
126
 
123
- ingress_url = ask("Rails ingress URL? (e.g. https://yourapp.com#{ingress_path})")
124
- if ingress_url.to_s.strip.empty?
125
- say " Skipping Worker deploy — no URL supplied. Re-run `wrangler deploy` manually when ready.", :yellow
126
- return
127
+ ingress_url = ask("Rails ingress URL? (e.g. https://yourapp.com#{ingress_path})")
128
+ if ingress_url.to_s.strip.empty?
129
+ say " Skipping Worker deploy — no URL supplied. Re-run `npm run deploy -- --env production` manually when ready.", :yellow
130
+ return
131
+ end
132
+
133
+ require "cloudflare/email/endpoint"
134
+ Cloudflare::Email::Endpoint.parse(ingress_url.strip)
135
+
136
+ run "npm run deploy -- --env production", abort_on_failure: true
137
+ run_with_stdin("npx --no-install wrangler secret put RAILS_INGRESS_URL --env production", ingress_url.strip)
138
+ run_with_stdin("npx --no-install wrangler secret put INGRESS_SECRET --env production", @ingress_secret)
127
139
  end
128
140
 
129
- run_with_stdin("wrangler secret put RAILS_INGRESS_URL", ingress_url.strip)
130
- run_with_stdin("wrangler secret put INGRESS_SECRET", @ingress_secret)
131
- run "wrangler deploy", abort_on_failure: false
141
+ @worker_deployed = true
132
142
  end
133
-
134
- @worker_deployed = true
143
+ private :wrangler_deploy
135
144
  end
136
145
 
137
146
  def print_post_install
@@ -155,41 +164,43 @@ module Cloudflare
155
164
  say " bin/rails cloudflare:email:doctor"
156
165
  say ""
157
166
  say " 3. Send a test email:"
158
- say " TO=you@example.com bin/rails cloudflare:email:send_test"
167
+ say " FROM=hello@your-verified-domain.com TO=you@example.com bin/rails cloudflare:email:send_test"
159
168
  say ""
160
169
 
161
170
  if options[:inbound] && !@worker_deployed
162
171
  say " 4. Deploy the Worker (pick one):"
163
- say " # Pure Ruby (recommended — no wrangler/Node required):"
164
- say " bin/rails cloudflare:email:deploy_worker URL=https://yourapp.com#{ingress_path}"
172
+ say " # First follow Worker docs/durable-inbound.md to provision R2, Queue and cron."
173
+ say " RAILS_ENV=production bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument} URL=https://yourapp.com#{ingress_path}"
165
174
  say ""
166
- say " # Or via wrangler if you have it installed:"
175
+ say " # Initial infrastructure deploy (after provisioning resources):"
167
176
  say " cd #{options[:worker_dir]}"
168
- say " npm install --legacy-peer-deps"
169
- say " wrangler secret put INGRESS_SECRET # paste #{@ingress_secret[0, 8]}..."
170
- say " wrangler secret put RAILS_INGRESS_URL # https://yourapp.com#{ingress_path}"
171
- say " wrangler deploy"
177
+ say " npm ci"
178
+ say " npm run deploy -- --env production"
179
+ say " npx --no-install wrangler secret put INGRESS_SECRET --env production # paste #{@ingress_secret[0, 8]}..."
180
+ say " npx --no-install wrangler secret put RAILS_INGRESS_URL --env production # https://yourapp.com#{ingress_path}"
172
181
  say ""
173
182
  end
174
183
 
175
184
  if options[:inbound]
176
185
  say " 5. For local dev (tunnels cloudflared to your Worker):"
177
- say " bin/rails cloudflare:email:dev"
186
+ say " INBOUND_DELIVERY_MODE=direct RAILS_ENV=development bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument}"
187
+ say " RAILS_ENV=development bin/rails cloudflare:email:dev"
188
+ say " Route a separate test address to cloudflare-email-ingress-development."
178
189
  say ""
179
190
  say " 6. In the Cloudflare dashboard:"
180
191
  say " Email Routing -> Routes -> Send to a Worker -> #{worker_name}"
181
192
  say ""
182
193
  say " Dashboard deep-links:"
183
194
  say " API tokens: https://dash.cloudflare.com/profile/api-tokens"
184
- say " Sending domains: https://dash.cloudflare.com/?to=/:account/email/sending"
185
- say " Email routing: https://dash.cloudflare.com/?to=/:account/email/routing"
195
+ say " Sending domains: https://dash.cloudflare.com/?to=/:account/email-service/sending"
196
+ say " Email routing: https://dash.cloudflare.com/?to=/:account/email-service/routing"
186
197
  say ""
187
198
  say " Rotation: to rotate the ingress secret, update cloudflare.ingress_secret"
188
- say " in Rails credentials AND re-run `wrangler secret put INGRESS_SECRET` in"
199
+ say " in Rails credentials AND re-run `wrangler secret put INGRESS_SECRET --env production` in"
189
200
  say " #{options[:worker_dir]}/ with the new value, then redeploy the Worker."
190
201
  say ""
191
- say " Dev/test: by default only production.rb is wired to :cloudflare ingress."
192
- say " Re-run with --all-envs to also configure development.rb and test.rb."
202
+ say " Development and production are wired to :cloudflare ingress."
203
+ say " Re-run with --all-envs to also configure test.rb."
193
204
  end
194
205
  say ""
195
206
  end
@@ -211,14 +222,19 @@ module Cloudflare
211
222
  end
212
223
 
213
224
  def worker_name
214
- "cloudflare-email-ingress"
225
+ "cloudflare-email-ingress-production"
226
+ end
227
+
228
+ def worker_script_argument
229
+ require "shellwords"
230
+ Shellwords.escape(File.join(options[:worker_dir], "src/index.js"))
215
231
  end
216
232
 
217
233
  def run_with_stdin(cmd, input)
218
234
  require "open3"
219
235
  out, err, status = Open3.capture3(cmd, stdin_data: input + "\n")
220
236
  unless status.success?
221
- say " #{cmd} failed: #{err.empty? ? out : err}", :red
237
+ raise Thor::Error, "#{cmd} failed: #{err.empty? ? out : err}"
222
238
  end
223
239
  end
224
240
  end
@@ -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
@@ -0,0 +1,55 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+ require "generators/cloudflare/email/outbox/outbox_generator"
4
+ require "generators/cloudflare/email/tracking/tracking_generator"
5
+
6
+ module Cloudflare
7
+ module Email
8
+ module Generators
9
+ class MailboxesGenerator < ::Rails::Generators::Base
10
+ include ::ActiveRecord::Generators::Migration
11
+ namespace "cloudflare:email:mailboxes"
12
+ source_root File.expand_path("templates", __dir__)
13
+ class_option :tenant_migrations_path, type: :string, default: "db/migrate",
14
+ desc: "Migration directory applied to every tenant database"
15
+ class_option :directory_migrations_path, type: :string, default: "db/migrate",
16
+ desc: "Migration directory applied only to the shared directory database"
17
+
18
+ def copy_migrations
19
+ invoke "cloudflare:email:outbox", [], migrations_path: options[:tenant_migrations_path]
20
+ invoke "cloudflare:email:tracking", [], migrations_path: options[:tenant_migrations_path]
21
+ migration_template "create_cloudflare_email_receiving_domains.rb",
22
+ File.join(options[:directory_migrations_path], "create_cloudflare_email_receiving_domains.rb")
23
+ migration_template "create_cloudflare_email_shared_events.rb",
24
+ File.join(options[:directory_migrations_path], "create_cloudflare_email_shared_events.rb")
25
+ migration_template "create_cloudflare_email_mailboxes.rb",
26
+ File.join(options[:tenant_migrations_path], "create_cloudflare_email_mailboxes.rb")
27
+ end
28
+
29
+ def create_initializer
30
+ create_file "config/initializers/00_cloudflare_email_tenancy.rb", <<~RUBY
31
+ # Single database: no configuration is necessary.
32
+ # Separate tenant databases: configure BEFORE the optional models load.
33
+ # Replace these application-specific adapter names with your own:
34
+ # require "cloudflare/email/tenancy"
35
+ # Cloudflare::Email::Tenancy.configure(
36
+ # base_class: TenantRecord,
37
+ # switch: ->(key, &block) { TenantRecord.with_tenant(key, &block) },
38
+ # current: -> { TenantRecord.current_tenant }
39
+ # )
40
+ # require "cloudflare/email/mailboxes/configuration"
41
+ # Cloudflare::Email::Mailboxes.configure(directory_base: SharedRecord)
42
+ # ActionMailbox and ActiveStorage must use this same tenant connection.
43
+ # See docs/mailboxes.md for the complete application setup.
44
+ RUBY
45
+ create_file "config/initializers/cloudflare_email_mailboxes.rb", <<~RUBY
46
+ # Optional mailbox persistence. Run the generated migrations first.
47
+ # For database tenancy, configure Tenancy and the shared directory
48
+ # base before requiring cloudflare/email/mailboxes. See docs/mailboxes.md.
49
+ require "cloudflare/email/mailboxes"
50
+ RUBY
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,58 @@
1
+ class CreateCloudflareEmailMailboxes < ActiveRecord::Migration[7.1]
2
+ def up
3
+ create_table :cloudflare_email_mailboxes do |t|
4
+ t.string :tenant_key, null: false
5
+ t.string :name, null: false
6
+ t.string :owner_ref
7
+ t.string :state, null: false, default: "active"
8
+ t.timestamps
9
+ end
10
+ add_index :cloudflare_email_mailboxes, [:tenant_key, :owner_ref], name: "idx_cf_email_mailbox_owner"
11
+
12
+ create_table :cloudflare_email_addresses do |t|
13
+ t.string :tenant_key, null: false
14
+ t.references :mailbox, null: false, index: false, foreign_key: { to_table: :cloudflare_email_mailboxes }
15
+ # The domain directory can live in another database: no cross-database FK.
16
+ t.bigint :receiving_domain_id, null: false
17
+ t.string :local_part, null: false
18
+ t.string :domain, null: false
19
+ t.string :address, null: false
20
+ t.string :state, null: false, default: "pending"
21
+ t.text :provisioning_evidence
22
+ t.boolean :catch_all, null: false, default: false
23
+ t.text :catch_all_evidence
24
+ t.timestamps
25
+ end
26
+ add_index :cloudflare_email_addresses, :address, unique: true, name: "idx_cf_email_mailbox_address"
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"
30
+
31
+ create_table :cloudflare_email_mailbox_messages do |t|
32
+ t.string :tenant_key, null: false
33
+ t.references :mailbox, null: false, index: false, foreign_key: { to_table: :cloudflare_email_mailboxes }
34
+ # No database FK to the optional Rails table; the ingress service requires
35
+ # the same connection and manages membership+raw source atomically.
36
+ t.bigint :inbound_email_id, null: false
37
+ t.string :recipient, null: false
38
+ t.datetime :read_at
39
+ t.datetime :archived_at
40
+ t.timestamps
41
+ end
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, [:tenant_key, :mailbox_id, :archived_at, :id], name: "idx_cf_email_mailbox_inbox"
44
+
45
+ create_table :cloudflare_email_mailbox_outbound_messages do |t|
46
+ t.string :tenant_key, null: false
47
+ t.references :mailbox, null: false, index: false, foreign_key: { to_table: :cloudflare_email_mailboxes }
48
+ t.references :outbound_delivery, null: false, index: false, foreign_key: { to_table: :cloudflare_email_outbound_deliveries }
49
+ t.timestamps
50
+ end
51
+ add_index :cloudflare_email_mailbox_outbound_messages, :outbound_delivery_id, unique: true, name: "idx_cf_email_mailbox_outbound"
52
+ add_index :cloudflare_email_mailbox_outbound_messages, :mailbox_id, name: "idx_cf_email_outbound_mailbox"
53
+ end
54
+
55
+ def down
56
+ raise ActiveRecord::IrreversibleMigration, "Preserve mailbox ownership and retained messages; use a forward fix"
57
+ end
58
+ end
@@ -0,0 +1,16 @@
1
+ class CreateCloudflareEmailReceivingDomains < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :cloudflare_email_receiving_domains do |t|
4
+ t.string :domain, null: false
5
+ t.string :tenant_key, null: false
6
+ t.string :account_id, null: false
7
+ t.string :state, null: false, default: "pending"
8
+ t.boolean :sending_enabled, null: false, default: false
9
+ t.text :provisioning_evidence
10
+ t.datetime :verified_at
11
+ t.timestamps
12
+ end
13
+ add_index :cloudflare_email_receiving_domains, :domain, unique: true, name: "idx_cf_email_directory_domain"
14
+ add_index :cloudflare_email_receiving_domains, [:tenant_key, :state], name: "idx_cf_email_directory_tenant"
15
+ end
16
+ end
@@ -0,0 +1,35 @@
1
+ class CreateCloudflareEmailSharedEvents < ActiveRecord::Migration[7.2]
2
+ def up
3
+ create_table :cloudflare_email_shared_event_receipts do |t|
4
+ t.string :account_id, null: false
5
+ t.string :event_id, null: false
6
+ t.string :message_id, null: false
7
+ t.string :recipient, null: false
8
+ t.text :payload_json, null: false
9
+ t.string :state, null: false, default: "pending"
10
+ t.datetime :applied_at
11
+ t.timestamps
12
+ end
13
+ add_index :cloudflare_email_shared_event_receipts, [:account_id, :event_id], unique: true,
14
+ name: "idx_cf_shared_events_identity"
15
+ add_index :cloudflare_email_shared_event_receipts, [:state, :id], name: "idx_cf_shared_events_replay"
16
+
17
+ create_table :cloudflare_email_provider_correlations do |t|
18
+ t.string :account_id, null: false
19
+ t.string :message_id, null: false
20
+ t.string :recipient, null: false
21
+ t.string :tenant_key, null: false
22
+ t.bigint :outbound_delivery_id, null: false
23
+ t.timestamps
24
+ end
25
+ add_index :cloudflare_email_provider_correlations,
26
+ [:account_id, :message_id, :recipient, :tenant_key, :outbound_delivery_id], unique: true,
27
+ name: "idx_cf_provider_correlations_identity"
28
+ add_index :cloudflare_email_provider_correlations, [:account_id, :message_id, :recipient],
29
+ name: "idx_cf_provider_correlations_lookup"
30
+ end
31
+
32
+ def down
33
+ raise ActiveRecord::IrreversibleMigration, "Preserve tenant routing and event deduplication evidence; use a forward fix"
34
+ end
35
+ end
@@ -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 OutboxGenerator < ::Rails::Generators::Base
8
+ include ::ActiveRecord::Generators::Migration
9
+ namespace "cloudflare:email:outbox"
10
+ source_root File.expand_path("templates", __dir__)
11
+ class_option :migrations_path, type: :string, default: "db/migrate"
12
+
13
+ def copy_outbox_migration
14
+ migration_template "create_cloudflare_email_outbox.rb", File.join(options[:migrations_path], "create_cloudflare_email_outbox.rb")
15
+ end
16
+
17
+ def create_initializer
18
+ create_file "config/initializers/cloudflare_email_outbox.rb", <<~RUBY
19
+ # Opt in after running db:migrate. Never perform network delivery inside a database transaction.
20
+ require "cloudflare/email/active_record"
21
+ require "cloudflare/email/send_job"
22
+ require "cloudflare/email/replay_events_job"
23
+ RUBY
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,48 @@
1
+ class CreateCloudflareEmailOutbox < ActiveRecord::Migration[7.1]
2
+ def up
3
+ create_table :cloudflare_email_outbound_deliveries do |t|
4
+ t.string :account_id, null: false
5
+ t.string :operation_key, null: false
6
+ t.string :from_address, null: false
7
+ t.text :recipients_json, null: false
8
+ t.binary :mime_message, null: false
9
+ t.string :snapshot_digest, null: false
10
+ t.string :state, null: false, default: "prepared"
11
+ t.string :provider_message_id
12
+ t.text :response_json
13
+ t.string :error_class
14
+ t.datetime :request_started_at
15
+ t.datetime :completed_at
16
+ t.timestamps
17
+ end
18
+ add_index :cloudflare_email_outbound_deliveries, [:account_id, :operation_key], unique: true, name: "idx_cf_email_outbound_operation"
19
+ add_index :cloudflare_email_outbound_deliveries, [:account_id, :provider_message_id], name: "idx_cf_email_outbound_provider"
20
+ add_index :cloudflare_email_outbound_deliveries, [:state, :id], name: "idx_cf_email_outbound_pending"
21
+
22
+ create_table :cloudflare_email_outbound_recipients do |t|
23
+ t.references :outbound_delivery, null: false, index: false, foreign_key: { to_table: :cloudflare_email_outbound_deliveries }
24
+ t.string :recipient, null: false
25
+ t.string :state, null: false, default: "prepared"
26
+ t.string :acceptance_state, null: false, default: "prepared"
27
+ t.datetime :occurred_at
28
+ t.boolean :terminal, null: false, default: false
29
+ t.timestamps
30
+ end
31
+ add_index :cloudflare_email_outbound_recipients, [:outbound_delivery_id, :recipient], unique: true, name: "idx_cf_email_outbound_recipient"
32
+
33
+ create_table :cloudflare_email_outbound_reconciliations do |t|
34
+ t.references :outbound_delivery, null: false, index: { name: "idx_cf_email_reconciliation_delivery" }, foreign_key: { to_table: :cloudflare_email_outbound_deliveries }
35
+ t.string :actor, null: false
36
+ t.text :reason, null: false
37
+ t.text :evidence, null: false
38
+ t.string :outcome, null: false
39
+ t.string :provider_message_id
40
+ t.text :recipients_json
41
+ t.timestamps
42
+ end
43
+ end
44
+
45
+ def down
46
+ raise ActiveRecord::IrreversibleMigration, "Preserve delivery evidence; use a forward fix or a reconciled backup"
47
+ end
48
+ end
@@ -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
@@ -8,13 +8,13 @@
8
8
  # Inbound: ActionMailbox ingress is mounted at
9
9
  # /rails/action_mailbox/cloudflare/inbound_emails
10
10
  # Set `config.action_mailbox.ingress = :cloudflare` (the install generator
11
- # does this for production by default) and configure cloudflare.ingress_secret
11
+ # does this for development and production by default) and configure cloudflare.ingress_secret
12
12
  # in your Rails credentials.
13
13
 
14
14
  Rails.application.configure do
15
15
  config.action_mailer.delivery_method = :cloudflare
16
16
  config.action_mailer.cloudflare_settings = {
17
- account_id: Rails.application.credentials.dig(:cloudflare, :account_id),
18
- api_token: Rails.application.credentials.dig(:cloudflare, :api_token),
17
+ account_id: Cloudflare::Email::Credentials.account_id,
18
+ api_token: Cloudflare::Email::Credentials.api_token,
19
19
  }
20
20
  end
@@ -6,13 +6,8 @@
6
6
  # ActionMailbox API (mail is a Mail::Message, inbound_email is the AR record).
7
7
  class MainMailbox < ApplicationMailbox
8
8
  def process
9
- Rails.logger.info(
10
- "[cloudflare-email] inbound received: " \
11
- "from=#{mail.from&.first.inspect} " \
12
- "to=#{Array(mail.to).inspect} " \
13
- "subject=#{mail.subject.inspect} " \
14
- "message_id=#{mail.message_id.inspect}"
15
- )
9
+ # Keep message content and personal addresses out of application logs.
10
+ Rails.logger.info("[cloudflare-email] inbound received: inbound_email_id=#{inbound_email.id}")
16
11
 
17
12
  # Example ways to pull content out of the incoming message:
18
13
  #
@@ -0,0 +1,22 @@
1
+ class CreateCloudflareEmailEventReceipts < ActiveRecord::Migration[7.1]
2
+ def up
3
+ create_table :cloudflare_email_event_receipts do |t|
4
+ t.string :account_id, null: false
5
+ t.string :event_id, null: false
6
+ t.string :message_id, null: false
7
+ t.text :payload_json, null: false
8
+ t.string :state, null: false, default: "pending"
9
+ t.datetime :applied_at
10
+ t.timestamps
11
+ end
12
+ add_index :cloudflare_email_event_receipts, [:account_id, :event_id], unique: true,
13
+ name: "idx_cf_email_receipts_account_event"
14
+ add_index :cloudflare_email_event_receipts, [:account_id, :message_id],
15
+ name: "idx_cf_email_receipts_account_message"
16
+ add_index :cloudflare_email_event_receipts, [:state, :id], name: "idx_cf_email_receipts_replay"
17
+ end
18
+
19
+ def down
20
+ raise ActiveRecord::IrreversibleMigration, "Preserve event deduplication evidence; use a forward fix or a reconciled backup"
21
+ end
22
+ end
@@ -0,0 +1,26 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ module Generators
7
+ class TrackingGenerator < ::Rails::Generators::Base
8
+ include ::ActiveRecord::Generators::Migration
9
+ namespace "cloudflare:email: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_event_receipts.rb", File.join(options[:migrations_path], "create_cloudflare_email_event_receipts.rb")
15
+ end
16
+
17
+ def create_initializer
18
+ create_file "config/initializers/cloudflare_email_tracking.rb", <<~RUBY
19
+ # Opt in to durable delivery-event receipts after running db:migrate.
20
+ require "cloudflare/email/active_record/event_inbox"
21
+ RUBY
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end