cloudflare-email 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/README.md +34 -9
  4. data/app/controllers/cloudflare/email/ingress_controller.rb +16 -60
  5. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
  6. data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
  7. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
  8. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
  9. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
  10. data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
  11. data/docs/activerecord-tenanted.md +46 -0
  12. data/docs/architecture.md +13 -3
  13. data/docs/custom-ingress.md +207 -0
  14. data/docs/delivery-events.md +9 -0
  15. data/docs/features.md +36 -5
  16. data/docs/getting-started.md +18 -8
  17. data/docs/mailboxes.md +67 -3
  18. data/docs/management-engine.md +190 -0
  19. data/docs/routing-deliveries.md +94 -0
  20. data/docs/routing-diagnostics.md +68 -0
  21. data/docs/troubleshooting.md +4 -3
  22. data/docs/upgrading-0.2.md +5 -0
  23. data/docs/upgrading-0.3.md +55 -0
  24. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  25. data/docs/verification/2026-09-12-destination-context.md +46 -0
  26. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  27. data/lib/cloudflare/email/active_record/base.rb +13 -1
  28. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  29. data/lib/cloudflare/email/check_route_task.rb +21 -0
  30. data/lib/cloudflare/email/client.rb +45 -10
  31. data/lib/cloudflare/email/delivery_method.rb +2 -0
  32. data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
  33. data/lib/cloudflare/email/engine.rb +5 -0
  34. data/lib/cloudflare/email/envelope.rb +2 -2
  35. data/lib/cloudflare/email/event_consumer.rb +12 -3
  36. data/lib/cloudflare/email/ingress.rb +115 -0
  37. data/lib/cloudflare/email/mailboxes/models.rb +19 -2
  38. data/lib/cloudflare/email/mailboxes/service.rb +99 -15
  39. data/lib/cloudflare/email/management/adapter.rb +31 -0
  40. data/lib/cloudflare/email/management/configuration.rb +17 -0
  41. data/lib/cloudflare/email/management/engine.rb +15 -0
  42. data/lib/cloudflare/email/management/management.css +68 -0
  43. data/lib/cloudflare/email/management/routes.rb +14 -0
  44. data/lib/cloudflare/email/management.rb +7 -0
  45. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  46. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  47. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  48. data/lib/cloudflare/email/verification.rb +17 -9
  49. data/lib/cloudflare/email/version.rb +1 -1
  50. data/lib/cloudflare/email/worker_deployer.rb +36 -5
  51. data/lib/generators/cloudflare/email/install_generator.rb +6 -5
  52. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  53. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  54. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +4 -0
  55. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  56. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  57. data/lib/tasks/cloudflare_email.rake +2 -1
  58. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  59. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  60. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  61. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  62. data/templates/deploy-to-cloudflare/README.md +84 -0
  63. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  64. data/templates/deploy-to-cloudflare/package.json +36 -0
  65. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  66. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  67. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  68. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  69. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  70. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  71. data/templates/worker/README.md +180 -15
  72. data/templates/worker/docs/durable-inbound.md +149 -0
  73. data/templates/worker/package-lock.json +2 -2
  74. data/templates/worker/package.json +1 -1
  75. data/templates/worker/src/index.js +331 -38
  76. data/templates/worker/test/durable.test.ts +193 -0
  77. data/templates/worker/test/index.test.ts +78 -20
  78. data/templates/worker/test/relay.test.ts +226 -0
  79. data/templates/worker/wrangler.toml +50 -0
  80. metadata +49 -3
@@ -0,0 +1,206 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+ require "timeout"
5
+ require "cloudflare/email/endpoint"
6
+
7
+ module Cloudflare
8
+ module Email
9
+ # A bounded, GET-only snapshot of provider configuration. This deliberately
10
+ # does not inherit RoutingProvisioner: inspecting must never repair routing.
11
+ class RoutingDiagnostics
12
+ API_BASE = "https://api.cloudflare.com/client/v4".freeze
13
+ MAX_BYTES = 1_048_576
14
+ MAX_PAGES = 50
15
+ LIMITATIONS = "Configuration snapshot only: does not prove public DNS propagation, live delivery, Worker destination, or application acceptance.".freeze
16
+ class ReadError < StandardError; end
17
+
18
+ def initialize(api_token:, api_base: API_BASE)
19
+ raise ArgumentError, "api_token is required" if api_token.to_s.empty?
20
+ @api_token = api_token
21
+ @api_base = Endpoint.parse(api_base).to_s.delete_suffix("/")
22
+ end
23
+
24
+ # Status is pass only if every requested configuration check passes.
25
+ # Unknown means inspection could not establish readiness, not success.
26
+ def check(address:, worker_name:, account_id: nil)
27
+ unless address.is_a?(String) && address.bytesize <= 254 && address.match?(/\A[^\s@]+@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9-]+\z/)
28
+ raise ArgumentError, "address must be a full email address with a DNS domain"
29
+ end
30
+ raise ArgumentError, "worker_name is required" if worker_name.to_s.empty?
31
+ domain = address.split("@", 2).last.downcase
32
+ address = "#{address.split('@', 2).first}@#{domain}"
33
+ checks = []
34
+ zone = inspect_check(checks, "zone") { find_zone(domain) }
35
+ if zone
36
+ if account_id
37
+ actual = zone["account"].is_a?(Hash) ? zone["account"]["id"] : nil
38
+ add(checks, "account", actual.nil? ? "unknown" : (actual == account_id ? "pass" : "fail"),
39
+ actual.nil? ? "Zone account was not returned." : (actual == account_id ? "Zone belongs to the expected account." : "Zone belongs to a different account."))
40
+ end
41
+ zone_id = URI.encode_www_form_component(zone.fetch("id"))
42
+ inspect_check(checks, "routing") { routing_check(checks, zone_id) }
43
+ inspect_check(checks, "dns") { dns_check(checks, zone_id, domain) }
44
+ inspect_check(checks, "route") { route_check(checks, zone_id, address, worker_name) }
45
+ else
46
+ %w[routing dns route].each { |name| add(checks, name, "unknown", "Cannot inspect without a unique zone.") }
47
+ end
48
+ statuses = checks.map { |item| item[:status] }
49
+ { status: statuses.include?("fail") ? "fail" : (statuses.include?("unknown") ? "unknown" : "pass"),
50
+ checks: checks, limitations: LIMITATIONS }
51
+ end
52
+
53
+ private
54
+
55
+ def add(checks, name, status, message)
56
+ checks << { name: name, status: status, message: message }
57
+ end
58
+
59
+ def inspect_check(checks, name)
60
+ value = yield
61
+ add(checks, name, "pass", "Found a unique containing Cloudflare zone.") if name == "zone" && value
62
+ add(checks, name, "fail", "No containing Cloudflare zone was found.") if name == "zone" && !value
63
+ value
64
+ rescue ReadError => e
65
+ add(checks, name, "unknown", e.message)
66
+ nil
67
+ rescue StandardError
68
+ add(checks, name, "unknown", "Provider configuration could not be interpreted safely.")
69
+ nil
70
+ end
71
+
72
+ def find_zone(domain)
73
+ parts = domain.split(".")
74
+ (0..parts.size - 2).each do |index|
75
+ candidate = parts[index..].join(".")
76
+ zones = pages("/zones?name=#{URI.encode_www_form_component(candidate)}")
77
+ next if zones.empty?
78
+ unless zones.size == 1 && zones.first["name"].to_s.downcase == candidate && zones.first["id"].is_a?(String)
79
+ raise ReadError, "Zone lookup was ambiguous or incomplete."
80
+ end
81
+ return zones.first
82
+ end
83
+ nil
84
+ end
85
+
86
+ def routing_check(checks, zone_id)
87
+ result = get("/zones/#{zone_id}/email/routing")["result"]
88
+ enabled = result.is_a?(Hash) ? result["enabled"] : nil
89
+ status = enabled == true ? "pass" : (enabled == false ? "fail" : "unknown")
90
+ add(checks, "routing", status, enabled == true ? "Zone Email Routing is enabled." : "Zone Email Routing is disabled or its enabled flag is unavailable.")
91
+ end
92
+
93
+ def dns_check(checks, zone_id, domain)
94
+ records = pages("/zones/#{zone_id}/dns_records?name=#{URI.encode_www_form_component(domain)}")
95
+ # Defensively enforce exact name even when the provider returns parent
96
+ # records. A parent's Google MX must not invalidate a receiving subdomain.
97
+ records = records.select { |record| record["name"].to_s.downcase.delete_suffix(".") == domain }
98
+ mx = records.select { |r| r["type"] == "MX" }.map { |r| r["content"].to_s.downcase.delete_suffix(".") }.uniq.sort
99
+ spf = records.select { |r| r["type"] == "TXT" }.map { |r| r["content"].to_s.delete('"') }.select { |v| v.match?(/\Av=spf1(?:\s|\z)/i) }
100
+ mx_ok = mx == (1..3).map { |n| "route#{n}.mx.cloudflare.net" }
101
+ # Do not declare an include useful when an earlier `all` terminates SPF.
102
+ terms = spf.size == 1 ? spf.first.split.drop(1) : []
103
+ include_index = terms.index("include:_spf.mx.cloudflare.net")
104
+ all_index = terms.index { |term| term.match?(/\A[+~?-]?all\z/i) }
105
+ spf_ok = include_index && (!all_index || include_index < all_index)
106
+ add(checks, "dns", mx_ok && spf_ok ? "pass" : "fail", mx_ok && spf_ok ?
107
+ "Exact receiving domain has Cloudflare MX records and one SPF record including Cloudflare." :
108
+ "Exact receiving domain needs Cloudflare MX records and a single SPF record with an effective Cloudflare include; records may be missing or conflicting.")
109
+ end
110
+
111
+ def route_check(checks, zone_id, address, worker_name)
112
+ rules = pages("/zones/#{zone_id}/email/routing/rules")
113
+ matching = []
114
+ rules.each do |rule|
115
+ next if rule["enabled"] == false
116
+ matchers = rule["matchers"]
117
+ unless matchers.is_a?(Array) && matchers.size == 1 && matchers.first.is_a?(Hash)
118
+ raise ReadError, "Unsupported rule matchers prevent establishing which rule wins."
119
+ end
120
+ matcher = matchers.first
121
+ unless matcher["type"] == "literal" && matcher["field"] == "to" && matcher["value"].is_a?(String)
122
+ raise ReadError, "Unsupported rule matchers prevent establishing which rule wins."
123
+ end
124
+ next unless matcher["value"].casecmp?(address)
125
+ raise ReadError, "A matching rule has an unknown enabled state." unless rule["enabled"] == true
126
+ matching << rule
127
+ end
128
+ if matching.size > 1
129
+ raise ReadError, "Multiple explicit rules match; their priority or tie ordering has not been verified."
130
+ end
131
+ if matching.one?
132
+ selected = matching.first
133
+ source = "Explicit address rule (takes precedence over catch-all)"
134
+ else
135
+ selected = get("/zones/#{zone_id}/email/routing/rules/catch_all")["result"]
136
+ unless selected.is_a?(Hash) && selected["matchers"] == [{ "type" => "all" }]
137
+ raise ReadError, "Catch-all settings are incomplete or use unsupported matchers."
138
+ end
139
+ source = "Catch-all rule"
140
+ end
141
+ unless [true, false].include?(selected["enabled"])
142
+ raise ReadError, "Selected rule has an unknown enabled state."
143
+ end
144
+ unless selected["enabled"]
145
+ return add(checks, "route", "fail", "No enabled explicit route or catch-all accepts this address.")
146
+ end
147
+ actions = selected["actions"]
148
+ unless actions.is_a?(Array) && actions.all? { |action| action.is_a?(Hash) }
149
+ raise ReadError, "Selected rule actions were not returned."
150
+ end
151
+ correct = actions.size == 1 && actions.first["type"] == "worker" && actions.first["value"] == [worker_name]
152
+ add(checks, "route", correct ? "pass" : "fail", correct ?
153
+ "#{source} targets the expected Worker." : "#{source} does not exclusively target the expected Worker (it may drop, forward, or target another Worker).")
154
+ end
155
+
156
+ def pages(path)
157
+ records = []
158
+ 1.upto(MAX_PAGES) do |page|
159
+ data = get("#{path}#{path.include?('?') ? '&' : '?'}per_page=50&page=#{page}")
160
+ batch = data["result"]
161
+ raise ReadError, "Provider returned an invalid list." unless batch.is_a?(Array) && batch.all? { |item| item.is_a?(Hash) }
162
+ records.concat(batch)
163
+ total = data.dig("result_info", "total_pages")
164
+ if total && (!total.is_a?(Integer) || total.negative? || (total.zero? && batch.any?))
165
+ raise ReadError, "Provider returned invalid pagination."
166
+ end
167
+ return records if total ? page >= total : batch.size < 50
168
+ raise ReadError, "Provider pagination ended before all pages were available." if batch.empty?
169
+ end
170
+ raise ReadError, "Provider pagination exceeds the inspection limit."
171
+ end
172
+
173
+ def get(path)
174
+ uri = URI.parse("#{@api_base}#{path}")
175
+ http = Net::HTTP.new(uri.host, uri.port)
176
+ http.use_ssl = uri.scheme == "https"
177
+ http.open_timeout = 5
178
+ http.read_timeout = 10
179
+ request = Net::HTTP::Get.new(uri.request_uri)
180
+ request["Authorization"] = "Bearer #{@api_token}"
181
+ request["Accept"] = "application/json"
182
+ body = +""
183
+ # read_timeout alone does not bound a peer streaming tiny chunks.
184
+ Timeout.timeout(20) do
185
+ http.request(request) do |response|
186
+ raise ReadError, "Provider request failed (HTTP #{response.code.to_i}); check token scopes and configuration." unless response.code.to_i.between?(200, 299)
187
+ response.read_body do |chunk|
188
+ raise ReadError, "Provider response exceeds the inspection size limit." if body.bytesize + chunk.bytesize > MAX_BYTES
189
+ body << chunk
190
+ end
191
+ end
192
+ end
193
+ data = JSON.parse(body)
194
+ unless data.is_a?(Hash) && data["success"] == true && data.key?("result")
195
+ raise ReadError, "Provider response did not contain a successful result."
196
+ end
197
+ data
198
+ rescue ReadError
199
+ raise
200
+ rescue StandardError
201
+ # Never surface raw provider bodies, URLs, credentials, or socket errors.
202
+ raise ReadError, "Provider request could not be read; check connectivity, token scopes, and response format."
203
+ end
204
+ end
205
+ end
206
+ end
@@ -1,5 +1,6 @@
1
1
  require "cloudflare/email/signing"
2
2
  require "cloudflare/email/envelope"
3
+ require "cloudflare/email/provider_metadata"
3
4
 
4
5
  module Cloudflare
5
6
  module Email
@@ -13,20 +14,22 @@ module Cloudflare
13
14
  # X-CF-Email-Signature: <hex digest>
14
15
  # X-CF-Email-Signature-Version: 2
15
16
  # X-CF-Email-Envelope: <unpadded base64url JSON from/to>
16
- # The version and authenticated SMTP envelope are required.
17
+ # Custom integrations may use v3, adding X-CF-Email-Metadata and signing
18
+ # "v3.{timestamp}.{encoded_envelope}.{encoded_metadata}.{raw_body}".
19
+ # v2 rejects metadata headers; v3 requires them. Both require the envelope.
17
20
  module Verification
18
21
  DEFAULT_WINDOW = 5 * 60 # seconds
19
22
 
20
23
  # Returns :ok, :bad_signature, or :stale.
21
24
  # Returns :bad_signature for any malformed input.
22
- def self.verify(secret:, body:, timestamp:, signature:, version: nil, envelope: nil, window: DEFAULT_WINDOW, now: Time.now.to_i)
25
+ def self.verify(secret:, body:, timestamp:, signature:, version: nil, envelope: nil, metadata: nil, window: DEFAULT_WINDOW, now: Time.now.to_i)
23
26
  return :bad_signature if blank?(body)
24
27
  timestamp = timestamp.to_s if timestamp.is_a?(Integer)
25
28
  preflight = verify_headers(secret: secret, timestamp: timestamp, signature: signature,
26
- version: version, envelope: envelope, window: window, now: now)
29
+ version: version, envelope: envelope, metadata: metadata, window: window, now: now)
27
30
  return preflight unless preflight == :ok
28
31
 
29
- expected = sign(secret: secret, body: body, timestamp: Integer(timestamp, 10), version: version, envelope: envelope)
32
+ expected = sign(secret: secret, body: body, timestamp: timestamp, version: version, envelope: envelope, metadata: metadata)
30
33
  return :bad_signature unless Signing.secure_compare(expected, signature)
31
34
 
32
35
  :ok
@@ -34,11 +37,12 @@ module Cloudflare
34
37
 
35
38
  # Reject malformed/stale requests before the controller reads MIME bytes.
36
39
  # This is only a preflight: authentication still requires verify(body: ...).
37
- def self.verify_headers(secret:, timestamp:, signature:, version:, envelope:, window: DEFAULT_WINDOW, now: Time.now.to_i)
40
+ def self.verify_headers(secret:, timestamp:, signature:, version:, envelope:, metadata: nil, window: DEFAULT_WINDOW, now: Time.now.to_i)
38
41
  return :bad_signature if blank?(secret)
39
42
  return :bad_signature unless timestamp.is_a?(String) && /\A[0-9]{1,20}\z/.match?(timestamp)
40
43
  return :bad_signature unless signature.is_a?(String) && /\A[0-9a-f]{64}\z/.match?(signature)
41
- return :bad_signature unless version == "2" && Envelope.decode(envelope)
44
+ return :bad_signature unless Envelope.decode(envelope)
45
+ return :bad_signature unless valid_metadata_version?(version, metadata)
42
46
 
43
47
  ts = begin
44
48
  Integer(timestamp.to_s, 10)
@@ -51,13 +55,17 @@ module Cloudflare
51
55
  :ok
52
56
  end
53
57
 
54
- def self.sign(secret:, body:, timestamp:, envelope:, version: "2")
55
- raise ArgumentError, "unsupported signature version" unless version == "2"
58
+ def self.sign(secret:, body:, timestamp:, envelope:, version: "2", metadata: nil)
59
+ raise ArgumentError, "invalid signature version or metadata" unless valid_metadata_version?(version, metadata)
56
60
  raise ArgumentError, "invalid SMTP envelope" unless Envelope.decode(envelope)
57
- prefix = "v2.#{timestamp}.#{envelope}."
61
+ prefix = version == "3" ? "v3.#{timestamp}.#{envelope}.#{metadata}." : "v2.#{timestamp}.#{envelope}."
58
62
  Signing.hmac_hex(secret, prefix.b + body.b)
59
63
  end
60
64
 
65
+ def self.valid_metadata_version?(version, metadata)
66
+ (version == "2" && metadata.nil?) || (version == "3" && ProviderMetadata.decode(metadata))
67
+ end
68
+
61
69
  def self.blank?(v)
62
70
  v.nil? || v.to_s.empty?
63
71
  end
@@ -1,5 +1,5 @@
1
1
  module Cloudflare
2
2
  module Email
3
- VERSION = "0.2.0"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
@@ -55,12 +55,20 @@ module Cloudflare
55
55
 
56
56
  # Uploads/updates the Worker script. Accepts either `script_path:` (a
57
57
  # path to a .js file) or `source:` (the JS source string directly).
58
- def deploy(script_path: nil, source: nil)
58
+ # The Rails deploy task passes delivery_mode: "durable" and validates
59
+ # existing infrastructure. Low-level callers managing custom Workers may
60
+ # omit it to preserve their configured transport without changing it.
61
+ def deploy(script_path: nil, source: nil, delivery_mode: nil)
59
62
  source ||= File.read(script_path) if script_path
60
63
  raise ArgumentError, "must pass script_path: or source:" if source.nil?
61
64
 
65
+ if delivery_mode
66
+ raise ArgumentError, "delivery_mode must be durable or direct" unless %w[durable direct].include?(delivery_mode)
67
+ verify_durable_infrastructure! if delivery_mode == "durable"
68
+ end
69
+
62
70
  boundary = "----cf-email-#{SecureRandom.hex(16)}"
63
- body = build_multipart(boundary, source)
71
+ body = build_multipart(boundary, source, delivery_mode)
64
72
 
65
73
  request(
66
74
  method: :put,
@@ -108,11 +116,34 @@ module Cloudflare
108
116
 
109
117
  private
110
118
 
111
- def build_multipart(boundary, source)
112
- metadata = JSON.generate({
119
+ def verify_durable_infrastructure!
120
+ settings = request(method: :get, path: "/accounts/#{@account_id}/workers/scripts/#{@script_name}/settings")
121
+ bindings = Array(settings.dig("result", "bindings"))
122
+ configured = bindings.any? { |b| b["name"] == "INBOUND_EMAIL_STORE" && b["type"] == "r2_bucket" } &&
123
+ bindings.any? { |b| b["name"] == "INBOUND_EMAIL_QUEUE" && b["type"] == "queue" }
124
+ schedules = request(method: :get, path: "/accounts/#{@account_id}/workers/scripts/#{@script_name}/schedules")
125
+ configured &&= Array(schedules.dig("result", "schedules")).any? { |s| s["cron"] == "* * * * *" }
126
+ return if configured
127
+
128
+ raise ConfigurationError, "Provision durable R2, Queue producer/consumer and minute schedule using the generated Worker docs/durable-inbound.md before deploying; use INBOUND_DELIVERY_MODE=direct only for explicit direct fallback"
129
+ rescue Error => error
130
+ raise unless error.status == 404
131
+
132
+ raise ConfigurationError, "Provision the durable Worker with Wrangler first (see Worker docs/durable-inbound.md); use INBOUND_DELIVERY_MODE=direct only for explicit direct fallback"
133
+ end
134
+
135
+ def build_multipart(boundary, source, delivery_mode)
136
+ settings = {
113
137
  main_module: "index.js",
114
138
  compatibility_date: @compatibility_date,
115
- })
139
+ # API script uploads must not remove provisioned storage, queue or
140
+ # secrets. In particular a direct rollback still drains pending mail.
141
+ keep_bindings: %w[secret_text plain_text r2_bucket queue],
142
+ }
143
+ if delivery_mode
144
+ settings[:bindings] = [{ type: "plain_text", name: "INBOUND_DELIVERY_MODE", text: delivery_mode }]
145
+ end
146
+ metadata = JSON.generate(settings)
116
147
 
117
148
  parts = []
118
149
  parts << "--#{boundary}"
@@ -102,8 +102,8 @@ module Cloudflare
102
102
  should_deploy = options[:deploy_worker]
103
103
  if should_deploy.nil?
104
104
  say ""
105
- say " The Worker can be deployed via the Cloudflare API (pure Ruby, no wrangler/Node)"
106
- say " once you've set cloudflare.account_id and cloudflare.api_token in Rails credentials."
105
+ say " First provision durable storage, Queue and cron using the generated Worker docs/durable-inbound.md."
106
+ say " After the initial Wrangler deploy, Ruby uploads preserve those infrastructure bindings."
107
107
  say " Run `RAILS_ENV=production bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument} URL=https://yourapp.com#{ingress_path}`"
108
108
  say " after `bin/rails credentials:edit`."
109
109
  say ""
@@ -121,6 +121,7 @@ module Cloudflare
121
121
  @ingress_secret = SecureRandom.hex(32)
122
122
 
123
123
  inside options[:worker_dir] do
124
+ say "Create the R2 bucket and Queue in docs/durable-inbound.md before deploying. Customize wrangler.toml names if needed."
124
125
  run "npm ci", abort_on_failure: true
125
126
 
126
127
  ingress_url = ask("Rails ingress URL? (e.g. https://yourapp.com#{ingress_path})")
@@ -168,10 +169,10 @@ module Cloudflare
168
169
 
169
170
  if options[:inbound] && !@worker_deployed
170
171
  say " 4. Deploy the Worker (pick one):"
171
- say " # Pure Ruby (recommended — no wrangler/Node required):"
172
+ say " # First follow Worker docs/durable-inbound.md to provision R2, Queue and cron."
172
173
  say " RAILS_ENV=production bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument} URL=https://yourapp.com#{ingress_path}"
173
174
  say ""
174
- say " # Or via wrangler if you have it installed:"
175
+ say " # Initial infrastructure deploy (after provisioning resources):"
175
176
  say " cd #{options[:worker_dir]}"
176
177
  say " npm ci"
177
178
  say " npm run deploy -- --env production"
@@ -182,7 +183,7 @@ module Cloudflare
182
183
 
183
184
  if options[:inbound]
184
185
  say " 5. For local dev (tunnels cloudflared to your Worker):"
185
- say " RAILS_ENV=development bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument}"
186
+ say " INBOUND_DELIVERY_MODE=direct RAILS_ENV=development bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument}"
186
187
  say " RAILS_ENV=development bin/rails cloudflare:email:dev"
187
188
  say " Route a separate test address to cloudflare-email-ingress-development."
188
189
  say ""
@@ -0,0 +1,21 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ module Generators
7
+ class CatchAllGenerator < ::Rails::Generators::Base
8
+ include ::ActiveRecord::Generators::Migration
9
+ namespace "cloudflare:email:mailboxes:catch_all"
10
+ source_root File.expand_path("templates", __dir__)
11
+ class_option :tenant_migrations_path, type: :string, default: "db/migrate",
12
+ desc: "Migration directory applied to every tenant database"
13
+
14
+ def copy_migration
15
+ migration_template "add_cloudflare_email_catch_all.rb",
16
+ File.join(options[:tenant_migrations_path], "add_cloudflare_email_catch_all.rb")
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ class AddCloudflareEmailCatchAll < ActiveRecord::Migration[7.1]
2
+ def up
3
+ unless column_exists?(:cloudflare_email_addresses, :catch_all)
4
+ add_column :cloudflare_email_addresses, :catch_all, :boolean, null: false, default: false
5
+ end
6
+ unless column_exists?(:cloudflare_email_addresses, :catch_all_evidence)
7
+ add_column :cloudflare_email_addresses, :catch_all_evidence, :text
8
+ end
9
+ unless index_exists?(:cloudflare_email_addresses, :receiving_domain_id, name: "idx_cf_email_domain_catch_all")
10
+ add_index :cloudflare_email_addresses, :receiving_domain_id, unique: true,
11
+ where: "catch_all = TRUE AND state = 'active'", name: "idx_cf_email_domain_catch_all"
12
+ end
13
+ end
14
+
15
+ def down
16
+ raise ActiveRecord::IrreversibleMigration, "Disable catch-all receiving before an explicit forward schema change"
17
+ end
18
+ end
@@ -19,10 +19,14 @@ class CreateCloudflareEmailMailboxes < ActiveRecord::Migration[7.1]
19
19
  t.string :address, null: false
20
20
  t.string :state, null: false, default: "pending"
21
21
  t.text :provisioning_evidence
22
+ t.boolean :catch_all, null: false, default: false
23
+ t.text :catch_all_evidence
22
24
  t.timestamps
23
25
  end
24
26
  add_index :cloudflare_email_addresses, :address, unique: true, name: "idx_cf_email_mailbox_address"
25
27
  add_index :cloudflare_email_addresses, :mailbox_id, name: "idx_cf_email_address_mailbox"
28
+ add_index :cloudflare_email_addresses, :receiving_domain_id, unique: true,
29
+ where: "catch_all = TRUE AND state = 'active'", name: "idx_cf_email_domain_catch_all"
26
30
 
27
31
  create_table :cloudflare_email_mailbox_messages do |t|
28
32
  t.string :tenant_key, null: false
@@ -0,0 +1,28 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ module Generators
7
+ class RoutingTrackingGenerator < ::Rails::Generators::Base
8
+ include ::ActiveRecord::Generators::Migration
9
+ namespace "cloudflare:email:routing_tracking"
10
+ source_root File.expand_path("templates", __dir__)
11
+ class_option :migrations_path, type: :string, default: "db/migrate"
12
+
13
+ def copy_tracking_migration
14
+ migration_template "create_cloudflare_email_routing_delivery_receipts.rb",
15
+ File.join(options[:migrations_path], "create_cloudflare_email_routing_delivery_receipts.rb")
16
+ end
17
+
18
+ def create_initializer
19
+ create_file "config/initializers/cloudflare_email_routing_tracking.rb", <<~RUBY
20
+ # Requires the outbox migration and Rails Active Record encryption keys.
21
+ # This loads the API only; your application schedules analytics polling.
22
+ require "cloudflare/email/active_record/routing_deliveries"
23
+ RUBY
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,22 @@
1
+ class CreateCloudflareEmailRoutingDeliveryReceipts < ActiveRecord::Migration[7.1]
2
+ def up
3
+ create_table :cloudflare_email_routing_delivery_receipts do |t|
4
+ t.references :outbound_delivery, null: false, index: {name: "idx_cf_routing_delivery"},
5
+ foreign_key: {to_table: :cloudflare_email_outbound_deliveries}
6
+ t.string :account_id, null: false
7
+ t.string :zone_id, null: false
8
+ t.string :event_key, null: false
9
+ t.text :payload_json, null: false
10
+ t.string :state, null: false, default: "pending"
11
+ t.datetime :received_at, null: false
12
+ t.datetime :applied_at
13
+ t.timestamps
14
+ end
15
+ add_index :cloudflare_email_routing_delivery_receipts, [:account_id, :event_key], unique: true, name: "idx_cf_routing_identity"
16
+ add_index :cloudflare_email_routing_delivery_receipts, [:account_id, :state, :id], name: "idx_cf_routing_replay"
17
+ end
18
+
19
+ def down
20
+ raise ActiveRecord::IrreversibleMigration, "Preserve authentic delivery evidence; use a forward fix or a reconciled backup"
21
+ end
22
+ end
@@ -15,7 +15,8 @@ namespace :cloudflare do
15
15
  desc "Deploy the Worker via Cloudflare API. URL=https://... sets RAILS_INGRESS_URL; SCRIPT=path overrides the Worker source"
16
16
  task deploy_worker: :environment do
17
17
  require "cloudflare/email/deploy_worker_task"
18
- exit Cloudflare::Email::DeployWorkerTask.call(ingress_url: ENV["URL"], script_path: ENV["SCRIPT"])
18
+ exit Cloudflare::Email::DeployWorkerTask.call(ingress_url: ENV["URL"], script_path: ENV["SCRIPT"],
19
+ delivery_mode: ENV.fetch("INBOUND_DELIVERY_MODE", "durable"))
19
20
  end
20
21
 
21
22
  desc "Create Cloudflare Email Routing rule: ADDRESS=addr@domain → env-scoped Worker (WORKER=name to override)"
@@ -0,0 +1,11 @@
1
+ namespace :cloudflare do
2
+ namespace :email do
3
+ desc "Inspect routing without changes (ADDRESS=addr [WORKER_NAME=name] [ACCOUNT_ID=id]); nonzero if failed or unverified"
4
+ task check_route: :environment do
5
+ require "cloudflare/email/check_route_task"
6
+ exit Cloudflare::Email::CheckRouteTask.call(
7
+ address: ENV["ADDRESS"], worker_name: ENV["WORKER_NAME"] || ENV["WORKER"], account_id: ENV["ACCOUNT_ID"],
8
+ )
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ # Supply these in the Cloudflare deployment form. Never commit real values.
2
+ RAILS_INGRESS_URL=
3
+ INGRESS_SECRET=
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ .wrangler/
3
+ .dev.vars*
4
+ !.dev.vars.example
5
+ .env*
6
+ !.env.example
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cole
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,84 @@
1
+ # Cloudflare Email for Rails
2
+
3
+ Receive email in Rails with the [cloudflare-email gem](https://github.com/cole-robertson/cloudflare-email).
4
+ This template stores inbound mail in private R2, delivers it through a Queue,
5
+ and retries from storage when Rails is unavailable. It includes scheduled
6
+ recovery and Worker logs. No React app or extra server is needed.
7
+
8
+ [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cole-robertson/cloudflare-email/tree/main/templates/deploy-to-cloudflare)
9
+
10
+ ## 1. Prepare Rails
11
+
12
+ Install `gem "cloudflare-email", "~> 0.3.0"` and follow the
13
+ [Rails setup guide](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/getting-started.md).
14
+ Deploy Rails with Action Mailbox storage and routing configured before connecting
15
+ real email. Generate a secret with `openssl rand -hex 32` and configure it as
16
+ `cloudflare.ingress_secret` in Rails credentials or `CLOUDFLARE_INGRESS_SECRET`.
17
+ Keep the same value for the deployment form.
18
+
19
+ ## 2. Deploy the Worker
20
+
21
+ Click the button and sign in to Cloudflare and GitHub/GitLab as prompted.
22
+ Cloudflare copies this directory into your own repository, provisions the R2
23
+ bucket and Queue, and builds the Worker. Enter:
24
+
25
+ - **RAILS_INGRESS_URL:** your public HTTPS endpoint, normally
26
+ `https://your-app.com/rails/action_mailbox/cloudflare/inbound_emails`.
27
+ - **INGRESS_SECRET:** the exact secret you configured in Rails.
28
+
29
+ Choose distinct Worker, bucket, and Queue names for each app/environment. This
30
+ template has one environment per deployment; deploy separate copies for staging
31
+ and production. R2 and Queues must be available on your account and their usage
32
+ can incur charges. Review Cloudflare's resource and billing prompts.
33
+
34
+ The form uses `npm run deploy`; no `--env` argument is required for this template.
35
+ After deployment, confirm both bindings, the Queue consumer, and the once-per-minute
36
+ cron are present. Keep the bucket private with no lifecycle rule expiring
37
+ `cloudflare-email/pending/` objects. The Worker has no public email-submit HTTP API.
38
+
39
+ ## 3. Connect an email address
40
+
41
+ In Cloudflare, enable Email Routing for the intended receiving domain/subdomain
42
+ and complete its required DNS setup. Add an Email Routing rule for your receiving
43
+ address with **Send to a Worker**, selecting the Worker you just deployed.
44
+ The button does not change your MX records, provision addresses in Rails, or
45
+ replace your existing mailbox provider. Use a receiving subdomain when your apex
46
+ domain's email belongs to Google Workspace or Microsoft 365.
47
+
48
+ If using the gem's managed mailboxes, register the receiving mailbox/address in
49
+ Rails too. Tenant support and domain catch-all receiving remain explicit choices.
50
+ Outbound Email Sending credentials and delivery-event subscriptions are separate
51
+ from this inbound Worker setup.
52
+
53
+ ## 4. Verify and operate
54
+
55
+ Send to the configured address and verify that Rails retains and routes it.
56
+ In staging, stop Rails, send another message, confirm it remains in R2, restore
57
+ Rails, and confirm one inbox record and removal of the pending object. Verify a
58
+ retry does not duplicate application work. Monitor Worker errors and pending R2
59
+ count/age; Queue depth alone misses mail whose queue retries are exhausted.
60
+
61
+ Read the [durable delivery and recovery guide](https://github.com/cole-robertson/cloudflare-email/blob/main/templates/worker/docs/durable-inbound.md)
62
+ for recovery limits, retention, failure drills, and the explicit direct fallback.
63
+ In this standalone template, configure bindings at the top level and run
64
+ `npm run deploy` without `--env`. Do not remove storage or cron while mail is pending.
65
+ Delivery is at least once, with Rails deduplication; application handlers must
66
+ also tolerate retries. Configure operational alerts in your monitoring service.
67
+
68
+ ## Updates and local development
69
+
70
+ Your copied repository belongs to you; gem updates do not update its Worker
71
+ automatically. Review upstream release notes, deploy Rails first, then port Worker
72
+ changes into your copy while preserving your resource names, secrets, and pending
73
+ storage. Keep production resources separate from test resources.
74
+
75
+ Use Node 22.12+ (or a supported newer version): `npm ci`, `npm test`, and
76
+ `npm run check`. For local development, copy `.dev.vars.example` to `.dev.vars`,
77
+ set local values, and run `npm run dev`. Never commit secrets.
78
+
79
+ Upstream maintainers: `templates/worker` is the canonical runtime. Run
80
+ `node script/sync_deploy_template.mjs` from the gem repository after modifying its
81
+ runtime, tests, dependencies, or production bindings. CI rejects drift and tests
82
+ this directory after copying it outside the repository, matching Cloudflare's
83
+ subdirectory deployment model. Edit this README directly; generated files are
84
+ marked by the sync script's file list.