cloudflare-email 0.1.0 → 0.2.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 +73 -1
- data/README.md +265 -388
- data/SECURITY.md +51 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +62 -6
- data/docs/activerecord-tenanted.md +135 -0
- data/docs/architecture.md +95 -0
- data/docs/delivery-events.md +177 -0
- data/docs/features.md +107 -0
- data/docs/getting-started.md +298 -0
- data/docs/mailboxes.md +299 -0
- data/docs/outbox.md +175 -0
- data/docs/tenant-mailboxes-plan.md +149 -0
- data/docs/thread-correlation.md +27 -0
- data/docs/troubleshooting.md +103 -0
- data/docs/upgrading-0.2.md +96 -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-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/examples/plain_ruby.rb +24 -0
- data/lib/cloudflare/email/active_record/base.rb +48 -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.rb +5 -0
- data/lib/cloudflare/email/client.rb +51 -11
- 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 +3 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +3 -0
- 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 +28 -0
- data/lib/cloudflare/email/envelope.rb +58 -0
- data/lib/cloudflare/email/event_consumer.rb +86 -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 +148 -0
- data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
- data/lib/cloudflare/email/mailboxes/service.rb +288 -0
- data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
- data/lib/cloudflare/email/mailboxes.rb +11 -0
- data/lib/cloudflare/email/message_id.rb +12 -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_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 +30 -8
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +5 -3
- data/lib/cloudflare-email.rb +4 -1
- data/lib/generators/cloudflare/email/install_generator.rb +48 -33
- data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +54 -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/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 +41 -4
- data/templates/worker/README.md +61 -11
- 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 +83 -4
- data/templates/worker/test/index.test.ts +117 -5
- data/templates/worker/wrangler.toml +13 -4
- metadata +107 -6
- data/lib/cloudflare/email/secure_message_id.rb +0 -89
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
require "net/http"
|
|
2
2
|
require "json"
|
|
3
3
|
require "uri"
|
|
4
|
+
require "cloudflare/email/endpoint"
|
|
4
5
|
|
|
5
6
|
module Cloudflare
|
|
6
7
|
module Email
|
|
7
|
-
# Provision Cloudflare Email Routing rules via API
|
|
8
|
+
# Provision Cloudflare Email Routing rules via API.
|
|
8
9
|
#
|
|
9
10
|
# Looks up the zone ID for a domain, enables Email Routing on the zone
|
|
10
11
|
# (publishing the MX + SPF records Cloudflare needs), and creates/updates
|
|
@@ -13,6 +14,8 @@ module Cloudflare
|
|
|
13
14
|
# Required API token scopes:
|
|
14
15
|
# Zone → Zone → Read (to look up zone by name)
|
|
15
16
|
# Zone → Email Routing → Edit (to enable routing and add rules)
|
|
17
|
+
# Zone → DNS → Read (to check preconfigured subdomains)
|
|
18
|
+
# Zone → Zone Settings → Edit (to enable apex routing)
|
|
16
19
|
#
|
|
17
20
|
# Usage:
|
|
18
21
|
# provisioner = Cloudflare::Email::RoutingProvisioner.new(
|
|
@@ -28,7 +31,7 @@ module Cloudflare
|
|
|
28
31
|
def initialize(api_token:, api_base: API_BASE)
|
|
29
32
|
raise ArgumentError, "api_token is required" if api_token.to_s.empty?
|
|
30
33
|
@api_token = api_token
|
|
31
|
-
@api_base = api_base
|
|
34
|
+
@api_base = Endpoint.parse(api_base).to_s.delete_suffix("/")
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
# High-level: given an address + Worker name, do everything needed to
|
|
@@ -36,14 +39,22 @@ module Cloudflare
|
|
|
36
39
|
# is safe and will update the existing rule rather than duplicate it.
|
|
37
40
|
def provision(address:, worker_name:)
|
|
38
41
|
domain = extract_domain(address)
|
|
39
|
-
|
|
40
|
-
raise Error.new("No Cloudflare zone found for #{domain} — add the domain to your account first") unless
|
|
42
|
+
zone = find_zone_for(domain)
|
|
43
|
+
raise Error.new("No Cloudflare zone found for #{domain} — add the domain to your account first") unless zone
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
if zone["name"] == domain
|
|
46
|
+
enable_routing_if_needed(zone["id"])
|
|
47
|
+
else
|
|
48
|
+
check_subdomain_dns!(zone_id: zone["id"], domain: domain)
|
|
49
|
+
end
|
|
50
|
+
upsert_route(zone_id: zone["id"], address: address, worker_name: worker_name)
|
|
44
51
|
end
|
|
45
52
|
|
|
46
53
|
def find_zone_id_for(domain)
|
|
54
|
+
find_zone_for(domain)&.fetch("id")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def find_zone_for(domain)
|
|
47
58
|
# Try the exact domain, then walk up parent domains until we find a
|
|
48
59
|
# Cloudflare zone. Supports subdomains like "in.example.com" routing
|
|
49
60
|
# to the "example.com" zone.
|
|
@@ -52,35 +63,35 @@ module Cloudflare
|
|
|
52
63
|
candidates.each do |candidate|
|
|
53
64
|
result = api_request(:get, "/zones?name=#{URI.encode_www_form_component(candidate)}")
|
|
54
65
|
zones = Array(result["result"])
|
|
55
|
-
return zones.first
|
|
66
|
+
return zones.first.merge("name" => candidate) if zones.any?
|
|
56
67
|
end
|
|
57
68
|
|
|
58
69
|
nil
|
|
59
70
|
end
|
|
60
71
|
|
|
61
72
|
def enable_routing_if_needed(zone_id)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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")
|
|
73
|
+
current = api_request(:get, "/zones/#{zone_id}/email/routing")
|
|
74
|
+
unless [true, false].include?(current.dig("result", "enabled"))
|
|
75
|
+
raise Error.new("Email Routing settings response did not include an enabled flag; no routing settings were changed")
|
|
83
76
|
end
|
|
77
|
+
return if current.dig("result", "enabled") == true
|
|
78
|
+
|
|
79
|
+
api_request(:post, "/zones/#{zone_id}/email/routing/dns")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Cloudflare's documented subdomain onboarding is dashboard-only. Check
|
|
83
|
+
# its DNS prerequisites without ever modifying the parent zone's routing.
|
|
84
|
+
# This checks configured records, not propagation or live delivery.
|
|
85
|
+
def check_subdomain_dns!(zone_id:, domain:)
|
|
86
|
+
records = paginated_results("/zones/#{zone_id}/dns_records?name=#{URI.encode_www_form_component(domain)}")
|
|
87
|
+
mx = records.select { |r| r["type"] == "MX" }.map { |r| r["content"].to_s.downcase.delete_suffix(".") }.uniq.sort
|
|
88
|
+
expected_mx = (1..3).map { |n| "route#{n}.mx.cloudflare.net" }
|
|
89
|
+
spf = records.select { |r| r["type"] == "TXT" }.map { |r| r["content"].to_s.delete('"') }.select { |v| v.start_with?("v=spf1 ") }
|
|
90
|
+
return if mx == expected_mx && spf.size == 1 && spf.first.split.include?("include:_spf.mx.cloudflare.net")
|
|
91
|
+
|
|
92
|
+
raise Error.new("Email Routing DNS for #{domain} is missing or conflicts with another mail provider. " \
|
|
93
|
+
"In Cloudflare, open Email Routing > the apex domain > Settings > Subdomains and onboard #{domain}; " \
|
|
94
|
+
"then retry after checking its routing MX and SPF records. Parent-zone routing was not changed.")
|
|
84
95
|
end
|
|
85
96
|
|
|
86
97
|
def upsert_route(zone_id:, address:, worker_name:)
|
|
@@ -110,18 +121,14 @@ module Cloudflare
|
|
|
110
121
|
end
|
|
111
122
|
|
|
112
123
|
def find_rule_for(zone_id:, address:)
|
|
113
|
-
|
|
114
|
-
rules = Array(result["result"])
|
|
115
|
-
|
|
116
|
-
rules.find do |r|
|
|
124
|
+
list_rules(zone_id).find do |r|
|
|
117
125
|
matchers = Array(r["matchers"])
|
|
118
|
-
matchers.any? { |m| m["field"] == "to" && m["value"] == address }
|
|
126
|
+
matchers.size == 1 && matchers.any? { |m| m["field"] == "to" && m["type"] == "literal" && m["value"] == address }
|
|
119
127
|
end
|
|
120
128
|
end
|
|
121
129
|
|
|
122
130
|
def list_rules(zone_id)
|
|
123
|
-
|
|
124
|
-
Array(result["result"])
|
|
131
|
+
paginated_results("/zones/#{zone_id}/email/routing/rules")
|
|
125
132
|
end
|
|
126
133
|
|
|
127
134
|
# Point the zone's catch-all rule at our Worker. Catch-all matches any
|
|
@@ -141,21 +148,40 @@ module Cloudflare
|
|
|
141
148
|
end
|
|
142
149
|
|
|
143
150
|
def provision_catch_all_for_domain(domain:, worker_name:)
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
domain = domain.to_s.downcase.delete_suffix(".")
|
|
152
|
+
zone = find_zone_for(domain)
|
|
153
|
+
raise Error.new("No Cloudflare zone found for #{domain}") unless zone
|
|
154
|
+
unless zone["name"] == domain
|
|
155
|
+
raise Error.new("Catch-all rules are zone-wide: #{domain} belongs to zone #{zone['name']}. " \
|
|
156
|
+
"This task cannot scope a catch-all to a subdomain. Use explicit address routes or configure subdomain handling in the dashboard.")
|
|
157
|
+
end
|
|
146
158
|
|
|
147
|
-
enable_routing_if_needed(
|
|
148
|
-
provision_catch_all(zone_id:
|
|
159
|
+
enable_routing_if_needed(zone["id"])
|
|
160
|
+
provision_catch_all(zone_id: zone["id"], worker_name: worker_name)
|
|
149
161
|
end
|
|
150
162
|
|
|
151
163
|
private
|
|
152
164
|
|
|
165
|
+
def paginated_results(path)
|
|
166
|
+
records = []
|
|
167
|
+
page = 1
|
|
168
|
+
loop do
|
|
169
|
+
separator = path.include?("?") ? "&" : "?"
|
|
170
|
+
result = api_request(:get, "#{path}#{separator}per_page=50&page=#{page}")
|
|
171
|
+
batch = Array(result["result"])
|
|
172
|
+
records.concat(batch)
|
|
173
|
+
total_pages = result.dig("result_info", "total_pages")
|
|
174
|
+
break if batch.empty? || (total_pages ? page >= total_pages.to_i : batch.size < 50)
|
|
175
|
+
page += 1
|
|
176
|
+
end
|
|
177
|
+
records
|
|
178
|
+
end
|
|
179
|
+
|
|
153
180
|
def extract_domain(address)
|
|
154
|
-
|
|
155
|
-
address
|
|
156
|
-
else
|
|
157
|
-
address
|
|
181
|
+
unless address.to_s.match?(/\A[^\s@]+@[^\s@]+\z/)
|
|
182
|
+
raise ArgumentError, "address must be a full email address"
|
|
158
183
|
end
|
|
184
|
+
address.split("@", 2).last.downcase.delete_suffix(".")
|
|
159
185
|
end
|
|
160
186
|
|
|
161
187
|
# "a.b.c.example.com" → ["a.b.c.example.com", "b.c.example.com", "c.example.com", "example.com"]
|
|
@@ -196,9 +222,9 @@ module Cloudflare
|
|
|
196
222
|
|
|
197
223
|
def handle!(response, context)
|
|
198
224
|
status = response.code.to_i
|
|
199
|
-
return if status.between?(200, 299)
|
|
200
|
-
|
|
201
225
|
body = parse(response.body)
|
|
226
|
+
return if status.between?(200, 299) && body.is_a?(Hash) && body["success"] != false && body.key?("result")
|
|
227
|
+
|
|
202
228
|
errors = body.is_a?(Hash) ? Array(body["errors"]) : []
|
|
203
229
|
message = errors.map { |e| e.is_a?(Hash) ? e["message"] : e.to_s }.compact.join("; ")
|
|
204
230
|
message = "HTTP #{status}" if message.empty?
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "active_job"
|
|
2
|
+
require "cloudflare/email/active_record"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
# Serialize only the durable operation identity, never MIME or credentials.
|
|
7
|
+
# The application's job backend controls scheduling/retries; uncertainty is
|
|
8
|
+
# never cleared by retrying this job.
|
|
9
|
+
class SendJob < ::ActiveJob::Base
|
|
10
|
+
queue_as :mailers
|
|
11
|
+
|
|
12
|
+
def perform(account_id, operation_key)
|
|
13
|
+
delivery = ActiveRecord::OutboundDelivery.find_by!(account_id: account_id, operation_key: operation_key)
|
|
14
|
+
options = settings&.outbox_client_options || {}
|
|
15
|
+
client = Client.new(**options.to_h.merge(account_id: Credentials.account_id,
|
|
16
|
+
api_token: Credentials.api_token, retry_ambiguous: false))
|
|
17
|
+
ActiveRecord::Outbox.deliver(delivery, client: client)
|
|
18
|
+
handler = settings&.outbox_delivery_handler
|
|
19
|
+
# This is product projection after the durable send result. If it fails,
|
|
20
|
+
# a retry sees the existing accepted operation and cannot send it again.
|
|
21
|
+
delivery.with_lock { handler.call(delivery) } if handler.respond_to?(:call)
|
|
22
|
+
recipient_handler = settings&.outbox_recipient_handler
|
|
23
|
+
if %w[accepted partial].include?(delivery.state) && delivery.provider_message_id
|
|
24
|
+
ActiveRecord::DeliveryEvents.replay(account_id: account_id,
|
|
25
|
+
message_id: delivery.provider_message_id,
|
|
26
|
+
&(recipient_handler.method(:call) if recipient_handler.respond_to?(:call)))
|
|
27
|
+
end
|
|
28
|
+
if delivery.state == "unknown"
|
|
29
|
+
raise ActiveRecord::Outbox::InvalidTransition, "delivery outcome is unknown; reconcile before retrying"
|
|
30
|
+
end
|
|
31
|
+
delivery
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def settings
|
|
37
|
+
Rails.application.config.x.cloudflare_email if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
require "cloudflare/email/task_base"
|
|
2
2
|
require "cloudflare/email/client"
|
|
3
|
+
require "time"
|
|
3
4
|
|
|
4
5
|
module Cloudflare
|
|
5
6
|
module Email
|
|
6
|
-
# `bin/rails cloudflare:email:send_test TO=...
|
|
7
|
+
# `bin/rails cloudflare:email:send_test TO=... FROM=...` — one-shot
|
|
7
8
|
# test send via the current Cloudflare Email config.
|
|
8
9
|
class SendTest < TaskBase
|
|
9
10
|
def self.call(to:, from: nil, io: $stdout)
|
|
@@ -17,8 +18,7 @@ module Cloudflare
|
|
|
17
18
|
require_value!(api_token, "cloudflare.api_token")
|
|
18
19
|
require_value!(opts[:to], "TO=recipient@example.com")
|
|
19
20
|
|
|
20
|
-
sender = opts[:from]
|
|
21
|
-
raise "Missing FROM= and couldn't infer from verified sending domains" if sender.to_s.empty?
|
|
21
|
+
sender = require_value!(opts[:from], "FROM=sender@your-verified-domain.example")
|
|
22
22
|
|
|
23
23
|
say "Sending test email:"
|
|
24
24
|
say " from: #{sender}"
|
|
@@ -39,37 +39,13 @@ module Cloudflare
|
|
|
39
39
|
)
|
|
40
40
|
|
|
41
41
|
say " success: #{response.success?}"
|
|
42
|
+
say " message_id: #{response.message_id}" if response.message_id
|
|
42
43
|
say " delivered: #{response.delivered.inspect}"
|
|
43
44
|
say " queued: #{response.queued.inspect}" if response.queued.any?
|
|
44
45
|
say " bounces: #{response.permanent_bounces.inspect}" if response.permanent_bounces.any?
|
|
46
|
+
say " suppressed: #{response.suppressed_recipients.inspect}" if response.suppressed_recipients.any?
|
|
45
47
|
end
|
|
46
48
|
|
|
47
|
-
private
|
|
48
|
-
|
|
49
|
-
def infer_from
|
|
50
|
-
require "net/http"
|
|
51
|
-
require "json"
|
|
52
|
-
|
|
53
|
-
uri = URI.parse("https://api.cloudflare.com/client/v4/accounts/#{account_id}/email/sending/domains")
|
|
54
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
55
|
-
http.use_ssl = true
|
|
56
|
-
http.open_timeout = 10
|
|
57
|
-
http.read_timeout = 10
|
|
58
|
-
|
|
59
|
-
req = Net::HTTP::Get.new(uri.request_uri)
|
|
60
|
-
req["Authorization"] = "Bearer #{api_token}"
|
|
61
|
-
response = http.request(req)
|
|
62
|
-
return nil unless response.code.to_i.between?(200, 299)
|
|
63
|
-
|
|
64
|
-
domains = JSON.parse(response.body).dig("result") || []
|
|
65
|
-
verified = domains.find { |d| d["verified"] == true || d["status"] == "verified" }
|
|
66
|
-
return nil unless verified
|
|
67
|
-
|
|
68
|
-
domain = verified["name"] || verified["domain"]
|
|
69
|
-
domain.to_s.empty? ? nil : "test@#{domain}"
|
|
70
|
-
rescue StandardError
|
|
71
|
-
nil
|
|
72
|
-
end
|
|
73
49
|
end
|
|
74
50
|
end
|
|
75
51
|
end
|
|
@@ -4,8 +4,8 @@ require "openssl"
|
|
|
4
4
|
module Cloudflare
|
|
5
5
|
module Email
|
|
6
6
|
# Shared cryptographic helpers: HMAC-SHA256, constant-time compare,
|
|
7
|
-
# base64url encoding. Used by Verification
|
|
8
|
-
#
|
|
7
|
+
# base64url encoding. Used by Verification and authenticated envelopes.
|
|
8
|
+
# Keeps every crypto primitive in
|
|
9
9
|
# one place so the hash algorithm, encoding choice, and compare function
|
|
10
10
|
# can't drift between call sites.
|
|
11
11
|
module Signing
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "cloudflare/email/error"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
# An adapter boundary: the host owns tenant discovery and connection switching.
|
|
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
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require "cloudflare/email/tenancy"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
# Opt in with `prepend Cloudflare::Email::TenantJobContext` on jobs whose
|
|
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
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "cloudflare/email/signing"
|
|
2
|
+
require "cloudflare/email/envelope"
|
|
2
3
|
|
|
3
4
|
module Cloudflare
|
|
4
5
|
module Email
|
|
@@ -6,17 +7,38 @@ module Cloudflare
|
|
|
6
7
|
# Cloudflare Email Worker. Pure-Ruby and Rails-free so it can be
|
|
7
8
|
# unit-tested in isolation.
|
|
8
9
|
#
|
|
9
|
-
# Worker signs: HMAC-SHA256(secret, "{timestamp}.{raw_body}")
|
|
10
|
+
# Worker signs: HMAC-SHA256(secret, "v2.{timestamp}.{encoded_envelope}.{raw_body}")
|
|
10
11
|
# Worker sends:
|
|
11
12
|
# X-CF-Email-Timestamp: <unix seconds>
|
|
12
13
|
# X-CF-Email-Signature: <hex digest>
|
|
14
|
+
# X-CF-Email-Signature-Version: 2
|
|
15
|
+
# X-CF-Email-Envelope: <unpadded base64url JSON from/to>
|
|
16
|
+
# The version and authenticated SMTP envelope are required.
|
|
13
17
|
module Verification
|
|
14
18
|
DEFAULT_WINDOW = 5 * 60 # seconds
|
|
15
19
|
|
|
16
20
|
# Returns :ok, :bad_signature, or :stale.
|
|
17
21
|
# Returns :bad_signature for any malformed input.
|
|
18
|
-
def self.verify(secret:, body:, timestamp:, signature:, window: DEFAULT_WINDOW, now: Time.now.to_i)
|
|
19
|
-
return :bad_signature if blank?(
|
|
22
|
+
def self.verify(secret:, body:, timestamp:, signature:, version: nil, envelope: nil, window: DEFAULT_WINDOW, now: Time.now.to_i)
|
|
23
|
+
return :bad_signature if blank?(body)
|
|
24
|
+
timestamp = timestamp.to_s if timestamp.is_a?(Integer)
|
|
25
|
+
preflight = verify_headers(secret: secret, timestamp: timestamp, signature: signature,
|
|
26
|
+
version: version, envelope: envelope, window: window, now: now)
|
|
27
|
+
return preflight unless preflight == :ok
|
|
28
|
+
|
|
29
|
+
expected = sign(secret: secret, body: body, timestamp: Integer(timestamp, 10), version: version, envelope: envelope)
|
|
30
|
+
return :bad_signature unless Signing.secure_compare(expected, signature)
|
|
31
|
+
|
|
32
|
+
:ok
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Reject malformed/stale requests before the controller reads MIME bytes.
|
|
36
|
+
# 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)
|
|
38
|
+
return :bad_signature if blank?(secret)
|
|
39
|
+
return :bad_signature unless timestamp.is_a?(String) && /\A[0-9]{1,20}\z/.match?(timestamp)
|
|
40
|
+
return :bad_signature unless signature.is_a?(String) && /\A[0-9a-f]{64}\z/.match?(signature)
|
|
41
|
+
return :bad_signature unless version == "2" && Envelope.decode(envelope)
|
|
20
42
|
|
|
21
43
|
ts = begin
|
|
22
44
|
Integer(timestamp.to_s, 10)
|
|
@@ -26,14 +48,14 @@ module Cloudflare
|
|
|
26
48
|
|
|
27
49
|
return :stale if (now - ts).abs > window
|
|
28
50
|
|
|
29
|
-
expected = Signing.hmac_hex(secret, "#{ts}.#{body}")
|
|
30
|
-
return :bad_signature unless Signing.secure_compare(expected, signature.to_s)
|
|
31
|
-
|
|
32
51
|
:ok
|
|
33
52
|
end
|
|
34
53
|
|
|
35
|
-
def self.sign(secret:, body:, timestamp:)
|
|
36
|
-
|
|
54
|
+
def self.sign(secret:, body:, timestamp:, envelope:, version: "2")
|
|
55
|
+
raise ArgumentError, "unsupported signature version" unless version == "2"
|
|
56
|
+
raise ArgumentError, "invalid SMTP envelope" unless Envelope.decode(envelope)
|
|
57
|
+
prefix = "v2.#{timestamp}.#{envelope}."
|
|
58
|
+
Signing.hmac_hex(secret, prefix.b + body.b)
|
|
37
59
|
end
|
|
38
60
|
|
|
39
61
|
def self.blank?(v)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "net/http"
|
|
2
2
|
require "json"
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require "cloudflare/email/endpoint"
|
|
4
5
|
|
|
5
6
|
module Cloudflare
|
|
6
7
|
module Email
|
|
@@ -19,7 +20,7 @@ module Cloudflare
|
|
|
19
20
|
# deployer.put_secret("RAILS_INGRESS_URL", "https://...")
|
|
20
21
|
class WorkerDeployer
|
|
21
22
|
SCRIPT_NAME_PREFIX = "cloudflare-email-ingress".freeze
|
|
22
|
-
DEFAULT_COMPATIBILITY_DATE = "2026-
|
|
23
|
+
DEFAULT_COMPATIBILITY_DATE = "2026-09-10".freeze
|
|
23
24
|
API_BASE = "https://api.cloudflare.com/client/v4".freeze
|
|
24
25
|
|
|
25
26
|
attr_reader :script_name
|
|
@@ -49,7 +50,7 @@ module Cloudflare
|
|
|
49
50
|
@api_token = api_token
|
|
50
51
|
@script_name = script_name
|
|
51
52
|
@compatibility_date = compatibility_date
|
|
52
|
-
@api_base = api_base
|
|
53
|
+
@api_base = Endpoint.parse(api_base).to_s.delete_suffix("/")
|
|
53
54
|
end
|
|
54
55
|
|
|
55
56
|
# Uploads/updates the Worker script. Accepts either `script_path:` (a
|
|
@@ -71,6 +72,7 @@ module Cloudflare
|
|
|
71
72
|
|
|
72
73
|
# Set/update a Worker secret.
|
|
73
74
|
def put_secret(name, value)
|
|
75
|
+
Endpoint.parse(value) if name.to_s == "RAILS_INGRESS_URL"
|
|
74
76
|
request(
|
|
75
77
|
method: :put,
|
|
76
78
|
path: "/accounts/#{@account_id}/workers/scripts/#{@script_name}/secrets",
|
|
@@ -160,7 +162,7 @@ module Cloudflare
|
|
|
160
162
|
status = response.code.to_i
|
|
161
163
|
body = parse(response.body)
|
|
162
164
|
|
|
163
|
-
return body if status.between?(200, 299)
|
|
165
|
+
return body if status.between?(200, 299) && body.is_a?(Hash) && body["success"] == true
|
|
164
166
|
|
|
165
167
|
errors = body.is_a?(Hash) ? Array(body["errors"]) : []
|
|
166
168
|
message = errors.map { |e| e.is_a?(Hash) ? e["message"] : e.to_s }.compact.join("; ")
|
data/lib/cloudflare-email.rb
CHANGED
|
@@ -2,8 +2,11 @@ require "cloudflare/email/version"
|
|
|
2
2
|
require "cloudflare/email/error"
|
|
3
3
|
require "cloudflare/email/response"
|
|
4
4
|
require "cloudflare/email/signing"
|
|
5
|
+
require "cloudflare/email/envelope"
|
|
5
6
|
require "cloudflare/email/credentials"
|
|
6
7
|
require "cloudflare/email/client"
|
|
7
|
-
require "cloudflare/email/
|
|
8
|
+
require "cloudflare/email/message_id"
|
|
9
|
+
require "cloudflare/email/delivery_event"
|
|
10
|
+
require "cloudflare/email/event_consumer"
|
|
8
11
|
|
|
9
12
|
require "cloudflare/email/engine" if defined?(::Rails::Engine)
|