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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +109 -0
- data/README.md +44 -9
- data/app/controllers/cloudflare/email/ingress_controller.rb +15 -60
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +9 -0
- data/app/controllers/cloudflare/email/management/styles_controller.rb +9 -0
- data/docs/activerecord-tenanted.md +46 -0
- data/docs/architecture.md +13 -3
- data/docs/custom-ingress.md +211 -0
- data/docs/delivery-events.md +9 -0
- data/docs/features.md +36 -5
- data/docs/getting-started.md +19 -8
- data/docs/mailboxes.md +74 -3
- data/docs/management-engine.md +190 -0
- data/docs/routing-deliveries.md +94 -0
- data/docs/routing-diagnostics.md +75 -0
- data/docs/troubleshooting.md +5 -4
- data/docs/upgrading-0.2.md +5 -0
- data/docs/upgrading-0.3.md +55 -0
- data/docs/verification/2026-09-11-custom-ingress.md +49 -0
- data/docs/verification/2026-09-12-destination-context.md +46 -0
- data/docs/verification/2026-09-13-action-mailbox-core.md +96 -0
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
- data/lib/cloudflare/email/active_record/base.rb +3 -41
- data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
- data/lib/cloudflare/email/check_route_task.rb +21 -0
- data/lib/cloudflare/email/client.rb +45 -10
- data/lib/cloudflare/email/delivery_method.rb +2 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
- data/lib/cloudflare/email/engine.rb +6 -13
- data/lib/cloudflare/email/envelope.rb +4 -14
- data/lib/cloudflare/email/error.rb +5 -12
- data/lib/cloudflare/email/event_consumer.rb +12 -3
- data/lib/cloudflare/email/ingress.rb +126 -0
- data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
- data/lib/cloudflare/email/mailboxes/models.rb +8 -130
- data/lib/cloudflare/email/mailboxes/service.rb +7 -174
- data/lib/cloudflare/email/mailboxes.rb +1 -0
- data/lib/cloudflare/email/management/adapter.rb +9 -0
- data/lib/cloudflare/email/management/configuration.rb +10 -0
- data/lib/cloudflare/email/management/engine.rb +17 -0
- data/lib/cloudflare/email/management/routes.rb +14 -0
- data/lib/cloudflare/email/management.rb +7 -0
- data/lib/cloudflare/email/provider_metadata.rb +77 -0
- data/lib/cloudflare/email/routing_analytics.rb +196 -0
- data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
- data/lib/cloudflare/email/tenancy.rb +2 -73
- data/lib/cloudflare/email/tenant_job_context.rb +2 -85
- data/lib/cloudflare/email/verification.rb +17 -9
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +36 -5
- data/lib/cloudflare-email.rb +6 -0
- data/lib/generators/cloudflare/email/install_generator.rb +6 -5
- data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
- data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +5 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
- data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
- data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
- data/lib/tasks/cloudflare_email.rake +2 -1
- data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
- data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
- data/templates/deploy-to-cloudflare/.gitignore +6 -0
- data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
- data/templates/deploy-to-cloudflare/README.md +104 -0
- data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
- data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
- data/templates/deploy-to-cloudflare/package.json +37 -0
- data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
- data/templates/deploy-to-cloudflare/src/index.js +445 -0
- data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
- data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
- data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
- data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
- data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
- data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
- data/templates/worker/README.md +186 -15
- data/templates/worker/docs/domain-setup.md +198 -0
- data/templates/worker/docs/durable-inbound.md +149 -0
- data/templates/worker/package-lock.json +2 -2
- data/templates/worker/package.json +2 -1
- data/templates/worker/scripts/check-subdomains.mjs +54 -0
- data/templates/worker/src/index.js +331 -38
- data/templates/worker/test/durable.test.ts +193 -0
- data/templates/worker/test/index.test.ts +78 -20
- data/templates/worker/test/relay.test.ts +226 -0
- data/templates/worker/test/subdomains.test.ts +51 -0
- data/templates/worker/wrangler.toml +50 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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:
|
|
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
|
|
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, "
|
|
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
|