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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +87 -0
- data/LICENSE.txt +21 -0
- data/README.md +521 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +67 -0
- data/lib/cloudflare/email/client.rb +216 -0
- data/lib/cloudflare/email/credentials.rb +59 -0
- data/lib/cloudflare/email/delivery_method.rb +57 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +62 -0
- data/lib/cloudflare/email/dev_tunnel.rb +97 -0
- data/lib/cloudflare/email/doctor.rb +244 -0
- data/lib/cloudflare/email/engine.rb +25 -0
- data/lib/cloudflare/email/error.rb +20 -0
- data/lib/cloudflare/email/provision_catchall_task.rb +36 -0
- data/lib/cloudflare/email/provision_route_task.rb +34 -0
- data/lib/cloudflare/email/response.rb +61 -0
- data/lib/cloudflare/email/routing_provisioner.rb +220 -0
- data/lib/cloudflare/email/secure_message_id.rb +89 -0
- data/lib/cloudflare/email/send_test.rb +75 -0
- data/lib/cloudflare/email/signing.rb +37 -0
- data/lib/cloudflare/email/task_base.rb +61 -0
- data/lib/cloudflare/email/verification.rb +44 -0
- data/lib/cloudflare/email/version.rb +5 -0
- data/lib/cloudflare/email/worker_deployer.rb +183 -0
- data/lib/cloudflare-email.rb +9 -0
- data/lib/generators/cloudflare/email/install_generator.rb +227 -0
- data/lib/generators/cloudflare/email/templates/initializer.rb +20 -0
- data/lib/generators/cloudflare/email/templates/main_mailbox.rb +32 -0
- data/lib/tasks/cloudflare_email.rake +45 -0
- data/templates/worker/README.md +37 -0
- data/templates/worker/package.json +15 -0
- data/templates/worker/src/index.js +73 -0
- data/templates/worker/test/index.test.ts +139 -0
- data/templates/worker/vitest.config.ts +8 -0
- data/templates/worker/wrangler.toml +10 -0
- 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
|