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