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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -1
- data/README.md +265 -388
- data/SECURITY.md +51 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +62 -6
- data/docs/activerecord-tenanted.md +135 -0
- data/docs/architecture.md +95 -0
- data/docs/delivery-events.md +177 -0
- data/docs/features.md +107 -0
- data/docs/getting-started.md +298 -0
- data/docs/mailboxes.md +299 -0
- data/docs/outbox.md +175 -0
- data/docs/tenant-mailboxes-plan.md +149 -0
- data/docs/thread-correlation.md +27 -0
- data/docs/troubleshooting.md +103 -0
- data/docs/upgrading-0.2.md +96 -0
- data/docs/verification/2026-09-10-followup.md +49 -0
- data/docs/verification/2026-09-10-install-upgrade.md +86 -0
- data/docs/verification/2026-09-10-live.md +102 -0
- data/docs/verification/2026-09-10.md +175 -0
- data/docs/verification/2026-09-11-extraction.md +32 -0
- data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
- data/docs/verification/2026-09-11-security.md +76 -0
- data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
- data/examples/plain_ruby.rb +24 -0
- data/lib/cloudflare/email/active_record/base.rb +48 -0
- data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
- data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
- data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
- data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
- data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
- data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
- data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
- data/lib/cloudflare/email/active_record/outbox.rb +219 -0
- data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
- data/lib/cloudflare/email/active_record.rb +5 -0
- data/lib/cloudflare/email/client.rb +51 -11
- data/lib/cloudflare/email/consume_events_task.rb +22 -0
- data/lib/cloudflare/email/delivery_event.rb +70 -0
- data/lib/cloudflare/email/delivery_method.rb +3 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +3 -0
- data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
- data/lib/cloudflare/email/dev_tunnel.rb +31 -5
- data/lib/cloudflare/email/doctor.rb +16 -38
- data/lib/cloudflare/email/endpoint.rb +24 -0
- data/lib/cloudflare/email/engine.rb +28 -0
- data/lib/cloudflare/email/envelope.rb +58 -0
- data/lib/cloudflare/email/event_consumer.rb +86 -0
- data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
- data/lib/cloudflare/email/mailboxes/events.rb +112 -0
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
- data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
- data/lib/cloudflare/email/mailboxes/models.rb +148 -0
- data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
- data/lib/cloudflare/email/mailboxes/service.rb +288 -0
- data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
- data/lib/cloudflare/email/mailboxes.rb +11 -0
- data/lib/cloudflare/email/message_id.rb +12 -0
- data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
- data/lib/cloudflare/email/provision_route_task.rb +2 -1
- data/lib/cloudflare/email/replay_events_job.rb +18 -0
- data/lib/cloudflare/email/response.rb +21 -3
- data/lib/cloudflare/email/routing_provisioner.rb +71 -45
- data/lib/cloudflare/email/send_job.rb +41 -0
- data/lib/cloudflare/email/send_test.rb +5 -29
- data/lib/cloudflare/email/signing.rb +2 -2
- data/lib/cloudflare/email/tenancy.rb +78 -0
- data/lib/cloudflare/email/tenant_job_context.rb +90 -0
- data/lib/cloudflare/email/verification.rb +30 -8
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +5 -3
- data/lib/cloudflare-email.rb +4 -1
- data/lib/generators/cloudflare/email/install_generator.rb +48 -33
- data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +54 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
- data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
- data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
- data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
- data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
- data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
- data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
- data/lib/tasks/cloudflare_email.rake +41 -4
- data/templates/worker/README.md +61 -11
- data/templates/worker/package-lock.json +2627 -0
- data/templates/worker/package.json +8 -5
- data/templates/worker/scripts/wrangler.mjs +16 -0
- data/templates/worker/src/index.js +83 -4
- data/templates/worker/test/index.test.ts +117 -5
- data/templates/worker/wrangler.toml +13 -4
- metadata +107 -6
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
119
|
+
no_tasks do
|
|
120
|
+
def wrangler_deploy
|
|
121
|
+
@ingress_secret = SecureRandom.hex(32)
|
|
119
122
|
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
inside options[:worker_dir] do
|
|
124
|
+
run "npm ci", abort_on_failure: true
|
|
122
125
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
169
|
-
say "
|
|
170
|
-
say " wrangler secret put
|
|
171
|
-
say " wrangler
|
|
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:
|
|
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 "
|
|
192
|
-
say " Re-run with --all-envs to also configure
|
|
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
|
-
|
|
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
|
data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb
ADDED
|
@@ -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:
|
|
18
|
-
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
|
-
|
|
10
|
-
|
|
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
|
#
|
data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb
ADDED
|
@@ -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
|
|
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
|
|
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
|
|
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
|