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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -1
  3. data/README.md +265 -388
  4. data/SECURITY.md +51 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +62 -6
  6. data/docs/activerecord-tenanted.md +135 -0
  7. data/docs/architecture.md +95 -0
  8. data/docs/delivery-events.md +177 -0
  9. data/docs/features.md +107 -0
  10. data/docs/getting-started.md +298 -0
  11. data/docs/mailboxes.md +299 -0
  12. data/docs/outbox.md +175 -0
  13. data/docs/tenant-mailboxes-plan.md +149 -0
  14. data/docs/thread-correlation.md +27 -0
  15. data/docs/troubleshooting.md +103 -0
  16. data/docs/upgrading-0.2.md +96 -0
  17. data/docs/verification/2026-09-10-followup.md +49 -0
  18. data/docs/verification/2026-09-10-install-upgrade.md +86 -0
  19. data/docs/verification/2026-09-10-live.md +102 -0
  20. data/docs/verification/2026-09-10.md +175 -0
  21. data/docs/verification/2026-09-11-extraction.md +32 -0
  22. data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
  23. data/docs/verification/2026-09-11-security.md +76 -0
  24. data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
  25. data/examples/plain_ruby.rb +24 -0
  26. data/lib/cloudflare/email/active_record/base.rb +48 -0
  27. data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
  28. data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
  29. data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
  30. data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
  31. data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
  32. data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
  33. data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
  34. data/lib/cloudflare/email/active_record/outbox.rb +219 -0
  35. data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
  36. data/lib/cloudflare/email/active_record.rb +5 -0
  37. data/lib/cloudflare/email/client.rb +51 -11
  38. data/lib/cloudflare/email/consume_events_task.rb +22 -0
  39. data/lib/cloudflare/email/delivery_event.rb +70 -0
  40. data/lib/cloudflare/email/delivery_method.rb +3 -0
  41. data/lib/cloudflare/email/deploy_worker_task.rb +3 -0
  42. data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
  43. data/lib/cloudflare/email/dev_tunnel.rb +31 -5
  44. data/lib/cloudflare/email/doctor.rb +16 -38
  45. data/lib/cloudflare/email/endpoint.rb +24 -0
  46. data/lib/cloudflare/email/engine.rb +28 -0
  47. data/lib/cloudflare/email/envelope.rb +58 -0
  48. data/lib/cloudflare/email/event_consumer.rb +86 -0
  49. data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
  50. data/lib/cloudflare/email/mailboxes/events.rb +112 -0
  51. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
  52. data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
  53. data/lib/cloudflare/email/mailboxes/models.rb +148 -0
  54. data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
  55. data/lib/cloudflare/email/mailboxes/service.rb +288 -0
  56. data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
  57. data/lib/cloudflare/email/mailboxes.rb +11 -0
  58. data/lib/cloudflare/email/message_id.rb +12 -0
  59. data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
  60. data/lib/cloudflare/email/provision_route_task.rb +2 -1
  61. data/lib/cloudflare/email/replay_events_job.rb +18 -0
  62. data/lib/cloudflare/email/response.rb +21 -3
  63. data/lib/cloudflare/email/routing_provisioner.rb +71 -45
  64. data/lib/cloudflare/email/send_job.rb +41 -0
  65. data/lib/cloudflare/email/send_test.rb +5 -29
  66. data/lib/cloudflare/email/signing.rb +2 -2
  67. data/lib/cloudflare/email/tenancy.rb +78 -0
  68. data/lib/cloudflare/email/tenant_job_context.rb +90 -0
  69. data/lib/cloudflare/email/verification.rb +30 -8
  70. data/lib/cloudflare/email/version.rb +1 -1
  71. data/lib/cloudflare/email/worker_deployer.rb +5 -3
  72. data/lib/cloudflare-email.rb +4 -1
  73. data/lib/generators/cloudflare/email/install_generator.rb +48 -33
  74. data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
  75. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +54 -0
  76. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
  77. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
  78. data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
  79. data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
  80. data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
  81. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
  82. data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
  83. data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
  84. data/lib/tasks/cloudflare_email.rake +41 -4
  85. data/templates/worker/README.md +61 -11
  86. data/templates/worker/package-lock.json +2627 -0
  87. data/templates/worker/package.json +8 -5
  88. data/templates/worker/scripts/wrangler.mjs +16 -0
  89. data/templates/worker/src/index.js +83 -4
  90. data/templates/worker/test/index.test.ts +117 -5
  91. data/templates/worker/wrangler.toml +13 -4
  92. metadata +107 -6
  93. 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 — no dashboard clicks.
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
- zone_id = find_zone_id_for(domain)
40
- raise Error.new("No Cloudflare zone found for #{domain} — add the domain to your account first") unless zone_id
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
- enable_routing_if_needed(zone_id)
43
- upsert_route(zone_id: zone_id, address: address, worker_name: worker_name)
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["id"] if zones.any?
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
- # This endpoint requires the "Email Routing Settings" permission group,
63
- # which most scoped tokens don't carry. If we can read the setting,
64
- # enable when off. If we can't (403), assume the user enabled routing
65
- # via the dashboard when they added the subdomain — the subsequent
66
- # rule create will fail with a clear error if not.
67
- current = raw_api_request(:get, "/zones/#{zone_id}/email/routing")
68
- status = current.code.to_i
69
-
70
- case status
71
- when 200
72
- body = parse(current.body)
73
- enabled = body.dig("result", "enabled")
74
- api_request(:post, "/zones/#{zone_id}/email/routing/enable") unless enabled
75
- when 403, 404
76
- # Either the token can't read settings or routing isn't set up.
77
- # Try to enable optimistically; ignore failure (rule create will
78
- # surface a precise error if routing is actually off).
79
- attempt = raw_api_request(:post, "/zones/#{zone_id}/email/routing/enable")
80
- # Don't fail here even if this also 403s — move on to rule creation.
81
- else
82
- handle!(current, "GET /zones/#{zone_id}/email/routing")
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
- result = api_request(:get, "/zones/#{zone_id}/email/routing/rules?per_page=50")
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
- result = api_request(:get, "/zones/#{zone_id}/email/routing/rules?per_page=50")
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
- zone_id = find_zone_id_for(domain)
145
- raise Error.new("No Cloudflare zone found for #{domain}") unless zone_id
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(zone_id)
148
- provision_catch_all(zone_id: zone_id, worker_name: worker_name)
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
- if address.include?("@")
155
- address.split("@", 2).last
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=... [FROM=...]` — one-shot
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] || infer_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 (ingress HMAC) and
8
- # SecureMessageId (signed Message-IDs). Keeps every crypto primitive in
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?(secret) || blank?(body) || blank?(timestamp) || blank?(signature)
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
- Signing.hmac_hex(secret, "#{timestamp}.#{body}")
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,5 +1,5 @@
1
1
  module Cloudflare
2
2
  module Email
3
- VERSION = "0.1.0"
3
+ VERSION = "0.2.0"
4
4
  end
5
5
  end
@@ -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-04-01".freeze
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("; ")
@@ -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/secure_message_id"
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)