cloudflare-email 0.1.0 → 0.2.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -1
  3. data/README.md +265 -388
  4. data/SECURITY.md +51 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +62 -6
  6. data/docs/activerecord-tenanted.md +135 -0
  7. data/docs/architecture.md +95 -0
  8. data/docs/delivery-events.md +177 -0
  9. data/docs/features.md +107 -0
  10. data/docs/getting-started.md +298 -0
  11. data/docs/mailboxes.md +299 -0
  12. data/docs/outbox.md +175 -0
  13. data/docs/tenant-mailboxes-plan.md +149 -0
  14. data/docs/thread-correlation.md +27 -0
  15. data/docs/troubleshooting.md +103 -0
  16. data/docs/upgrading-0.2.md +96 -0
  17. data/docs/verification/2026-09-10-followup.md +49 -0
  18. data/docs/verification/2026-09-10-install-upgrade.md +86 -0
  19. data/docs/verification/2026-09-10-live.md +102 -0
  20. data/docs/verification/2026-09-10.md +175 -0
  21. data/docs/verification/2026-09-11-extraction.md +32 -0
  22. data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
  23. data/docs/verification/2026-09-11-security.md +76 -0
  24. data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
  25. data/examples/plain_ruby.rb +24 -0
  26. data/lib/cloudflare/email/active_record/base.rb +48 -0
  27. data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
  28. data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
  29. data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
  30. data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
  31. data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
  32. data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
  33. data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
  34. data/lib/cloudflare/email/active_record/outbox.rb +219 -0
  35. data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
  36. data/lib/cloudflare/email/active_record.rb +5 -0
  37. data/lib/cloudflare/email/client.rb +51 -11
  38. data/lib/cloudflare/email/consume_events_task.rb +22 -0
  39. data/lib/cloudflare/email/delivery_event.rb +70 -0
  40. data/lib/cloudflare/email/delivery_method.rb +3 -0
  41. data/lib/cloudflare/email/deploy_worker_task.rb +3 -0
  42. data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
  43. data/lib/cloudflare/email/dev_tunnel.rb +31 -5
  44. data/lib/cloudflare/email/doctor.rb +16 -38
  45. data/lib/cloudflare/email/endpoint.rb +24 -0
  46. data/lib/cloudflare/email/engine.rb +28 -0
  47. data/lib/cloudflare/email/envelope.rb +58 -0
  48. data/lib/cloudflare/email/event_consumer.rb +86 -0
  49. data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
  50. data/lib/cloudflare/email/mailboxes/events.rb +112 -0
  51. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
  52. data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
  53. data/lib/cloudflare/email/mailboxes/models.rb +148 -0
  54. data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
  55. data/lib/cloudflare/email/mailboxes/service.rb +288 -0
  56. data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
  57. data/lib/cloudflare/email/mailboxes.rb +11 -0
  58. data/lib/cloudflare/email/message_id.rb +12 -0
  59. data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
  60. data/lib/cloudflare/email/provision_route_task.rb +2 -1
  61. data/lib/cloudflare/email/replay_events_job.rb +18 -0
  62. data/lib/cloudflare/email/response.rb +21 -3
  63. data/lib/cloudflare/email/routing_provisioner.rb +71 -45
  64. data/lib/cloudflare/email/send_job.rb +41 -0
  65. data/lib/cloudflare/email/send_test.rb +5 -29
  66. data/lib/cloudflare/email/signing.rb +2 -2
  67. data/lib/cloudflare/email/tenancy.rb +78 -0
  68. data/lib/cloudflare/email/tenant_job_context.rb +90 -0
  69. data/lib/cloudflare/email/verification.rb +30 -8
  70. data/lib/cloudflare/email/version.rb +1 -1
  71. data/lib/cloudflare/email/worker_deployer.rb +5 -3
  72. data/lib/cloudflare-email.rb +4 -1
  73. data/lib/generators/cloudflare/email/install_generator.rb +48 -33
  74. data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
  75. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +54 -0
  76. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
  77. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
  78. data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
  79. data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
  80. data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
  81. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
  82. data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
  83. data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
  84. data/lib/tasks/cloudflare_email.rake +41 -4
  85. data/templates/worker/README.md +61 -11
  86. data/templates/worker/package-lock.json +2627 -0
  87. data/templates/worker/package.json +8 -5
  88. data/templates/worker/scripts/wrangler.mjs +16 -0
  89. data/templates/worker/src/index.js +83 -4
  90. data/templates/worker/test/index.test.ts +117 -5
  91. data/templates/worker/wrangler.toml +13 -4
  92. metadata +107 -6
  93. data/lib/cloudflare/email/secure_message_id.rb +0 -89
@@ -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
@@ -103,35 +104,42 @@ module Cloudflare
103
104
  say ""
104
105
  say " The Worker can be deployed via the Cloudflare API (pure Ruby, no wrangler/Node)"
105
106
  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}`"
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
+ run "npm ci", abort_on_failure: true
122
125
 
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
126
+ ingress_url = ask("Rails ingress URL? (e.g. https://yourapp.com#{ingress_path})")
127
+ if ingress_url.to_s.strip.empty?
128
+ say " Skipping Worker deploy — no URL supplied. Re-run `npm run deploy -- --env production` manually when ready.", :yellow
129
+ return
130
+ end
131
+
132
+ require "cloudflare/email/endpoint"
133
+ Cloudflare::Email::Endpoint.parse(ingress_url.strip)
134
+
135
+ run "npm run deploy -- --env production", abort_on_failure: true
136
+ run_with_stdin("npx --no-install wrangler secret put RAILS_INGRESS_URL --env production", ingress_url.strip)
137
+ run_with_stdin("npx --no-install wrangler secret put INGRESS_SECRET --env production", @ingress_secret)
127
138
  end
128
139
 
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
140
+ @worker_deployed = true
132
141
  end
133
-
134
- @worker_deployed = true
142
+ private :wrangler_deploy
135
143
  end
136
144
 
137
145
  def print_post_install
@@ -155,41 +163,43 @@ module Cloudflare
155
163
  say " bin/rails cloudflare:email:doctor"
156
164
  say ""
157
165
  say " 3. Send a test email:"
158
- say " TO=you@example.com bin/rails cloudflare:email:send_test"
166
+ say " FROM=hello@your-verified-domain.com TO=you@example.com bin/rails cloudflare:email:send_test"
159
167
  say ""
160
168
 
161
169
  if options[:inbound] && !@worker_deployed
162
170
  say " 4. Deploy the Worker (pick one):"
163
171
  say " # Pure Ruby (recommended — no wrangler/Node required):"
164
- say " bin/rails cloudflare:email:deploy_worker URL=https://yourapp.com#{ingress_path}"
172
+ say " RAILS_ENV=production bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument} URL=https://yourapp.com#{ingress_path}"
165
173
  say ""
166
174
  say " # Or via wrangler if you have it installed:"
167
175
  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"
176
+ say " npm ci"
177
+ say " npm run deploy -- --env production"
178
+ say " npx --no-install wrangler secret put INGRESS_SECRET --env production # paste #{@ingress_secret[0, 8]}..."
179
+ say " npx --no-install wrangler secret put RAILS_INGRESS_URL --env production # https://yourapp.com#{ingress_path}"
172
180
  say ""
173
181
  end
174
182
 
175
183
  if options[:inbound]
176
184
  say " 5. For local dev (tunnels cloudflared to your Worker):"
177
- say " bin/rails cloudflare:email:dev"
185
+ say " RAILS_ENV=development bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument}"
186
+ say " RAILS_ENV=development bin/rails cloudflare:email:dev"
187
+ say " Route a separate test address to cloudflare-email-ingress-development."
178
188
  say ""
179
189
  say " 6. In the Cloudflare dashboard:"
180
190
  say " Email Routing -> Routes -> Send to a Worker -> #{worker_name}"
181
191
  say ""
182
192
  say " Dashboard deep-links:"
183
193
  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"
194
+ say " Sending domains: https://dash.cloudflare.com/?to=/:account/email-service/sending"
195
+ say " Email routing: https://dash.cloudflare.com/?to=/:account/email-service/routing"
186
196
  say ""
187
197
  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"
198
+ say " in Rails credentials AND re-run `wrangler secret put INGRESS_SECRET --env production` in"
189
199
  say " #{options[:worker_dir]}/ with the new value, then redeploy the Worker."
190
200
  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."
201
+ say " Development and production are wired to :cloudflare ingress."
202
+ say " Re-run with --all-envs to also configure test.rb."
193
203
  end
194
204
  say ""
195
205
  end
@@ -211,14 +221,19 @@ module Cloudflare
211
221
  end
212
222
 
213
223
  def worker_name
214
- "cloudflare-email-ingress"
224
+ "cloudflare-email-ingress-production"
225
+ end
226
+
227
+ def worker_script_argument
228
+ require "shellwords"
229
+ Shellwords.escape(File.join(options[:worker_dir], "src/index.js"))
215
230
  end
216
231
 
217
232
  def run_with_stdin(cmd, input)
218
233
  require "open3"
219
234
  out, err, status = Open3.capture3(cmd, stdin_data: input + "\n")
220
235
  unless status.success?
221
- say " #{cmd} failed: #{err.empty? ? out : err}", :red
236
+ raise Thor::Error, "#{cmd} failed: #{err.empty? ? out : err}"
222
237
  end
223
238
  end
224
239
  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,54 @@
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.timestamps
23
+ end
24
+ add_index :cloudflare_email_addresses, :address, unique: true, name: "idx_cf_email_mailbox_address"
25
+ add_index :cloudflare_email_addresses, :mailbox_id, name: "idx_cf_email_address_mailbox"
26
+
27
+ create_table :cloudflare_email_mailbox_messages do |t|
28
+ t.string :tenant_key, null: false
29
+ t.references :mailbox, null: false, index: false, foreign_key: { to_table: :cloudflare_email_mailboxes }
30
+ # No database FK to the optional Rails table; the ingress service requires
31
+ # the same connection and manages membership+raw source atomically.
32
+ t.bigint :inbound_email_id, null: false
33
+ t.string :recipient, null: false
34
+ t.datetime :read_at
35
+ t.datetime :archived_at
36
+ t.timestamps
37
+ end
38
+ add_index :cloudflare_email_mailbox_messages, [:mailbox_id, :inbound_email_id], unique: true, name: "idx_cf_email_mailbox_inbound"
39
+ add_index :cloudflare_email_mailbox_messages, [:tenant_key, :mailbox_id, :archived_at, :id], name: "idx_cf_email_mailbox_inbox"
40
+
41
+ create_table :cloudflare_email_mailbox_outbound_messages do |t|
42
+ t.string :tenant_key, null: false
43
+ t.references :mailbox, null: false, index: false, foreign_key: { to_table: :cloudflare_email_mailboxes }
44
+ t.references :outbound_delivery, null: false, index: false, foreign_key: { to_table: :cloudflare_email_outbound_deliveries }
45
+ t.timestamps
46
+ end
47
+ add_index :cloudflare_email_mailbox_outbound_messages, :outbound_delivery_id, unique: true, name: "idx_cf_email_mailbox_outbound"
48
+ add_index :cloudflare_email_mailbox_outbound_messages, :mailbox_id, name: "idx_cf_email_outbound_mailbox"
49
+ end
50
+
51
+ def down
52
+ raise ActiveRecord::IrreversibleMigration, "Preserve mailbox ownership and retained messages; use a forward fix"
53
+ end
54
+ 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
@@ -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
@@ -6,16 +6,16 @@ namespace :cloudflare do
6
6
  exit Cloudflare::Email::Doctor.call
7
7
  end
8
8
 
9
- desc "Send a test email via the current Cloudflare Email config (TO=addr FROM=addr)"
9
+ desc "Send a real test email (both FROM=addr and TO=addr are required)"
10
10
  task send_test: :environment do
11
11
  require "cloudflare/email/send_test"
12
12
  exit Cloudflare::Email::SendTest.call(to: ENV["TO"], from: ENV["FROM"])
13
13
  end
14
14
 
15
- desc "Deploy the Worker via Cloudflare API (no wrangler/Node required). URL=https://... sets RAILS_INGRESS_URL"
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"])
18
+ exit Cloudflare::Email::DeployWorkerTask.call(ingress_url: ENV["URL"], script_path: ENV["SCRIPT"])
19
19
  end
20
20
 
21
21
  desc "Create Cloudflare Email Routing rule: ADDRESS=addr@domain → env-scoped Worker (WORKER=name to override)"
@@ -27,7 +27,7 @@ namespace :cloudflare do
27
27
  )
28
28
  end
29
29
 
30
- desc "Point the zone's catch-all rule at the env-scoped Worker (DOMAIN=in.example.com [WORKER=name])"
30
+ desc "Point a zone-wide catch-all at the env-scoped Worker (DOMAIN=example.com [WORKER=name])"
31
31
  task provision_catchall: :environment do
32
32
  require "cloudflare/email/provision_catchall_task"
33
33
  exit Cloudflare::Email::ProvisionCatchallTask.call(
@@ -41,5 +41,42 @@ namespace :cloudflare do
41
41
  require "cloudflare/email/dev_tunnel"
42
42
  Cloudflare::Email::DevTunnel.call(port: ENV.fetch("PORT", "3000").to_i)
43
43
  end
44
+
45
+ desc "Process one batch of outbound delivery events using config.x.cloudflare_email.event_handler"
46
+ task consume_events: :environment do
47
+ require "cloudflare/email/consume_events_task"
48
+ settings = Rails.application.config.x.cloudflare_email
49
+ exit Cloudflare::Email::ConsumeEventsTask.call(
50
+ handler: settings.event_handler,
51
+ domains: settings.event_domains,
52
+ batch_size: Integer(ENV.fetch("BATCH_SIZE", "5"), 10),
53
+ )
54
+ end
55
+
56
+ desc "Send a prepared durable operation using OPERATION_KEY (no new message is generated)"
57
+ task deliver: :environment do
58
+ require "cloudflare/email/send_job"
59
+ delivery = Cloudflare::Email::SendJob.perform_now(
60
+ Cloudflare::Email::Credentials.account_id, ENV.fetch("OPERATION_KEY"))
61
+ puts "operation=#{delivery.operation_key} state=#{delivery.state}"
62
+ end
63
+
64
+ desc "Replay durable outbound receipts for the configured account (optional MESSAGE_ID)"
65
+ task replay_events: :environment do
66
+ require "cloudflare/email/replay_events_job"
67
+ count = Cloudflare::Email::ReplayEventsJob.perform_now(
68
+ Cloudflare::Email::Credentials.account_id, ENV["MESSAGE_ID"])
69
+ puts "Replayed #{count} receipt(s)."
70
+ end
71
+
72
+ desc "List prepared or uncertain outbound operations requiring dispatch or operator review"
73
+ task pending_deliveries: :environment do
74
+ require "cloudflare/email/active_record"
75
+ Cloudflare::Email::ActiveRecord::OutboundDelivery.where(
76
+ account_id: Cloudflare::Email::Credentials.account_id,
77
+ state: %w[prepared sending unknown partial]).find_each do |delivery|
78
+ puts "operation=#{delivery.operation_key} state=#{delivery.state} updated_at=#{delivery.updated_at.iso8601}"
79
+ end
80
+ end
44
81
  end
45
82
  end