cloudflare-email 0.1.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 (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +87 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +521 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +67 -0
  6. data/lib/cloudflare/email/client.rb +216 -0
  7. data/lib/cloudflare/email/credentials.rb +59 -0
  8. data/lib/cloudflare/email/delivery_method.rb +57 -0
  9. data/lib/cloudflare/email/deploy_worker_task.rb +62 -0
  10. data/lib/cloudflare/email/dev_tunnel.rb +97 -0
  11. data/lib/cloudflare/email/doctor.rb +244 -0
  12. data/lib/cloudflare/email/engine.rb +25 -0
  13. data/lib/cloudflare/email/error.rb +20 -0
  14. data/lib/cloudflare/email/provision_catchall_task.rb +36 -0
  15. data/lib/cloudflare/email/provision_route_task.rb +34 -0
  16. data/lib/cloudflare/email/response.rb +61 -0
  17. data/lib/cloudflare/email/routing_provisioner.rb +220 -0
  18. data/lib/cloudflare/email/secure_message_id.rb +89 -0
  19. data/lib/cloudflare/email/send_test.rb +75 -0
  20. data/lib/cloudflare/email/signing.rb +37 -0
  21. data/lib/cloudflare/email/task_base.rb +61 -0
  22. data/lib/cloudflare/email/verification.rb +44 -0
  23. data/lib/cloudflare/email/version.rb +5 -0
  24. data/lib/cloudflare/email/worker_deployer.rb +183 -0
  25. data/lib/cloudflare-email.rb +9 -0
  26. data/lib/generators/cloudflare/email/install_generator.rb +227 -0
  27. data/lib/generators/cloudflare/email/templates/initializer.rb +20 -0
  28. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +32 -0
  29. data/lib/tasks/cloudflare_email.rake +45 -0
  30. data/templates/worker/README.md +37 -0
  31. data/templates/worker/package.json +15 -0
  32. data/templates/worker/src/index.js +73 -0
  33. data/templates/worker/test/index.test.ts +139 -0
  34. data/templates/worker/vitest.config.ts +8 -0
  35. data/templates/worker/wrangler.toml +10 -0
  36. metadata +155 -0
@@ -0,0 +1,244 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ # Diagnostic runner for `bin/rails cloudflare:email:doctor`.
7
+ # Verifies each layer of configuration independently so a new user can
8
+ # see exactly which piece is misconfigured.
9
+ class Doctor
10
+ OK = :ok
11
+ WARN = :warn
12
+ FAIL = :fail
13
+ SKIP = :skip
14
+
15
+ def self.call(io: $stdout)
16
+ new(io: io).call
17
+ end
18
+
19
+ def initialize(io: $stdout)
20
+ @io = io
21
+ @results = []
22
+ end
23
+
24
+ def call
25
+ header
26
+ check_rails_loaded
27
+ check_credentials
28
+ check_api_token
29
+ check_account_access
30
+ check_sending_domains
31
+ check_ingress_secret
32
+ check_token_split
33
+ check_action_mailbox_ingress
34
+ check_delivery_method_registered
35
+ summary
36
+ @results.any? { |r| r[:status] == FAIL } ? 1 : 0
37
+ end
38
+
39
+ private
40
+
41
+ def header
42
+ @io.puts "cloudflare-email doctor — v#{Cloudflare::Email::VERSION}"
43
+ @io.puts ""
44
+ end
45
+
46
+ def check_rails_loaded
47
+ if defined?(Rails) && Rails.application
48
+ record("Rails app", OK, "#{Rails.application.class.name} (#{Rails.env})")
49
+ else
50
+ record("Rails app", FAIL, "Rails.application not loaded — run via bin/rails")
51
+ end
52
+ end
53
+
54
+ def check_credentials
55
+ account_id = credential(:account_id)
56
+ api_token = credential(:api_token)
57
+
58
+ if account_id.to_s.empty?
59
+ record("credentials.cloudflare.account_id", FAIL, "missing — run bin/rails credentials:edit")
60
+ else
61
+ record("credentials.cloudflare.account_id", OK, account_id)
62
+ end
63
+
64
+ if api_token.to_s.empty?
65
+ record("credentials.cloudflare.api_token", FAIL, "missing — run bin/rails credentials:edit")
66
+ else
67
+ record("credentials.cloudflare.api_token", OK, "#{api_token[0, 8]}...")
68
+ end
69
+ end
70
+
71
+ def check_api_token
72
+ token = credential(:api_token)
73
+ return record("API token valid", SKIP, "no token to test") if token.to_s.empty?
74
+
75
+ response = request("GET", "/user/tokens/verify", token: token)
76
+ if response[:ok] && response[:body].dig("result", "status") == "active"
77
+ record("API token valid", OK, "active (id: #{response[:body].dig('result', 'id')&.slice(0, 8)}...)")
78
+ else
79
+ record("API token valid", FAIL, extract_error(response))
80
+ end
81
+ end
82
+
83
+ def check_account_access
84
+ token = credential(:api_token)
85
+ account_id = credential(:account_id)
86
+ return record("Account accessible", SKIP, "missing token or account_id") if token.to_s.empty? || account_id.to_s.empty?
87
+
88
+ response = request("GET", "/accounts/#{account_id}", token: token)
89
+ if response[:ok]
90
+ record("Account accessible", OK, response[:body].dig("result", "name") || account_id)
91
+ elsif response[:status] == 403
92
+ # Narrowly-scoped send-only tokens don't have account read permission;
93
+ # that's a feature, not a bug. We already confirmed the token is valid.
94
+ record("Account accessible", OK, "send-scoped token (no account read — this is fine)")
95
+ else
96
+ record("Account accessible", FAIL, extract_error(response))
97
+ end
98
+ end
99
+
100
+ def check_sending_domains
101
+ token = credential(:api_token)
102
+ account_id = credential(:account_id)
103
+ return record("Sending domains", SKIP, "missing credentials") if token.to_s.empty? || account_id.to_s.empty?
104
+
105
+ response = request("GET", "/accounts/#{account_id}/email/sending/domains", token: token)
106
+ if response[:status] == 403 || response[:status] == 404
107
+ record("Sending domains", SKIP, "send-scoped token can't list domains (check the dashboard instead)")
108
+ return
109
+ end
110
+
111
+ unless response[:ok]
112
+ record("Sending domains", WARN, "could not list: #{extract_error(response)}")
113
+ return
114
+ end
115
+
116
+ domains = Array(response[:body]["result"])
117
+ if domains.empty?
118
+ record("Sending domains", WARN, "no sending domains set up — add one in the dashboard")
119
+ return
120
+ end
121
+
122
+ verified = domains.select { |d| d["verified"] == true || d["status"] == "verified" }
123
+ if verified.any?
124
+ names = verified.map { |d| d["name"] || d["domain"] }.compact.join(", ")
125
+ record("Sending domains", OK, "#{verified.size} verified (#{names})")
126
+ else
127
+ names = domains.map { |d| d["name"] || d["domain"] }.compact.join(", ")
128
+ record("Sending domains", WARN, "#{domains.size} configured but none verified (#{names})")
129
+ end
130
+ end
131
+
132
+ def check_ingress_secret
133
+ require "cloudflare/email/credentials"
134
+ secret = Cloudflare::Email::Credentials.ingress_secret
135
+
136
+ if secret.empty?
137
+ record("Ingress secret", WARN, "not set — inbound will 401 until you configure it")
138
+ elsif secret.length < 32
139
+ record("Ingress secret", WARN, "set but shorter than 32 chars — rotate to a strong random value")
140
+ else
141
+ record("Ingress secret", OK, "set (#{secret.length} chars)")
142
+ end
143
+ end
144
+
145
+ def check_token_split
146
+ require "cloudflare/email/credentials"
147
+ if Cloudflare::Email::Credentials.split_tokens?
148
+ record("Token split", OK, "separate management_token set (good security posture)")
149
+ else
150
+ record("Token split", WARN,
151
+ "single api_token used for runtime + management — consider splitting via CLOUDFLARE_MANAGEMENT_TOKEN " \
152
+ "(see README 'Tokens')")
153
+ end
154
+ end
155
+
156
+ def check_action_mailbox_ingress
157
+ return record("ActionMailbox ingress", SKIP, "ActionMailbox not loaded") unless defined?(ActionMailbox)
158
+
159
+ case ActionMailbox.ingress
160
+ when :cloudflare
161
+ record("ActionMailbox ingress", OK, ":cloudflare")
162
+ when nil
163
+ record("ActionMailbox ingress", WARN, "nil — inbound will 404. Set config.action_mailbox.ingress = :cloudflare")
164
+ else
165
+ record("ActionMailbox ingress", WARN, "#{ActionMailbox.ingress.inspect} — not :cloudflare, our controller will 404")
166
+ end
167
+ end
168
+
169
+ def check_delivery_method_registered
170
+ return record("Delivery method :cloudflare", SKIP, "ActionMailer not loaded") unless defined?(ActionMailer)
171
+
172
+ if ActionMailer::Base.delivery_methods[:cloudflare]
173
+ record("Delivery method :cloudflare", OK, "registered")
174
+ else
175
+ record("Delivery method :cloudflare", FAIL, "not registered — engine failed to load")
176
+ end
177
+ end
178
+
179
+ def summary
180
+ @io.puts ""
181
+ width = @results.map { |r| r[:name].length }.max
182
+ @results.each do |r|
183
+ @io.puts " #{icon(r[:status])} #{r[:name].ljust(width)} #{r[:detail]}"
184
+ end
185
+ @io.puts ""
186
+
187
+ fail_count = @results.count { |r| r[:status] == FAIL }
188
+ warn_count = @results.count { |r| r[:status] == WARN }
189
+
190
+ if fail_count.zero? && warn_count.zero?
191
+ @io.puts " Everything looks good."
192
+ elsif fail_count.zero?
193
+ @io.puts " #{warn_count} warning(s). Setup is usable but incomplete."
194
+ else
195
+ @io.puts " #{fail_count} failure(s), #{warn_count} warning(s). Fix failures before sending."
196
+ end
197
+ @io.puts ""
198
+ end
199
+
200
+ def record(name, status, detail)
201
+ @results << { name: name, status: status, detail: detail }
202
+ end
203
+
204
+ def icon(status)
205
+ case status
206
+ when OK then "[ok] "
207
+ when WARN then "[warn]"
208
+ when FAIL then "[fail]"
209
+ when SKIP then "[skip]"
210
+ end
211
+ end
212
+
213
+ def credential(key)
214
+ require "cloudflare/email/credentials"
215
+ Cloudflare::Email::Credentials.fetch(key)
216
+ end
217
+
218
+ def request(method, path, token:)
219
+ uri = URI.parse("https://api.cloudflare.com/client/v4#{path}")
220
+ http = Net::HTTP.new(uri.host, uri.port)
221
+ http.use_ssl = true
222
+ http.open_timeout = 10
223
+ http.read_timeout = 10
224
+
225
+ req = (method == "GET" ? Net::HTTP::Get : Net::HTTP::Post).new(uri.request_uri)
226
+ req["Authorization"] = "Bearer #{token}"
227
+ req["Content-Type"] = "application/json"
228
+
229
+ response = http.request(req)
230
+ body = JSON.parse(response.body) rescue {}
231
+ { ok: response.code.to_i.between?(200, 299), status: response.code.to_i, body: body }
232
+ rescue StandardError => e
233
+ { ok: false, status: 0, body: { "errors" => [{ "message" => e.message }] } }
234
+ end
235
+
236
+ def extract_error(response)
237
+ errors = response[:body].is_a?(Hash) ? response[:body]["errors"] : nil
238
+ return "HTTP #{response[:status]}" unless errors.is_a?(Array) && errors.any?
239
+ msg = errors.map { |e| e.is_a?(Hash) ? e["message"] : e.to_s }.compact.join("; ")
240
+ "HTTP #{response[:status]} — #{msg}"
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,25 @@
1
+ require "rails/engine"
2
+
3
+ # Register the delivery method at engine load time (not inside an initializer)
4
+ # so the `cloudflare_settings=` accessor exists before Rails' own
5
+ # "action_mailer.set_configs" initializer applies user config.
6
+ ActiveSupport.on_load(:action_mailer) do
7
+ require "cloudflare/email/delivery_method"
8
+ add_delivery_method :cloudflare, Cloudflare::Email::DeliveryMethod
9
+ end
10
+
11
+ module Cloudflare
12
+ module Email
13
+ class Engine < ::Rails::Engine
14
+ isolate_namespace Cloudflare::Email
15
+
16
+ initializer "cloudflare-email.routes" do |app|
17
+ app.routes.append do
18
+ post "/rails/action_mailbox/cloudflare/inbound_emails",
19
+ to: "cloudflare/email/ingress#create",
20
+ as: :rails_cloudflare_inbound_emails
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ module Cloudflare
2
+ module Email
3
+ class Error < StandardError
4
+ attr_reader :response, :status
5
+
6
+ def initialize(message = nil, status: nil, response: nil)
7
+ super(message)
8
+ @status = status
9
+ @response = response
10
+ end
11
+ end
12
+
13
+ class ConfigurationError < Error; end
14
+ class AuthenticationError < Error; end
15
+ class ValidationError < Error; end
16
+ class RateLimitError < Error; end
17
+ class ServerError < Error; end
18
+ class NetworkError < Error; end
19
+ end
20
+ end
@@ -0,0 +1,36 @@
1
+ require "cloudflare/email/task_base"
2
+ require "cloudflare/email/routing_provisioner"
3
+ require "cloudflare/email/worker_deployer"
4
+
5
+ module Cloudflare
6
+ module Email
7
+ # `bin/rails cloudflare:email:provision_catchall` — points a zone's
8
+ # catch-all rule at the env-scoped ingress Worker. Useful for bounce
9
+ # handling, dev subdomains, and alias routing.
10
+ class ProvisionCatchallTask < TaskBase
11
+ def self.call(domain:, worker_name: nil, io: $stdout)
12
+ new(io: io, domain: domain, worker_name: worker_name).call
13
+ end
14
+
15
+ protected
16
+
17
+ def run
18
+ require_value!(management_token, "cloudflare.api_token or cloudflare.management_token")
19
+ require_value!(opts[:domain], "DOMAIN=in.example.com")
20
+
21
+ worker = opts[:worker_name] || Cloudflare::Email::WorkerDeployer.default_script_name
22
+
23
+ say "Provisioning catch-all:"
24
+ say " Domain: #{opts[:domain]}"
25
+ say " Worker: #{worker}"
26
+ say ""
27
+
28
+ provisioner = Cloudflare::Email::RoutingProvisioner.new(api_token: management_token)
29
+ provisioner.provision_catch_all_for_domain(domain: opts[:domain], worker_name: worker)
30
+
31
+ say " ✓ Catch-all on #{opts[:domain]} now points at #{worker}."
32
+ say " All unrouted addresses on this domain will hit your Worker."
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,34 @@
1
+ require "cloudflare/email/task_base"
2
+ require "cloudflare/email/routing_provisioner"
3
+ require "cloudflare/email/worker_deployer"
4
+
5
+ module Cloudflare
6
+ module Email
7
+ # `bin/rails cloudflare:email:provision_route` — creates a Cloudflare
8
+ # Email Routing rule binding ADDRESS to the env-scoped ingress Worker.
9
+ class ProvisionRouteTask < TaskBase
10
+ def self.call(address:, worker_name: nil, io: $stdout)
11
+ new(io: io, address: address, worker_name: worker_name).call
12
+ end
13
+
14
+ protected
15
+
16
+ def run
17
+ require_value!(management_token, "cloudflare.api_token or cloudflare.management_token")
18
+ require_value!(opts[:address], "ADDRESS=address@domain")
19
+
20
+ worker = opts[:worker_name] || Cloudflare::Email::WorkerDeployer.default_script_name
21
+
22
+ say "Provisioning Email Routing:"
23
+ say " Address: #{opts[:address]}"
24
+ say " Worker: #{worker}"
25
+ say ""
26
+
27
+ provisioner = Cloudflare::Email::RoutingProvisioner.new(api_token: management_token)
28
+ provisioner.provision(address: opts[:address], worker_name: worker)
29
+
30
+ say " ✓ Route created/updated — mail to #{opts[:address]} will hit Worker #{worker}."
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,61 @@
1
+ module Cloudflare
2
+ module Email
3
+ class Response
4
+ attr_reader :raw, :status
5
+
6
+ def initialize(raw, status: 200)
7
+ @raw = raw.is_a?(Hash) ? raw : {}
8
+ @status = status
9
+ end
10
+
11
+ def success?
12
+ return !!@raw["success"] if @raw.key?("success") && !@raw["success"].nil?
13
+ @status >= 200 && @status < 300
14
+ end
15
+
16
+ def result
17
+ @raw["result"].is_a?(Hash) ? @raw["result"] : {}
18
+ end
19
+
20
+ def message_id
21
+ result["message_id"] ||
22
+ dig_message_id(result["delivered"]) ||
23
+ dig_message_id(result["queued"])
24
+ end
25
+
26
+ def delivered
27
+ Array(result["delivered"])
28
+ end
29
+
30
+ def queued
31
+ Array(result["queued"])
32
+ end
33
+
34
+ def permanent_bounces
35
+ Array(result["permanent_bounces"])
36
+ end
37
+
38
+ def errors
39
+ Array(@raw["errors"])
40
+ end
41
+
42
+ def messages
43
+ Array(@raw["messages"])
44
+ end
45
+
46
+ def to_h
47
+ @raw
48
+ end
49
+
50
+ private
51
+
52
+ # The API may return delivered/queued as an array of strings (email
53
+ # addresses) or an array of hashes ({message_id:, to:}). Handle both.
54
+ def dig_message_id(arr)
55
+ return nil unless arr.is_a?(Array)
56
+ first = arr.first
57
+ first.is_a?(Hash) ? first["message_id"] : nil
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,220 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module Cloudflare
6
+ module Email
7
+ # Provision Cloudflare Email Routing rules via API — no dashboard clicks.
8
+ #
9
+ # Looks up the zone ID for a domain, enables Email Routing on the zone
10
+ # (publishing the MX + SPF records Cloudflare needs), and creates/updates
11
+ # a rule sending mail for a specific address to a given Worker.
12
+ #
13
+ # Required API token scopes:
14
+ # Zone → Zone → Read (to look up zone by name)
15
+ # Zone → Email Routing → Edit (to enable routing and add rules)
16
+ #
17
+ # Usage:
18
+ # provisioner = Cloudflare::Email::RoutingProvisioner.new(
19
+ # api_token: ENV["CLOUDFLARE_API_TOKEN"],
20
+ # )
21
+ # provisioner.provision(
22
+ # address: "cole@in.example.com",
23
+ # worker_name: "cloudflare-email-ingress-production",
24
+ # )
25
+ class RoutingProvisioner
26
+ API_BASE = "https://api.cloudflare.com/client/v4".freeze
27
+
28
+ def initialize(api_token:, api_base: API_BASE)
29
+ raise ArgumentError, "api_token is required" if api_token.to_s.empty?
30
+ @api_token = api_token
31
+ @api_base = api_base
32
+ end
33
+
34
+ # High-level: given an address + Worker name, do everything needed to
35
+ # make that address route to that Worker. Idempotent — running twice
36
+ # is safe and will update the existing rule rather than duplicate it.
37
+ def provision(address:, worker_name:)
38
+ domain = extract_domain(address)
39
+ zone_id = find_zone_id_for(domain)
40
+ raise Error.new("No Cloudflare zone found for #{domain} — add the domain to your account first") unless zone_id
41
+
42
+ enable_routing_if_needed(zone_id)
43
+ upsert_route(zone_id: zone_id, address: address, worker_name: worker_name)
44
+ end
45
+
46
+ def find_zone_id_for(domain)
47
+ # Try the exact domain, then walk up parent domains until we find a
48
+ # Cloudflare zone. Supports subdomains like "in.example.com" routing
49
+ # to the "example.com" zone.
50
+ candidates = expand_parent_domains(domain)
51
+
52
+ candidates.each do |candidate|
53
+ result = api_request(:get, "/zones?name=#{URI.encode_www_form_component(candidate)}")
54
+ zones = Array(result["result"])
55
+ return zones.first["id"] if zones.any?
56
+ end
57
+
58
+ nil
59
+ end
60
+
61
+ def enable_routing_if_needed(zone_id)
62
+ # This endpoint requires the "Email Routing Settings" permission group,
63
+ # which most scoped tokens don't carry. If we can read the setting,
64
+ # enable when off. If we can't (403), assume the user enabled routing
65
+ # via the dashboard when they added the subdomain — the subsequent
66
+ # rule create will fail with a clear error if not.
67
+ current = raw_api_request(:get, "/zones/#{zone_id}/email/routing")
68
+ status = current.code.to_i
69
+
70
+ case status
71
+ when 200
72
+ body = parse(current.body)
73
+ enabled = body.dig("result", "enabled")
74
+ api_request(:post, "/zones/#{zone_id}/email/routing/enable") unless enabled
75
+ when 403, 404
76
+ # Either the token can't read settings or routing isn't set up.
77
+ # Try to enable optimistically; ignore failure (rule create will
78
+ # surface a precise error if routing is actually off).
79
+ attempt = raw_api_request(:post, "/zones/#{zone_id}/email/routing/enable")
80
+ # Don't fail here even if this also 403s — move on to rule creation.
81
+ else
82
+ handle!(current, "GET /zones/#{zone_id}/email/routing")
83
+ end
84
+ end
85
+
86
+ def upsert_route(zone_id:, address:, worker_name:)
87
+ existing = find_rule_for(zone_id: zone_id, address: address)
88
+
89
+ rule = {
90
+ name: "cloudflare-email gem — #{address}",
91
+ enabled: true,
92
+ priority: 0,
93
+ matchers: [{ field: "to", type: "literal", value: address }],
94
+ actions: [{ type: "worker", value: [worker_name] }],
95
+ }
96
+
97
+ if existing
98
+ api_request(
99
+ :put,
100
+ "/zones/#{zone_id}/email/routing/rules/#{existing['id']}",
101
+ body: rule,
102
+ )
103
+ else
104
+ api_request(
105
+ :post,
106
+ "/zones/#{zone_id}/email/routing/rules",
107
+ body: rule,
108
+ )
109
+ end
110
+ end
111
+
112
+ def find_rule_for(zone_id:, address:)
113
+ result = api_request(:get, "/zones/#{zone_id}/email/routing/rules?per_page=50")
114
+ rules = Array(result["result"])
115
+
116
+ rules.find do |r|
117
+ matchers = Array(r["matchers"])
118
+ matchers.any? { |m| m["field"] == "to" && m["value"] == address }
119
+ end
120
+ end
121
+
122
+ def list_rules(zone_id)
123
+ result = api_request(:get, "/zones/#{zone_id}/email/routing/rules?per_page=50")
124
+ Array(result["result"])
125
+ end
126
+
127
+ # Point the zone's catch-all rule at our Worker. Catch-all matches any
128
+ # address on the zone that isn't covered by a more specific rule.
129
+ # Useful for bounce handling, dev subdomains, alias routing.
130
+ def provision_catch_all(zone_id:, worker_name:)
131
+ api_request(
132
+ :put,
133
+ "/zones/#{zone_id}/email/routing/rules/catch_all",
134
+ body: {
135
+ name: "cloudflare-email gem — catch-all",
136
+ enabled: true,
137
+ matchers: [{ type: "all" }],
138
+ actions: [{ type: "worker", value: [worker_name] }],
139
+ },
140
+ )
141
+ end
142
+
143
+ def provision_catch_all_for_domain(domain:, worker_name:)
144
+ zone_id = find_zone_id_for(domain)
145
+ raise Error.new("No Cloudflare zone found for #{domain}") unless zone_id
146
+
147
+ enable_routing_if_needed(zone_id)
148
+ provision_catch_all(zone_id: zone_id, worker_name: worker_name)
149
+ end
150
+
151
+ private
152
+
153
+ def extract_domain(address)
154
+ if address.include?("@")
155
+ address.split("@", 2).last
156
+ else
157
+ address
158
+ end
159
+ end
160
+
161
+ # "a.b.c.example.com" → ["a.b.c.example.com", "b.c.example.com", "c.example.com", "example.com"]
162
+ def expand_parent_domains(domain)
163
+ parts = domain.split(".")
164
+ return [domain] if parts.size < 2
165
+ (0..(parts.size - 2)).map { |i| parts[i..].join(".") }
166
+ end
167
+
168
+ def api_request(method, path, body: nil)
169
+ response = raw_api_request(method, path, body: body)
170
+ handle!(response, "#{method.upcase} #{path}")
171
+ parse(response.body)
172
+ end
173
+
174
+ def raw_api_request(method, path, body: nil)
175
+ uri = URI.parse("#{@api_base}#{path}")
176
+ http = Net::HTTP.new(uri.host, uri.port)
177
+ http.use_ssl = uri.scheme == "https"
178
+ http.open_timeout = 30
179
+ http.read_timeout = 60
180
+
181
+ klass = {
182
+ get: Net::HTTP::Get,
183
+ post: Net::HTTP::Post,
184
+ put: Net::HTTP::Put,
185
+ delete: Net::HTTP::Delete,
186
+ }.fetch(method)
187
+
188
+ req = klass.new(uri.request_uri)
189
+ req["Authorization"] = "Bearer #{@api_token}"
190
+ req["Accept"] = "application/json"
191
+ req["Content-Type"] = "application/json" if body
192
+ req.body = JSON.generate(body) if body
193
+
194
+ http.request(req)
195
+ end
196
+
197
+ def handle!(response, context)
198
+ status = response.code.to_i
199
+ return if status.between?(200, 299)
200
+
201
+ body = parse(response.body)
202
+ errors = body.is_a?(Hash) ? Array(body["errors"]) : []
203
+ message = errors.map { |e| e.is_a?(Hash) ? e["message"] : e.to_s }.compact.join("; ")
204
+ message = "HTTP #{status}" if message.empty?
205
+
206
+ raise Error.new(
207
+ "[routing_provisioner] #{context} failed: #{message}",
208
+ status: status, response: body,
209
+ )
210
+ end
211
+
212
+ def parse(body)
213
+ return {} if body.nil? || body.empty?
214
+ JSON.parse(body)
215
+ rescue JSON::ParserError
216
+ { "errors" => [{ "message" => body.to_s[0, 200] }] }
217
+ end
218
+ end
219
+ end
220
+ end