cloudflare-email 0.1.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 (139) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +287 -385
  4. data/SECURITY.md +51 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +33 -21
  6. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
  7. data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
  8. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
  9. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
  10. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
  11. data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
  12. data/docs/activerecord-tenanted.md +181 -0
  13. data/docs/architecture.md +105 -0
  14. data/docs/custom-ingress.md +207 -0
  15. data/docs/delivery-events.md +186 -0
  16. data/docs/features.md +138 -0
  17. data/docs/getting-started.md +308 -0
  18. data/docs/mailboxes.md +363 -0
  19. data/docs/management-engine.md +190 -0
  20. data/docs/outbox.md +175 -0
  21. data/docs/routing-deliveries.md +94 -0
  22. data/docs/routing-diagnostics.md +68 -0
  23. data/docs/tenant-mailboxes-plan.md +149 -0
  24. data/docs/thread-correlation.md +27 -0
  25. data/docs/troubleshooting.md +104 -0
  26. data/docs/upgrading-0.2.md +101 -0
  27. data/docs/upgrading-0.3.md +55 -0
  28. data/docs/verification/2026-09-10-followup.md +49 -0
  29. data/docs/verification/2026-09-10-install-upgrade.md +86 -0
  30. data/docs/verification/2026-09-10-live.md +102 -0
  31. data/docs/verification/2026-09-10.md +175 -0
  32. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  33. data/docs/verification/2026-09-11-extraction.md +32 -0
  34. data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
  35. data/docs/verification/2026-09-11-security.md +76 -0
  36. data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
  37. data/docs/verification/2026-09-12-destination-context.md +46 -0
  38. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  39. data/examples/plain_ruby.rb +24 -0
  40. data/lib/cloudflare/email/active_record/base.rb +60 -0
  41. data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
  42. data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
  43. data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
  44. data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
  45. data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
  46. data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
  47. data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
  48. data/lib/cloudflare/email/active_record/outbox.rb +219 -0
  49. data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
  50. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  51. data/lib/cloudflare/email/active_record.rb +5 -0
  52. data/lib/cloudflare/email/check_route_task.rb +21 -0
  53. data/lib/cloudflare/email/client.rb +93 -18
  54. data/lib/cloudflare/email/consume_events_task.rb +22 -0
  55. data/lib/cloudflare/email/delivery_event.rb +70 -0
  56. data/lib/cloudflare/email/delivery_method.rb +5 -0
  57. data/lib/cloudflare/email/deploy_worker_task.rb +6 -3
  58. data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
  59. data/lib/cloudflare/email/dev_tunnel.rb +31 -5
  60. data/lib/cloudflare/email/doctor.rb +16 -38
  61. data/lib/cloudflare/email/endpoint.rb +24 -0
  62. data/lib/cloudflare/email/engine.rb +33 -0
  63. data/lib/cloudflare/email/envelope.rb +58 -0
  64. data/lib/cloudflare/email/event_consumer.rb +95 -0
  65. data/lib/cloudflare/email/ingress.rb +115 -0
  66. data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
  67. data/lib/cloudflare/email/mailboxes/events.rb +112 -0
  68. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
  69. data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
  70. data/lib/cloudflare/email/mailboxes/models.rb +165 -0
  71. data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
  72. data/lib/cloudflare/email/mailboxes/service.rb +372 -0
  73. data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
  74. data/lib/cloudflare/email/mailboxes.rb +11 -0
  75. data/lib/cloudflare/email/management/adapter.rb +31 -0
  76. data/lib/cloudflare/email/management/configuration.rb +17 -0
  77. data/lib/cloudflare/email/management/engine.rb +15 -0
  78. data/lib/cloudflare/email/management/management.css +68 -0
  79. data/lib/cloudflare/email/management/routes.rb +14 -0
  80. data/lib/cloudflare/email/management.rb +7 -0
  81. data/lib/cloudflare/email/message_id.rb +12 -0
  82. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  83. data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
  84. data/lib/cloudflare/email/provision_route_task.rb +2 -1
  85. data/lib/cloudflare/email/replay_events_job.rb +18 -0
  86. data/lib/cloudflare/email/response.rb +21 -3
  87. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  88. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  89. data/lib/cloudflare/email/routing_provisioner.rb +71 -45
  90. data/lib/cloudflare/email/send_job.rb +41 -0
  91. data/lib/cloudflare/email/send_test.rb +5 -29
  92. data/lib/cloudflare/email/signing.rb +2 -2
  93. data/lib/cloudflare/email/tenancy.rb +78 -0
  94. data/lib/cloudflare/email/tenant_job_context.rb +90 -0
  95. data/lib/cloudflare/email/verification.rb +38 -8
  96. data/lib/cloudflare/email/version.rb +1 -1
  97. data/lib/cloudflare/email/worker_deployer.rb +41 -8
  98. data/lib/cloudflare-email.rb +4 -1
  99. data/lib/generators/cloudflare/email/install_generator.rb +53 -37
  100. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  101. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  102. data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
  103. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +58 -0
  104. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
  105. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
  106. data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
  107. data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
  108. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  109. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  110. data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
  111. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
  112. data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
  113. data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
  114. data/lib/tasks/cloudflare_email.rake +42 -4
  115. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  116. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  117. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  118. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  119. data/templates/deploy-to-cloudflare/README.md +84 -0
  120. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  121. data/templates/deploy-to-cloudflare/package.json +36 -0
  122. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  123. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  124. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  125. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  126. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  127. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  128. data/templates/worker/README.md +231 -16
  129. data/templates/worker/docs/durable-inbound.md +149 -0
  130. data/templates/worker/package-lock.json +2627 -0
  131. data/templates/worker/package.json +8 -5
  132. data/templates/worker/scripts/wrangler.mjs +16 -0
  133. data/templates/worker/src/index.js +397 -25
  134. data/templates/worker/test/durable.test.ts +193 -0
  135. data/templates/worker/test/index.test.ts +183 -13
  136. data/templates/worker/test/relay.test.ts +226 -0
  137. data/templates/worker/wrangler.toml +63 -4
  138. metadata +154 -7
  139. data/lib/cloudflare/email/secure_message_id.rb +0 -89
@@ -6,7 +6,7 @@ module Cloudflare
6
6
  module Email
7
7
  # `bin/rails cloudflare:email:provision_catchall` — points a zone's
8
8
  # catch-all rule at the env-scoped ingress Worker. Useful for bounce
9
- # handling, dev subdomains, and alias routing.
9
+ # handling and alias routing. Applies to the entire Cloudflare zone.
10
10
  class ProvisionCatchallTask < TaskBase
11
11
  def self.call(domain:, worker_name: nil, io: $stdout)
12
12
  new(io: io, domain: domain, worker_name: worker_name).call
@@ -16,11 +16,11 @@ module Cloudflare
16
16
 
17
17
  def run
18
18
  require_value!(management_token, "cloudflare.api_token or cloudflare.management_token")
19
- require_value!(opts[:domain], "DOMAIN=in.example.com")
19
+ require_value!(opts[:domain], "DOMAIN=example.com (the Cloudflare zone name)")
20
20
 
21
21
  worker = opts[:worker_name] || Cloudflare::Email::WorkerDeployer.default_script_name
22
22
 
23
- say "Provisioning catch-all:"
23
+ say "Provisioning zone-wide catch-all:"
24
24
  say " Domain: #{opts[:domain]}"
25
25
  say " Worker: #{worker}"
26
26
  say ""
@@ -29,7 +29,7 @@ module Cloudflare
29
29
  provisioner.provision_catch_all_for_domain(domain: opts[:domain], worker_name: worker)
30
30
 
31
31
  say " ✓ Catch-all on #{opts[:domain]} now points at #{worker}."
32
- say " All unrouted addresses on this domain will hit your Worker."
32
+ say " All unrouted addresses covered by this zone's catch-all will hit your Worker."
33
33
  end
34
34
  end
35
35
  end
@@ -27,7 +27,8 @@ module Cloudflare
27
27
  provisioner = Cloudflare::Email::RoutingProvisioner.new(api_token: management_token)
28
28
  provisioner.provision(address: opts[:address], worker_name: worker)
29
29
 
30
- say " ✓ Route created/updated — mail to #{opts[:address]} will hit Worker #{worker}."
30
+ say " ✓ Route created/updated for #{opts[:address]} → Worker #{worker}."
31
+ say " Verify DNS propagation and send a test message to confirm delivery."
31
32
  end
32
33
  end
33
34
  end
@@ -0,0 +1,18 @@
1
+ require "active_job"
2
+ require "cloudflare/email/active_record"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ class ReplayEventsJob < ::ActiveJob::Base
7
+ queue_as :mailers
8
+
9
+ def perform(account_id, message_id = nil)
10
+ handler = if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
11
+ Rails.application.config.x.cloudflare_email.outbox_recipient_handler
12
+ end
13
+ ActiveRecord::DeliveryEvents.replay(account_id: account_id, message_id: message_id,
14
+ &(handler.method(:call) if handler.respond_to?(:call)))
15
+ end
16
+ end
17
+ end
18
+ end
@@ -1,3 +1,5 @@
1
+ require "cloudflare/email/message_id"
2
+
1
3
  module Cloudflare
2
4
  module Email
3
5
  class Response
@@ -9,18 +11,30 @@ module Cloudflare
9
11
  end
10
12
 
11
13
  def success?
12
- return !!@raw["success"] if @raw.key?("success") && !@raw["success"].nil?
13
- @status >= 200 && @status < 300
14
+ return false unless @status.is_a?(Integer) && @status >= 200 && @status < 300
15
+ return @raw["success"] == true if @raw.key?("success")
16
+ true
14
17
  end
15
18
 
16
19
  def result
17
20
  @raw["result"].is_a?(Hash) ? @raw["result"] : {}
18
21
  end
19
22
 
23
+ # Provider acceptance is not final delivery. A partial acceptance is true;
24
+ # inspect recipient outcomes before retrying any rejected recipients.
25
+ def accepted?
26
+ return false if %w[delivered queued permanent_bounces suppressed_recipients].any? { |key| result.key?(key) && !result[key].is_a?(Array) }
27
+ success? && (delivered.any? || queued.any? ||
28
+ (!message_id.to_s.strip.empty? && permanent_bounces.empty? && suppressed_recipients.empty?))
29
+ end
30
+
20
31
  def message_id
21
- result["message_id"] ||
32
+ value = result["message_id"] ||
22
33
  dig_message_id(result["delivered"]) ||
23
34
  dig_message_id(result["queued"])
35
+ return nil unless value.is_a?(String)
36
+ normalized = MessageId.normalize(value)
37
+ value unless normalized.empty? || normalized.match?(/[\s<>[:cntrl:]]/)
24
38
  end
25
39
 
26
40
  def delivered
@@ -35,6 +49,10 @@ module Cloudflare
35
49
  Array(result["permanent_bounces"])
36
50
  end
37
51
 
52
+ def suppressed_recipients
53
+ Array(result["suppressed_recipients"])
54
+ end
55
+
38
56
  def errors
39
57
  Array(@raw["errors"])
40
58
  end
@@ -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