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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +149 -1
- data/README.md +287 -385
- data/SECURITY.md +51 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +33 -21
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
- data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
- data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
- data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
- data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
- data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
- data/docs/activerecord-tenanted.md +181 -0
- data/docs/architecture.md +105 -0
- data/docs/custom-ingress.md +207 -0
- data/docs/delivery-events.md +186 -0
- data/docs/features.md +138 -0
- data/docs/getting-started.md +308 -0
- data/docs/mailboxes.md +363 -0
- data/docs/management-engine.md +190 -0
- data/docs/outbox.md +175 -0
- data/docs/routing-deliveries.md +94 -0
- data/docs/routing-diagnostics.md +68 -0
- data/docs/tenant-mailboxes-plan.md +149 -0
- data/docs/thread-correlation.md +27 -0
- data/docs/troubleshooting.md +104 -0
- data/docs/upgrading-0.2.md +101 -0
- data/docs/upgrading-0.3.md +55 -0
- data/docs/verification/2026-09-10-followup.md +49 -0
- data/docs/verification/2026-09-10-install-upgrade.md +86 -0
- data/docs/verification/2026-09-10-live.md +102 -0
- data/docs/verification/2026-09-10.md +175 -0
- data/docs/verification/2026-09-11-custom-ingress.md +49 -0
- data/docs/verification/2026-09-11-extraction.md +32 -0
- data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
- data/docs/verification/2026-09-11-security.md +76 -0
- data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
- data/docs/verification/2026-09-12-destination-context.md +46 -0
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
- data/examples/plain_ruby.rb +24 -0
- data/lib/cloudflare/email/active_record/base.rb +60 -0
- data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
- data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
- data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
- data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
- data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
- data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
- data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
- data/lib/cloudflare/email/active_record/outbox.rb +219 -0
- data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
- data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
- data/lib/cloudflare/email/active_record.rb +5 -0
- data/lib/cloudflare/email/check_route_task.rb +21 -0
- data/lib/cloudflare/email/client.rb +93 -18
- data/lib/cloudflare/email/consume_events_task.rb +22 -0
- data/lib/cloudflare/email/delivery_event.rb +70 -0
- data/lib/cloudflare/email/delivery_method.rb +5 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +6 -3
- data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
- data/lib/cloudflare/email/dev_tunnel.rb +31 -5
- data/lib/cloudflare/email/doctor.rb +16 -38
- data/lib/cloudflare/email/endpoint.rb +24 -0
- data/lib/cloudflare/email/engine.rb +33 -0
- data/lib/cloudflare/email/envelope.rb +58 -0
- data/lib/cloudflare/email/event_consumer.rb +95 -0
- data/lib/cloudflare/email/ingress.rb +115 -0
- data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
- data/lib/cloudflare/email/mailboxes/events.rb +112 -0
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
- data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
- data/lib/cloudflare/email/mailboxes/models.rb +165 -0
- data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
- data/lib/cloudflare/email/mailboxes/service.rb +372 -0
- data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
- data/lib/cloudflare/email/mailboxes.rb +11 -0
- data/lib/cloudflare/email/management/adapter.rb +31 -0
- data/lib/cloudflare/email/management/configuration.rb +17 -0
- data/lib/cloudflare/email/management/engine.rb +15 -0
- data/lib/cloudflare/email/management/management.css +68 -0
- data/lib/cloudflare/email/management/routes.rb +14 -0
- data/lib/cloudflare/email/management.rb +7 -0
- data/lib/cloudflare/email/message_id.rb +12 -0
- data/lib/cloudflare/email/provider_metadata.rb +77 -0
- data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
- data/lib/cloudflare/email/provision_route_task.rb +2 -1
- data/lib/cloudflare/email/replay_events_job.rb +18 -0
- data/lib/cloudflare/email/response.rb +21 -3
- data/lib/cloudflare/email/routing_analytics.rb +196 -0
- data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
- data/lib/cloudflare/email/routing_provisioner.rb +71 -45
- data/lib/cloudflare/email/send_job.rb +41 -0
- data/lib/cloudflare/email/send_test.rb +5 -29
- data/lib/cloudflare/email/signing.rb +2 -2
- data/lib/cloudflare/email/tenancy.rb +78 -0
- data/lib/cloudflare/email/tenant_job_context.rb +90 -0
- data/lib/cloudflare/email/verification.rb +38 -8
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +41 -8
- data/lib/cloudflare-email.rb +4 -1
- data/lib/generators/cloudflare/email/install_generator.rb +53 -37
- 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/mailboxes_generator.rb +55 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +58 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
- data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
- data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
- 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/generators/cloudflare/email/templates/initializer.rb +3 -3
- data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
- data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
- data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
- data/lib/tasks/cloudflare_email.rake +42 -4
- 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 +84 -0
- data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
- data/templates/deploy-to-cloudflare/package.json +36 -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/vitest.config.ts +8 -0
- data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
- data/templates/worker/README.md +231 -16
- data/templates/worker/docs/durable-inbound.md +149 -0
- data/templates/worker/package-lock.json +2627 -0
- data/templates/worker/package.json +8 -5
- data/templates/worker/scripts/wrangler.mjs +16 -0
- data/templates/worker/src/index.js +397 -25
- data/templates/worker/test/durable.test.ts +193 -0
- data/templates/worker/test/index.test.ts +183 -13
- data/templates/worker/test/relay.test.ts +226 -0
- data/templates/worker/wrangler.toml +63 -4
- metadata +154 -7
- 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
|
|
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=
|
|
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
|
|
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
|
|
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
|
|
13
|
-
@
|
|
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
|