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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +109 -0
  3. data/README.md +44 -9
  4. data/app/controllers/cloudflare/email/ingress_controller.rb +15 -60
  5. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +9 -0
  6. data/app/controllers/cloudflare/email/management/styles_controller.rb +9 -0
  7. data/docs/activerecord-tenanted.md +46 -0
  8. data/docs/architecture.md +13 -3
  9. data/docs/custom-ingress.md +211 -0
  10. data/docs/delivery-events.md +9 -0
  11. data/docs/features.md +36 -5
  12. data/docs/getting-started.md +19 -8
  13. data/docs/mailboxes.md +74 -3
  14. data/docs/management-engine.md +190 -0
  15. data/docs/routing-deliveries.md +94 -0
  16. data/docs/routing-diagnostics.md +75 -0
  17. data/docs/troubleshooting.md +5 -4
  18. data/docs/upgrading-0.2.md +5 -0
  19. data/docs/upgrading-0.3.md +55 -0
  20. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  21. data/docs/verification/2026-09-12-destination-context.md +46 -0
  22. data/docs/verification/2026-09-13-action-mailbox-core.md +96 -0
  23. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  24. data/lib/cloudflare/email/active_record/base.rb +3 -41
  25. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  26. data/lib/cloudflare/email/check_route_task.rb +21 -0
  27. data/lib/cloudflare/email/client.rb +45 -10
  28. data/lib/cloudflare/email/delivery_method.rb +2 -0
  29. data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
  30. data/lib/cloudflare/email/engine.rb +6 -13
  31. data/lib/cloudflare/email/envelope.rb +4 -14
  32. data/lib/cloudflare/email/error.rb +5 -12
  33. data/lib/cloudflare/email/event_consumer.rb +12 -3
  34. data/lib/cloudflare/email/ingress.rb +126 -0
  35. data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
  36. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
  37. data/lib/cloudflare/email/mailboxes/models.rb +8 -130
  38. data/lib/cloudflare/email/mailboxes/service.rb +7 -174
  39. data/lib/cloudflare/email/mailboxes.rb +1 -0
  40. data/lib/cloudflare/email/management/adapter.rb +9 -0
  41. data/lib/cloudflare/email/management/configuration.rb +10 -0
  42. data/lib/cloudflare/email/management/engine.rb +17 -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/tenancy.rb +2 -73
  49. data/lib/cloudflare/email/tenant_job_context.rb +2 -85
  50. data/lib/cloudflare/email/verification.rb +17 -9
  51. data/lib/cloudflare/email/version.rb +1 -1
  52. data/lib/cloudflare/email/worker_deployer.rb +36 -5
  53. data/lib/cloudflare-email.rb +6 -0
  54. data/lib/generators/cloudflare/email/install_generator.rb +6 -5
  55. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  56. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  57. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +5 -0
  58. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
  59. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  60. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  61. data/lib/tasks/cloudflare_email.rake +2 -1
  62. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  63. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  64. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  65. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  66. data/templates/deploy-to-cloudflare/README.md +104 -0
  67. data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
  68. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  69. data/templates/deploy-to-cloudflare/package.json +37 -0
  70. data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
  71. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  72. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  73. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  74. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  75. data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
  76. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  77. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  78. data/templates/worker/README.md +186 -15
  79. data/templates/worker/docs/domain-setup.md +198 -0
  80. data/templates/worker/docs/durable-inbound.md +149 -0
  81. data/templates/worker/package-lock.json +2 -2
  82. data/templates/worker/package.json +2 -1
  83. data/templates/worker/scripts/check-subdomains.mjs +54 -0
  84. data/templates/worker/src/index.js +331 -38
  85. data/templates/worker/test/durable.test.ts +193 -0
  86. data/templates/worker/test/index.test.ts +78 -20
  87. data/templates/worker/test/relay.test.ts +226 -0
  88. data/templates/worker/test/subdomains.test.ts +51 -0
  89. data/templates/worker/wrangler.toml +50 -0
  90. metadata +65 -3
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "timeout"
6
+ require "time"
7
+ require "digest"
8
+ require "cloudflare/email/message_id"
9
+
10
+ module Cloudflare
11
+ module Email
12
+ # Optional, authenticated, delivered-only evidence. This is not a webhook.
13
+ module RoutingAnalytics
14
+ class Error < StandardError; end
15
+
16
+ class Evidence
17
+ attr_reader :payload
18
+
19
+ def initialize(payload)
20
+ @payload = JSON.parse(JSON.generate(payload))
21
+ validate!
22
+ freeze_value(@payload)
23
+ freeze
24
+ rescue JSON::ParserError, JSON::GeneratorError, KeyError, ArgumentError, TypeError
25
+ raise Error, "Routing analytics evidence is malformed", cause: nil
26
+ end
27
+
28
+ def event = payload.fetch("event")
29
+ def account_id = payload.fetch("account_id")
30
+ def zone_id = payload.fetch("zone_id")
31
+ def message_id = MessageId.normalize(event.fetch("messageId"))
32
+ def occurred_at = Time.iso8601(event.fetch("datetime"))
33
+
34
+ def identity
35
+ Digest::SHA256.hexdigest(JSON.generate([account_id, zone_id, event.fetch("sessionId"),
36
+ message_id, occurred_at.iso8601(9), event.fetch("status")]))
37
+ end
38
+
39
+ # Query windows change between polls; the authentic row must not change.
40
+ def same_event?(other)
41
+ account_id == other.account_id && zone_id == other.zone_id && event == other.event
42
+ end
43
+
44
+ private
45
+
46
+ def validate!
47
+ unless payload.is_a?(Hash) && payload["source"] == "cloudflare_routing_analytics" &&
48
+ payload["account_id"].is_a?(String) && !payload["account_id"].empty? &&
49
+ payload["zone_id"].is_a?(String) && payload["zone_id"].match?(/\A[a-f0-9]{32}\z/) &&
50
+ event.is_a?(Hash) && event["eventType"] == "newEmail" && event["status"] == "delivered" &&
51
+ event["isNDR"] == 0 && event["isLastEvent"] == 1 && event["sampleInterval"] == 1 &&
52
+ event["sessionId"].is_a?(String) && !event["sessionId"].strip.empty? &&
53
+ event["from"].is_a?(String) && !event["from"].strip.empty? &&
54
+ event["messageId"].is_a?(String) && !message_id.empty?
55
+ raise Error, "Routing analytics evidence is not an unsampled final delivery"
56
+ end
57
+ start = Time.iso8601(payload.fetch("query_started_at"))
58
+ finish = Time.iso8601(payload.fetch("query_finished_at"))
59
+ unless start <= occurred_at && occurred_at <= finish && finish <= Time.now.utc
60
+ raise Error, "Routing analytics evidence is outside its query window"
61
+ end
62
+ response = payload.fetch("response")
63
+ viewer = response["data"] if response.is_a?(Hash)
64
+ viewer = viewer["viewer"] if viewer.is_a?(Hash)
65
+ zones = viewer["zones"] if viewer.is_a?(Hash)
66
+ unless response.is_a?(Hash) && response["success"] != false &&
67
+ (response["errors"].nil? || response["errors"] == []) && zones.is_a?(Array) && zones.one? &&
68
+ zones.first.is_a?(Hash) && zones.first["zoneTag"] == zone_id
69
+ raise Error, "Routing analytics evidence response has unexpected zone"
70
+ end
71
+ rows = zones.first["emailRoutingAdaptive"]
72
+ unless rows.is_a?(Array) && rows.length < 100 && rows.all? { |row| row.is_a?(Hash) } && rows.include?(event)
73
+ raise Error, "Routing analytics evidence response is malformed or truncated"
74
+ end
75
+ end
76
+
77
+ def freeze_value(value)
78
+ value.each { |key, item| freeze_value(key); freeze_value(item) } if value.is_a?(Hash)
79
+ value.each { |item| freeze_value(item) } if value.is_a?(Array)
80
+ value.freeze
81
+ end
82
+ end
83
+
84
+ class Client
85
+ REQUEST_TIMEOUT = 5
86
+ MAX_RESPONSE_BYTES = 1_000_000
87
+ QUERY = <<~GRAPHQL.freeze
88
+ query($zone: string!, $ids: [string!], $start: Time!, $finish: Time!) {
89
+ viewer { zones(filter: {zoneTag: $zone}) {
90
+ zoneTag
91
+ emailRoutingAdaptive(limit: 100, orderBy: [datetime_ASC], filter: {
92
+ messageId_in: $ids, datetime_geq: $start, datetime_leq: $finish
93
+ }) { messageId sessionId datetime eventType status isNDR isLastEvent sampleInterval from to action }
94
+ } }
95
+ }
96
+ GRAPHQL
97
+
98
+ def initialize(account_id:, zone_id:, api_token:)
99
+ unless account_id.is_a?(String) && !account_id.empty? && zone_id.is_a?(String) &&
100
+ zone_id.match?(/\A[a-f0-9]{32}\z/) && api_token.is_a?(String) && !api_token.strip.empty? &&
101
+ !api_token.match?(/[\r\n]/)
102
+ raise ArgumentError, "Routing analytics requires an account, zone ID and API token"
103
+ end
104
+ @account, @zone, @token = account_id.dup.freeze, zone_id.dup.freeze, api_token.dup.freeze
105
+ end
106
+
107
+ def inspect = "#<#{self.class} credentials=[FILTERED]>"
108
+
109
+ def verify_account!
110
+ @verified = false
111
+ result = request("/zones/#{@zone}")["result"]
112
+ unless result.is_a?(Hash) && result["id"] == @zone && result["account"].is_a?(Hash) &&
113
+ result["account"]["id"] == @account
114
+ raise Error, "Routing analytics zone/account mismatch"
115
+ end
116
+ @verified = true
117
+ end
118
+
119
+ def delivery_evidence(message_id:, since:, until_time:)
120
+ raise Error, "Verify routing analytics zone/account first" unless @verified
121
+ id = MessageId.normalize(message_id)
122
+ unless !id.empty? && since.is_a?(Time) && until_time.is_a?(Time) && since <= until_time && until_time <= Time.now.utc
123
+ raise ArgumentError, "A message ID and valid historical Time window are required"
124
+ end
125
+ # Analytics timestamps have second precision, unlike saved attempts.
126
+ start, finish = Time.at(since.to_i).utc.iso8601, until_time.getutc.iso8601
127
+ data = request("/graphql", query: QUERY, variables: {
128
+ zone: @zone, ids: [id, "<#{id}>"], start: start, finish: finish
129
+ })
130
+ viewer = data["data"]
131
+ viewer = viewer["viewer"] if viewer.is_a?(Hash)
132
+ zones = viewer["zones"] if viewer.is_a?(Hash)
133
+ unless zones.is_a?(Array) && zones.one? && zones.first.is_a?(Hash) && zones.first["zoneTag"] == @zone
134
+ raise Error, "Routing analytics response has unexpected zone"
135
+ end
136
+ rows = zones.first["emailRoutingAdaptive"]
137
+ unless rows.is_a?(Array) && rows.length < 100 && rows.all? { |row| row.is_a?(Hash) }
138
+ raise Error, "Routing analytics response is malformed or truncated"
139
+ end
140
+ final_rows = rows.select { |row| row["eventType"] == "newEmail" && row["isNDR"] == 0 &&
141
+ row["isLastEvent"] == 1 && row["sampleInterval"] == 1 }
142
+ final_rows.group_by { |row| [MessageId.normalize(row["messageId"]), row["sessionId"]] }.each_value do |events|
143
+ if events.uniq.length > 1
144
+ raise Error, "Routing analytics response contains contradictory final events"
145
+ end
146
+ end
147
+ rows.filter_map do |row|
148
+ next unless row["eventType"] == "newEmail" && row["status"] == "delivered" &&
149
+ row["isNDR"] == 0 && row["isLastEvent"] == 1 && row["sampleInterval"] == 1
150
+ evidence = Evidence.new("source" => "cloudflare_routing_analytics", "account_id" => @account,
151
+ "zone_id" => @zone, "query_started_at" => start, "query_finished_at" => finish,
152
+ "response" => data, "event" => row)
153
+ raise Error, "Routing analytics response has unexpected Message-ID" unless evidence.message_id == id
154
+ evidence
155
+ end
156
+ end
157
+
158
+ private
159
+
160
+ def request(path, body = nil)
161
+ uri = URI("https://api.cloudflare.com/client/v4#{path}")
162
+ http = Net::HTTP.new(uri.host, uri.port)
163
+ http.use_ssl = true
164
+ http.open_timeout = http.read_timeout = http.write_timeout = REQUEST_TIMEOUT
165
+ http.max_retries = 0
166
+ request = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
167
+ request["Authorization"] = "Bearer #{@token}"
168
+ request["Content-Type"] = "application/json"
169
+ request.body = JSON.generate(body) if body
170
+ payload = +""
171
+ Timeout.timeout(REQUEST_TIMEOUT) do
172
+ http.request(request) do |response|
173
+ raise Error, "Routing analytics HTTP request failed" unless response.is_a?(Net::HTTPSuccess)
174
+ response.read_body do |chunk|
175
+ raise Error, "Routing analytics response is too large" if payload.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
176
+ payload << chunk
177
+ end
178
+ end
179
+ end
180
+ data = JSON.parse(payload)
181
+ unless data.is_a?(Hash) && data["success"] != false && (data["errors"].nil? || data["errors"] == [])
182
+ raise Error, "Routing analytics API rejected query"
183
+ end
184
+ data
185
+ rescue JSON::ParserError, KeyError, TypeError
186
+ raise Error, "Routing analytics API response is invalid", cause: nil
187
+ rescue Timeout::Error
188
+ raise Error, "Routing analytics HTTP deadline exceeded", cause: nil
189
+ rescue IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError,
190
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError
191
+ raise Error, "Routing analytics HTTP request failed", cause: nil
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -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,78 +1,7 @@
1
1
  require "cloudflare/email/error"
2
-
2
+ require "mailbox_kit/tenancy"
3
3
  module Cloudflare
4
4
  module Email
5
- # An adapter boundary: the host owns tenant discovery and connection switching.
6
- # Configure once, before requiring any optional Active Record models.
7
- module Tenancy
8
- CONTEXT_KEY = :cloudflare_email_tenant_key
9
-
10
- class << self
11
- def configure(base_class:, switch:, current:)
12
- raise ConfigurationError, "configure tenancy before loading Cloudflare Email models" if @models_loaded
13
- raise ConfigurationError, "tenancy is already configured" if enabled?
14
- unless base_class.is_a?(Class) && base_class.respond_to?(:abstract_class?) && base_class.abstract_class?
15
- raise ConfigurationError, "base_class must be an abstract Active Record class"
16
- end
17
- unless switch.respond_to?(:call) && current.respond_to?(:call)
18
- raise ConfigurationError, "switch and current must be callable"
19
- end
20
-
21
- @base_class, @switch, @current = base_class, switch, current
22
- self
23
- end
24
-
25
- def enabled?
26
- !@switch.nil?
27
- end
28
-
29
- def model_base(default)
30
- @models_loaded = true
31
- @base_class || default
32
- end
33
-
34
- def current_key
35
- Thread.current[CONTEXT_KEY]
36
- end
37
-
38
- # Only trusted host integration code should use this to capture new work.
39
- # Model access and persisted job deserialization still require explicit
40
- # gem context; the adapter must not invent a default tenant here.
41
- def host_current_key
42
- return unless enabled?
43
- key = @current.call
44
- normalize_key(key) unless key.nil?
45
- end
46
-
47
- def normalize_key(key)
48
- unless key.is_a?(String) && !key.empty? && key == key.strip && !key.match?(/[[:cntrl:]]/)
49
- raise ConfigurationError, "tenant key must be a nonempty string without surrounding whitespace or control characters"
50
- end
51
- key.dup.freeze
52
- end
53
-
54
- def require_context!
55
- key = current_key
56
- raise ConfigurationError, "an explicit Cloudflare Email tenant context is required" unless key
57
- if enabled? && @current.call != key
58
- raise ConfigurationError, "host database tenant does not match Cloudflare Email tenant context"
59
- end
60
- key
61
- end
62
-
63
- def with(key)
64
- key = normalize_key(key)
65
- previous = current_key
66
- run = proc do
67
- Thread.current[CONTEXT_KEY] = key
68
- require_context!
69
- yield
70
- ensure
71
- Thread.current[CONTEXT_KEY] = previous
72
- end
73
- enabled? ? @switch.call(key, &run) : run.call
74
- end
75
- end
76
- end
5
+ Tenancy = MailboxKit::Tenancy
77
6
  end
78
7
  end
@@ -1,90 +1,7 @@
1
1
  require "cloudflare/email/tenancy"
2
-
2
+ require "mailbox_kit/tenant_job_context"
3
3
  module Cloudflare
4
4
  module Email
5
- # Opt in with `prepend Cloudflare::Email::TenantJobContext` on jobs whose
6
- # arguments or work reference tenant records. Queue payloads must be trusted.
7
- module TenantJobContext
8
- PAYLOAD_KEY = "cloudflare_email_tenant_key".freeze
9
-
10
- def serialize
11
- key = cloudflare_email_job_tenant_key
12
- return super unless key
13
- Tenancy.with(key) do
14
- payload = super
15
- verify_cloudflare_email_host_tenant!(key, payload["tenant"])
16
- payload.merge(PAYLOAD_KEY => key)
17
- end
18
- end
19
-
20
- def deserialize(job_data)
21
- # Bind before super: host job integrations may deserialize model arguments
22
- # eagerly. Never infer a missing tenant from the worker's ambient context.
23
- if !job_data.key?(PAYLOAD_KEY) && cloudflare_email_optional_job_context?
24
- @cloudflare_email_job_tenant_key = nil
25
- return super
26
- end
27
- @cloudflare_email_job_tenant_key = Tenancy.normalize_key(job_data[PAYLOAD_KEY])
28
- verify_cloudflare_email_host_tenant!(@cloudflare_email_job_tenant_key, job_data["tenant"])
29
- Tenancy.with(@cloudflare_email_job_tenant_key) { super }
30
- end
31
-
32
- def perform_now
33
- # Active Job resolves GlobalIDs before around_perform, so that callback
34
- # cannot safely implement database tenant selection.
35
- key = cloudflare_email_job_tenant_key
36
- return super unless key
37
- if defined?(::ActiveRecord::Tenanted::Job) && respond_to?(:tenant)
38
- verify_cloudflare_email_host_tenant!(key, tenant)
39
- end
40
- Tenancy.with(key) { super }
41
- end
42
-
43
- def self.install_framework_jobs!
44
- if defined?(::ActionMailbox)
45
- %i[RoutingJob IncinerationJob].each do |name|
46
- next unless ::ActionMailbox.const_defined?(name)
47
- klass = ::ActionMailbox.const_get(name)
48
- klass.define_singleton_method(:cloudflare_email_optional_job_context?) { !Tenancy.enabled? }
49
- klass.prepend(self) unless klass.ancestors.include?(self)
50
- end
51
- end
52
- if defined?(::ActiveStorage)
53
- %i[BaseJob AnalyzeJob PurgeJob MirrorJob TransformJob PreviewImageJob].each do |name|
54
- next unless ::ActiveStorage.const_defined?(name)
55
- klass = ::ActiveStorage.const_get(name)
56
- klass.define_singleton_method(:cloudflare_email_optional_job_context?) { !Tenancy.enabled? }
57
- klass.prepend(self) unless klass.ancestors.include?(self)
58
- end
59
- end
60
- end
61
-
62
- private
63
-
64
- def cloudflare_email_job_tenant_key
65
- # Once serialized/deserialized, retries retain their original tenant even
66
- # if re-enqueued from a different tenant or outside a tenant context.
67
- return @cloudflare_email_job_tenant_key if instance_variable_defined?(:@cloudflare_email_job_tenant_key)
68
- return nil if cloudflare_email_optional_job_context? && !Tenancy.current_key
69
- # Native uploads/mailbox work may originate in the host's own with_tenant
70
- # block. Framework jobs can capture that trusted context on first use.
71
- # deserialize never takes this path for missing persisted metadata.
72
- if !Tenancy.current_key && self.class.respond_to?(:cloudflare_email_optional_job_context?)
73
- key = Tenancy.host_current_key
74
- return @cloudflare_email_job_tenant_key = key if key
75
- end
76
- @cloudflare_email_job_tenant_key = Tenancy.require_context!
77
- end
78
-
79
- def cloudflare_email_optional_job_context?
80
- self.class.respond_to?(:cloudflare_email_optional_job_context?) && self.class.cloudflare_email_optional_job_context?
81
- end
82
-
83
- def verify_cloudflare_email_host_tenant!(key, host_key)
84
- if host_key && host_key != key
85
- raise ConfigurationError, "job host tenant conflicts with Cloudflare Email tenant context"
86
- end
87
- end
88
- end
5
+ TenantJobContext = MailboxKit::TenantJobContext
89
6
  end
90
7
  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.4.0"
4
4
  end
5
5
  end