cloudflare-email 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +109 -0
  3. data/README.md +44 -9
  4. data/app/controllers/cloudflare/email/ingress_controller.rb +15 -60
  5. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +9 -0
  6. data/app/controllers/cloudflare/email/management/styles_controller.rb +9 -0
  7. data/docs/activerecord-tenanted.md +46 -0
  8. data/docs/architecture.md +13 -3
  9. data/docs/custom-ingress.md +211 -0
  10. data/docs/delivery-events.md +9 -0
  11. data/docs/features.md +36 -5
  12. data/docs/getting-started.md +19 -8
  13. data/docs/mailboxes.md +74 -3
  14. data/docs/management-engine.md +190 -0
  15. data/docs/routing-deliveries.md +94 -0
  16. data/docs/routing-diagnostics.md +75 -0
  17. data/docs/troubleshooting.md +5 -4
  18. data/docs/upgrading-0.2.md +5 -0
  19. data/docs/upgrading-0.3.md +55 -0
  20. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  21. data/docs/verification/2026-09-12-destination-context.md +46 -0
  22. data/docs/verification/2026-09-13-action-mailbox-core.md +96 -0
  23. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  24. data/lib/cloudflare/email/active_record/base.rb +3 -41
  25. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  26. data/lib/cloudflare/email/check_route_task.rb +21 -0
  27. data/lib/cloudflare/email/client.rb +45 -10
  28. data/lib/cloudflare/email/delivery_method.rb +2 -0
  29. data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
  30. data/lib/cloudflare/email/engine.rb +6 -13
  31. data/lib/cloudflare/email/envelope.rb +4 -14
  32. data/lib/cloudflare/email/error.rb +5 -12
  33. data/lib/cloudflare/email/event_consumer.rb +12 -3
  34. data/lib/cloudflare/email/ingress.rb +126 -0
  35. data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
  36. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
  37. data/lib/cloudflare/email/mailboxes/models.rb +8 -130
  38. data/lib/cloudflare/email/mailboxes/service.rb +7 -174
  39. data/lib/cloudflare/email/mailboxes.rb +1 -0
  40. data/lib/cloudflare/email/management/adapter.rb +9 -0
  41. data/lib/cloudflare/email/management/configuration.rb +10 -0
  42. data/lib/cloudflare/email/management/engine.rb +17 -0
  43. data/lib/cloudflare/email/management/routes.rb +14 -0
  44. data/lib/cloudflare/email/management.rb +7 -0
  45. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  46. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  47. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  48. data/lib/cloudflare/email/tenancy.rb +2 -73
  49. data/lib/cloudflare/email/tenant_job_context.rb +2 -85
  50. data/lib/cloudflare/email/verification.rb +17 -9
  51. data/lib/cloudflare/email/version.rb +1 -1
  52. data/lib/cloudflare/email/worker_deployer.rb +36 -5
  53. data/lib/cloudflare-email.rb +6 -0
  54. data/lib/generators/cloudflare/email/install_generator.rb +6 -5
  55. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  56. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  57. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +5 -0
  58. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
  59. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  60. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  61. data/lib/tasks/cloudflare_email.rake +2 -1
  62. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  63. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  64. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  65. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  66. data/templates/deploy-to-cloudflare/README.md +104 -0
  67. data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
  68. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  69. data/templates/deploy-to-cloudflare/package.json +37 -0
  70. data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
  71. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  72. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  73. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  74. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  75. data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
  76. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  77. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  78. data/templates/worker/README.md +186 -15
  79. data/templates/worker/docs/domain-setup.md +198 -0
  80. data/templates/worker/docs/durable-inbound.md +149 -0
  81. data/templates/worker/package-lock.json +2 -2
  82. data/templates/worker/package.json +2 -1
  83. data/templates/worker/scripts/check-subdomains.mjs +54 -0
  84. data/templates/worker/src/index.js +331 -38
  85. data/templates/worker/test/durable.test.ts +193 -0
  86. data/templates/worker/test/index.test.ts +78 -20
  87. data/templates/worker/test/relay.test.ts +226 -0
  88. data/templates/worker/test/subdomains.test.ts +51 -0
  89. data/templates/worker/wrangler.toml +50 -0
  90. metadata +65 -3
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cloudflare/email/routing_analytics"
4
+ require "cloudflare/email/active_record/outbox"
5
+
6
+ module Cloudflare
7
+ module Email
8
+ module ActiveRecord
9
+ class RoutingDeliveryReceipt < Base
10
+ self.table_name = "cloudflare_email_routing_delivery_receipts"
11
+ belongs_to :outbound_delivery, class_name: "Cloudflare::Email::ActiveRecord::OutboundDelivery"
12
+ encrypts :payload_json
13
+ attr_readonly :account_id, :zone_id, :event_key, :outbound_delivery_id, :payload_json, :received_at
14
+
15
+ def evidence
16
+ RoutingAnalytics::Evidence.new(JSON.parse(payload_json))
17
+ rescue JSON::ParserError, TypeError
18
+ raise RoutingAnalytics::Error, "Stored Routing analytics evidence is malformed", cause: nil
19
+ end
20
+ end
21
+
22
+ # An opt-in delivery projection, separate from Sending webhook receipts and
23
+ # acceptance reconciliation. No method here can send or authorize a resend.
24
+ class RoutingDeliveries
25
+ class InvalidEvidence < RoutingAnalytics::Error; end
26
+ ReplayResult = Struct.new(:after_id, :processed, :errors, :finished, keyword_init: true)
27
+
28
+ class << self
29
+ def record(delivery:, evidence:)
30
+ unless evidence.is_a?(RoutingAnalytics::Evidence) && delivery.is_a?(OutboundDelivery) && delivery.persisted?
31
+ raise ArgumentError, "A persisted outbound delivery and RoutingAnalytics::Evidence are required"
32
+ end
33
+ if RoutingDeliveryReceipt.connection.transaction_open?
34
+ raise ArgumentError, "record must commit outside an existing transaction before projection"
35
+ end
36
+ validate!(delivery.reload, evidence)
37
+ receipt = RoutingDeliveryReceipt.create_or_find_by!(account_id: evidence.account_id, event_key: evidence.identity) do |row|
38
+ row.outbound_delivery = delivery
39
+ row.zone_id = evidence.zone_id
40
+ row.payload_json = JSON.generate(evidence.payload)
41
+ row.received_at = Time.now.utc
42
+ end
43
+ unless receipt.outbound_delivery_id == delivery.id && receipt.evidence.same_event?(evidence)
44
+ raise InvalidEvidence, "Routing analytics evidence identity conflict"
45
+ end
46
+ receipt
47
+ end
48
+
49
+ # Returns :applied (including idempotent replay). An existing terminal
50
+ # fact is preserved; inspect recipient.state instead of assuming delivery.
51
+ # Only new recipient changes invoke the same-database callback.
52
+ def apply(receipt)
53
+ unless receipt.is_a?(RoutingDeliveryReceipt) && receipt.persisted?
54
+ raise ArgumentError, "A persisted RoutingDeliveryReceipt is required"
55
+ end
56
+ completed = false
57
+ receipt.with_lock(requires_new: true) do
58
+ if receipt.state == "applied"
59
+ completed = true
60
+ next
61
+ end
62
+ evidence = receipt.evidence
63
+ unless receipt.account_id == evidence.account_id && receipt.zone_id == evidence.zone_id && receipt.event_key == evidence.identity
64
+ raise InvalidEvidence, "Routing analytics receipt identity mismatch"
65
+ end
66
+ delivery = receipt.outbound_delivery
67
+ delivery.with_lock do
68
+ recipient = validate!(delivery, evidence)
69
+ unless recipient.terminal?
70
+ recipient.update!(state: "delivered", occurred_at: evidence.occurred_at, terminal: true)
71
+ yield delivery, recipient if block_given?
72
+ end
73
+ receipt.update!(state: "applied", applied_at: Time.now.utc)
74
+ completed = true
75
+ end
76
+ # ActiveRecord::Rollback can be swallowed by the nested lock's
77
+ # transaction. Fail before this outer transaction can commit.
78
+ raise ArgumentError, "Projection rolled back instead of completing" unless completed
79
+ end
80
+ raise ArgumentError, "Projection rolled back instead of completing" unless completed
81
+ :applied
82
+ end
83
+
84
+ # Each failed receipt advances the cursor too. Persist after_id in the
85
+ # host job and reset it to zero after finished; discovery gets its own budget.
86
+ def replay(account_id:, limit: 100, after_id: 0, &on_change)
87
+ unless account_id.is_a?(String) && !account_id.empty? && limit.is_a?(Integer) && (1..1000).cover?(limit) &&
88
+ after_id.is_a?(Integer) && after_id >= 0
89
+ raise ArgumentError, "An account, limit of 1..1000 and nonnegative cursor are required"
90
+ end
91
+ scope = RoutingDeliveryReceipt.where(account_id: account_id, state: "pending").where("id > ?", after_id).order(:id)
92
+ rows = scope.limit(limit).to_a
93
+ errors = []
94
+ rows.each do |receipt|
95
+ apply(receipt, &on_change)
96
+ rescue StandardError => error
97
+ errors << {receipt_id: receipt.id, error: error}
98
+ end
99
+ cursor = rows.last&.id || after_id
100
+ ReplayResult.new(after_id: cursor, processed: rows.length, errors: errors,
101
+ finished: !RoutingDeliveryReceipt.where(account_id: account_id, state: "pending").where("id > ?", cursor).exists?)
102
+ end
103
+
104
+ private
105
+
106
+ def validate!(delivery, evidence)
107
+ ids = OutboundDelivery.where(account_id: evidence.account_id,
108
+ provider_message_id: [evidence.message_id, "<#{evidence.message_id}>"]).limit(2).pluck(:id)
109
+ unless delivery.state == "accepted" && delivery.account_id == evidence.account_id && ids == [delivery.id] &&
110
+ MessageId.normalize(delivery.provider_message_id) == evidence.message_id &&
111
+ delivery.from_address.casecmp?(evidence.event.fetch("from")) && delivery.request_started_at &&
112
+ evidence.occurred_at >= Time.at(delivery.request_started_at.to_i).utc
113
+ raise InvalidEvidence, "Routing analytics requires one matching accepted outbound operation"
114
+ end
115
+ recipients = delivery.outbound_recipients.to_a
116
+ envelope = delivery.recipients
117
+ unless envelope.is_a?(Array) && envelope.one? && recipients.one? &&
118
+ recipients.first.recipient == Outbox.normalize_recipient(envelope.first) &&
119
+ recipients.first.acceptance_state == "accepted"
120
+ raise InvalidEvidence, "Routing analytics requires one accepted saved envelope recipient"
121
+ end
122
+ recipients.first
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,21 @@
1
+ require "cloudflare/email/credentials"
2
+ require "cloudflare/email/routing_diagnostics"
3
+ require "cloudflare/email/worker_deployer"
4
+
5
+ module Cloudflare
6
+ module Email
7
+ class CheckRouteTask
8
+ def self.call(address:, worker_name: nil, account_id: nil, io: $stdout)
9
+ report = RoutingDiagnostics.new(api_token: Credentials.management_token).check(
10
+ address: address, worker_name: worker_name || WorkerDeployer.default_script_name,
11
+ account_id: account_id || Credentials.account_id)
12
+ report[:checks].each { |check| io.puts "#{check[:status].upcase} #{check[:name]}: #{check[:message]}" }
13
+ io.puts report[:limitations]
14
+ report[:status] == "pass" ? 0 : 1
15
+ rescue StandardError
16
+ io.puts "UNKNOWN: Unable to inspect routing. Check ADDRESS, WORKER_NAME, and Cloudflare management credentials."
17
+ 1
18
+ end
19
+ end
20
+ end
21
+ end
@@ -2,6 +2,8 @@ require "net/http"
2
2
  require "json"
3
3
  require "uri"
4
4
  require "time"
5
+ require "timeout"
6
+ require "zlib"
5
7
  require "cloudflare/email/endpoint"
6
8
 
7
9
  module Cloudflare
@@ -11,33 +13,47 @@ module Cloudflare
11
13
  DEFAULT_RETRIES = 3
12
14
  DEFAULT_TIMEOUT = 30
13
15
  DEFAULT_BACKOFF = 0.5
16
+ DEFAULT_MAX_RESPONSE_BYTES = 1_048_576
14
17
  MAX_RETRY_AFTER = 60 # seconds; never sleep longer than this even if server says so
15
18
 
16
19
  RETRYABLE_NETWORK = [
17
20
  Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNRESET,
18
21
  Errno::ECONNREFUSED, Errno::EHOSTUNREACH, EOFError, SocketError,
19
- IOError
22
+ IOError, Timeout::Error, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
23
+ OpenSSL::SSL::SSLError, Zlib::Error
20
24
  ].freeze
21
25
  PRE_SEND_NETWORK = [Net::OpenTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError].freeze
22
26
 
23
- attr_reader :account_id, :base_url, :retries, :timeout, :retry_ambiguous
27
+ attr_reader :account_id, :base_url, :retries, :timeout, :retry_ambiguous,
28
+ :total_timeout, :max_response_bytes
24
29
 
25
30
  def initialize(account_id:, api_token:, base_url: DEFAULT_BASE_URL,
26
31
  retries: DEFAULT_RETRIES, timeout: DEFAULT_TIMEOUT,
27
32
  initial_backoff: DEFAULT_BACKOFF, max_retry_after: MAX_RETRY_AFTER,
28
- retry_ambiguous: false, logger: nil)
33
+ retry_ambiguous: false, logger: nil,
34
+ total_timeout: timeout, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES)
29
35
  unless account_id.is_a?(String) && account_id.match?(/\A[a-zA-Z0-9_-]+\z/)
30
36
  raise ConfigurationError, "account_id is required and must be a single account identifier"
31
37
  end
32
38
  unless api_token.is_a?(String) && !api_token.empty? && !api_token.match?(/\s/)
33
39
  raise ConfigurationError, "api_token is required and must not contain whitespace"
34
40
  end
41
+ [[:timeout, timeout], [:total_timeout, total_timeout]].each do |name, value|
42
+ unless value.is_a?(Numeric) && value.real? && value.finite? && value.positive?
43
+ raise ConfigurationError, "#{name} must be a finite positive number"
44
+ end
45
+ end
46
+ unless max_response_bytes.is_a?(Integer) && max_response_bytes.positive?
47
+ raise ConfigurationError, "max_response_bytes must be a positive integer"
48
+ end
35
49
 
36
50
  @account_id = account_id
37
51
  @api_token = api_token
38
52
  @base_url = Endpoint.parse(base_url).to_s.delete_suffix("/")
39
53
  @retries = retries
40
54
  @timeout = timeout
55
+ @total_timeout = total_timeout
56
+ @max_response_bytes = max_response_bytes
41
57
  @initial_backoff = initial_backoff
42
58
  @max_retry_after = max_retry_after
43
59
  @logger = logger
@@ -143,9 +159,9 @@ module Cloudflare
143
159
  do_request(method, uri, body)
144
160
  rescue *RETRYABLE_NETWORK => e
145
161
  unless PRE_SEND_NETWORK.any? { |type| e.is_a?(type) } || @retry_ambiguous
146
- raise NetworkError.new("#{e.message}; delivery outcome is unknown; automatic retry disabled")
162
+ raise NetworkError.new("provider response could not be read; delivery outcome is unknown; automatic retry disabled"), cause: nil
147
163
  end
148
- raise NetworkError.new(e.message) if attempts > @retries
164
+ raise NetworkError.new("provider request could not be completed; request outcome is unknown"), cause: nil if attempts > @retries
149
165
  log_retry(attempts, e)
150
166
  sleep(backoff); backoff *= 2
151
167
  retry
@@ -194,13 +210,32 @@ module Cloudflare
194
210
  req["User-Agent"] = "cloudflare-email-ruby/#{Cloudflare::Email::VERSION}"
195
211
  req.body = JSON.generate(body) if body
196
212
 
197
- response = http.request(req)
198
- handle_response(response)
213
+ raw = +"".b
214
+ response = nil
215
+ byte_limit = response_byte_limit(uri)
216
+ # Bound each network attempt, including trickling headers/body. Parsing,
217
+ # queue handlers, and database transactions are outside this deadline.
218
+ Timeout.timeout(@total_timeout) do
219
+ http.request(req) do |incoming|
220
+ response = incoming
221
+ incoming.read_body do |chunk|
222
+ # Net::HTTP yields decompressed bytes, so compressed expansion is
223
+ # subject to the same budget. Check before extending the buffer.
224
+ raise IOError, "provider response exceeds byte limit" if raw.bytesize + chunk.bytesize > byte_limit
225
+ raw << chunk
226
+ end
227
+ end
228
+ end
229
+ handle_response(response, raw)
230
+ end
231
+
232
+ def response_byte_limit(_uri)
233
+ @max_response_bytes
199
234
  end
200
235
 
201
- def handle_response(response)
236
+ def handle_response(response, raw = response.body)
202
237
  status = response.code.to_i
203
- body = parse_body(response.body)
238
+ body = parse_body(raw)
204
239
  retry_after = response["Retry-After"]
205
240
 
206
241
  # Stash Retry-After on the error response so retry logic can use it.
@@ -236,7 +271,7 @@ module Cloudflare
236
271
  return {} if raw.nil? || raw.empty?
237
272
  JSON.parse(raw)
238
273
  rescue JSON::ParserError
239
- { "errors" => [{ "message" => raw.to_s[0, 200] }] }
274
+ { "errors" => [{ "message" => "invalid JSON API response; request outcome is unknown" }] }
240
275
  end
241
276
 
242
277
  def extract_message(body)
@@ -25,6 +25,8 @@ module Cloudflare
25
25
  base_url: settings[:base_url] || Cloudflare::Email::Client::DEFAULT_BASE_URL,
26
26
  retries: settings.fetch(:retries, Cloudflare::Email::Client::DEFAULT_RETRIES),
27
27
  timeout: settings.fetch(:timeout, Cloudflare::Email::Client::DEFAULT_TIMEOUT),
28
+ total_timeout: settings.fetch(:total_timeout, settings.fetch(:timeout, Cloudflare::Email::Client::DEFAULT_TIMEOUT)),
29
+ max_response_bytes: settings.fetch(:max_response_bytes, Cloudflare::Email::Client::DEFAULT_MAX_RESPONSE_BYTES),
28
30
  initial_backoff: settings.fetch(:initial_backoff, Cloudflare::Email::Client::DEFAULT_BACKOFF),
29
31
  max_retry_after: settings.fetch(:max_retry_after, Cloudflare::Email::Client::MAX_RETRY_AFTER),
30
32
  retry_ambiguous: settings.fetch(:retry_ambiguous, false),
@@ -6,8 +6,8 @@ module Cloudflare
6
6
  # `bin/rails cloudflare:email:deploy_worker` — uploads the Worker
7
7
  # script + both secrets via the Cloudflare API. No wrangler required.
8
8
  class DeployWorkerTask < TaskBase
9
- def self.call(script_path: nil, ingress_url: nil, io: $stdout)
10
- new(io: io, script_path: script_path, ingress_url: ingress_url).call
9
+ def self.call(script_path: nil, ingress_url: nil, delivery_mode: "durable", io: $stdout)
10
+ new(io: io, script_path: script_path, ingress_url: ingress_url, delivery_mode: delivery_mode).call
11
11
  end
12
12
 
13
13
  protected
@@ -28,7 +28,7 @@ module Cloudflare
28
28
  )
29
29
 
30
30
  say " Deploying Worker '#{deployer.script_name}'..."
31
- deployer.deploy(script_path: path)
31
+ deployer.deploy(script_path: path, delivery_mode: opts[:delivery_mode])
32
32
  say " ✓ Worker script deployed"
33
33
 
34
34
  deployer.put_secret("INGRESS_SECRET", ingress_secret)
@@ -1,4 +1,5 @@
1
1
  require "rails/engine"
2
+ require "mailbox_kit/railtie"
2
3
  require "cloudflare/email/dev_ingress_guard"
3
4
 
4
5
  # Register the delivery method at engine load time (not inside an initializer)
@@ -18,20 +19,12 @@ module Cloudflare
18
19
  app.middleware.insert_before 0, DevIngressGuard if Rails.env.development?
19
20
  end
20
21
 
21
- config.to_prepare do
22
- if (defined?(Cloudflare::Email::Tenancy) && Cloudflare::Email::Tenancy.enabled?) ||
23
- (defined?(Cloudflare::Email::Mailboxes) && Cloudflare::Email::Mailboxes.enabled?)
24
- require "cloudflare/email/tenant_job_context"
25
- Cloudflare::Email::TenantJobContext.install_framework_jobs!
26
- end
27
- if defined?(Cloudflare::Email::Mailboxes) && Cloudflare::Email::Mailboxes.enabled? && defined?(::ActionMailbox::Engine)
28
- require "cloudflare/email/mailboxes/inbound_retention"
29
- ::ActionMailbox::InboundEmail.prepend(Cloudflare::Email::Mailboxes::InboundRetention) unless
30
- ::ActionMailbox::InboundEmail.ancestors.include?(Cloudflare::Email::Mailboxes::InboundRetention)
31
- end
32
- end
33
-
34
22
  config.before_initialize do
23
+ unless defined?(Cloudflare::Email::Management::Engine)
24
+ Rails.autoloaders.main.ignore(
25
+ File.expand_path("../../../app/controllers/cloudflare/email/management", __dir__),
26
+ )
27
+ end
35
28
  unless defined?(::ActionMailbox::Engine)
36
29
  Rails.autoloaders.main.ignore(
37
30
  File.expand_path("../../../app/controllers/cloudflare/email/ingress_controller.rb", __dir__),
@@ -1,3 +1,4 @@
1
+ require "mailbox_kit/address_syntax"
1
2
  require "json"
2
3
  require "cloudflare/email/signing"
3
4
 
@@ -8,19 +9,8 @@ module Cloudflare
8
9
  module Envelope
9
10
  METADATA_KEY = "cloudflare_email_envelope".freeze
10
11
  MAX_ENCODED_BYTES = 1024
11
- LOCAL_PART = /\A[A-Za-z0-9.!#$%&'*+\/=\?^_`{|}~-]+\z/
12
- DOMAIN_LABEL = /\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\z/
13
-
14
12
  def self.valid_address?(address, allow_empty: false)
15
- return false unless address.is_a?(String) && address.ascii_only?
16
- return true if allow_empty && address.empty?
17
- return false if address.bytesize > 254
18
- parts = address.split("@", -1)
19
- return false unless parts.size == 2
20
- local, domain = parts
21
- local.bytesize <= 64 && LOCAL_PART.match?(local) &&
22
- !local.start_with?(".") && !local.end_with?(".") && !local.include?("..") &&
23
- domain.split(".", -1).all? { |label| DOMAIN_LABEL.match?(label) }
13
+ MailboxKit::AddressSyntax.valid_address?(address, allow_empty: allow_empty)
24
14
  end
25
15
 
26
16
  def self.valid?(value)
@@ -45,11 +35,11 @@ module Cloudflare
45
35
  nil
46
36
  end
47
37
 
48
- # Only metadata written after v2 verification is trusted. MIME headers
38
+ # Only metadata written after v2/v3 verification is trusted. MIME headers
49
39
  # (including X-CF-* headers) never participate in this lookup.
50
40
  def self.for(inbound_email)
51
41
  metadata = inbound_email.raw_email.blob&.metadata&.fetch(METADATA_KEY, nil)
52
- return nil unless metadata.is_a?(Hash) && metadata["version"] == 2
42
+ return nil unless metadata.is_a?(Hash) && [2, 3].include?(metadata["version"])
53
43
  value = metadata.reject { |key, _| key == "version" }
54
44
  valid?(value) ? value : nil
55
45
  end
@@ -1,18 +1,11 @@
1
+ require "mailbox_kit/error"
2
+
1
3
  module Cloudflare
2
4
  module Email
3
- class Error < StandardError
4
- attr_reader :response, :status
5
-
6
- def initialize(message = nil, status: nil, response: nil)
7
- super(message)
8
- @status = status
9
- @response = response
10
- end
11
- end
12
-
13
- class ConfigurationError < Error; end
5
+ Error = MailboxKit::Error
6
+ ConfigurationError = MailboxKit::ConfigurationError
14
7
  class AuthenticationError < Error; end
15
- class ValidationError < Error; end
8
+ ValidationError = MailboxKit::ValidationError
16
9
  class RateLimitError < Error; end
17
10
  class ServerError < Error; end
18
11
  class NetworkError < Error; end
@@ -9,9 +9,12 @@ module Cloudflare
9
9
  # Handler/parse/ack failures propagate; unacknowledged leases expire and
10
10
  # are redelivered according to the queue's retry/dead-letter policy.
11
11
  class EventConsumer < Client
12
- def initialize(queue_id:, domains: nil, **options)
12
+ # Up to 100 queue messages, including encoded payloads and metadata.
13
+ DEFAULT_MAX_RESPONSE_BYTES = 32 * 1_048_576
14
+
15
+ def initialize(queue_id:, domains: nil, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, **options)
13
16
  raise ConfigurationError, "queue_id is required" unless queue_id.to_s.match?(/\A[a-zA-Z0-9_-]+\z/)
14
- super(**options.merge(retries: 0, retry_ambiguous: false))
17
+ super(**options.merge(retries: 0, retry_ambiguous: false, max_response_bytes: max_response_bytes))
15
18
  @queue_id = queue_id
16
19
  @domains = domains && Array(domains).map(&:downcase)
17
20
  end
@@ -53,6 +56,12 @@ module Cloudflare
53
56
 
54
57
  private
55
58
 
59
+ def response_byte_limit(uri)
60
+ # ACKs contain counts, never a queue batch. Keep their smaller budget
61
+ # even when a pull needs room for 100 encoded event payloads.
62
+ uri.path.end_with?("/messages/ack") ? [@max_response_bytes, Client::DEFAULT_MAX_RESPONSE_BYTES].min : @max_response_bytes
63
+ end
64
+
56
65
  def queue_path(action)
57
66
  "/accounts/#{account_id}/queues/#{@queue_id}/messages/#{action}"
58
67
  end
@@ -79,7 +88,7 @@ module Cloudflare
79
88
  end
80
89
  DeliveryEvent.new(body)
81
90
  rescue JSON::ParserError, ArgumentError, TypeError
82
- raise ValidationError, "queue body must contain a valid JSON Email Sending event"
91
+ raise ValidationError, "queue body must contain a valid JSON Email Sending event", cause: nil
83
92
  end
84
93
  end
85
94
  end
@@ -0,0 +1,126 @@
1
+ require "digest"
2
+ require "cloudflare/email/verification"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ # Shared bounded verification for custom Rack/Rails ingestion endpoints.
7
+ # This API deliberately performs no domain lookup or business processing.
8
+ module Ingress
9
+ DEFAULT_MAX_EMAIL_BYTES = 25 * 1024 * 1024
10
+ Result = Struct.new(:status, :message, :bytes, keyword_init: true)
11
+
12
+ class VerifiedMessage
13
+ attr_reader :body, :envelope, :provider_metadata, :storage_metadata, :message_checksum
14
+
15
+ def initialize(body:, envelope:, provider_metadata:, version:)
16
+ @body = body.b.freeze
17
+ @envelope = Ingress.deep_freeze(envelope)
18
+ @provider_metadata = Ingress.deep_freeze(provider_metadata)
19
+ @storage_metadata = {Envelope::METADATA_KEY => envelope.merge("version" => version.to_i)}
20
+ if provider_metadata
21
+ @storage_metadata[ProviderMetadata::METADATA_KEY] = provider_metadata.merge("signature_version" => 3)
22
+ end
23
+ Ingress.deep_freeze(@storage_metadata)
24
+ identity = if version == "3"
25
+ "v3\0#{JSON.generate([envelope.fetch('from'), envelope.fetch('to'), ProviderMetadata.canonical(provider_metadata)])}\0".b
26
+ else
27
+ "v2\0#{envelope.fetch('to')}\0".b
28
+ end
29
+ @message_checksum = Digest::SHA256.hexdigest(identity + @body).freeze
30
+ freeze
31
+ end
32
+ private_class_method :new
33
+
34
+ # Call within Mailboxes.receive when using the optional registry so the
35
+ # framework records and memberships share the resolved tenant context.
36
+ # Custom endpoints may instead persist body/metadata in their own store.
37
+ def persist_action_mailbox!
38
+ result = persist_rails_record
39
+ result.record if result.created?
40
+ end
41
+
42
+ # Verify once, resolve the envelope recipient before storage, then use
43
+ # the kit's ordinary Rails membership path. A duplicate can repair a
44
+ # missing membership without scheduling Rails processing again.
45
+ def receive_into_mailbox!
46
+ result = nil
47
+ Mailboxes.receive(recipient: envelope.fetch("to")) do
48
+ result = persist_rails_record
49
+ result.record
50
+ end
51
+ result.record if result.created?
52
+ end
53
+
54
+ private
55
+
56
+ def persist_rails_record
57
+ require "mailbox_kit/inbound_email"
58
+ MailboxKit::InboundEmail.persist(source: body, message_checksum: message_checksum,
59
+ message_id: stable_message_id, metadata: storage_metadata)
60
+ end
61
+
62
+ # ActionMailbox's fallback includes Socket.gethostname. A replay on a
63
+ # different Rails host must use the same database identity, while the
64
+ # original MIME stays untouched. Preserve parseable provider IDs.
65
+ def stable_message_id
66
+ parsed = begin
67
+ ::Mail.from_source(body).message_id
68
+ rescue StandardError
69
+ nil
70
+ end
71
+ parsed || "#{message_checksum}@cloudflare-email.invalid"
72
+ end
73
+ end
74
+
75
+ def self.verify(secret:, headers:, body:, content_length: nil,
76
+ max_email_bytes: DEFAULT_MAX_EMAIL_BYTES, now: Time.now.to_i, window: Verification::DEFAULT_WINDOW)
77
+ unless max_email_bytes.is_a?(Integer) && max_email_bytes.positive?
78
+ raise ArgumentError, "max_email_bytes must be a positive integer"
79
+ end
80
+ values = %w[Timestamp Signature Signature-Version Envelope Metadata].to_h do |name|
81
+ key = "X-CF-Email-#{name}"
82
+ value = headers[key] || headers["HTTP_#{key.upcase.tr('-', '_')}"]
83
+ [name, value.is_a?(String) ? value.dup.freeze : value]
84
+ end
85
+ options = {secret: secret, timestamp: values["Timestamp"], signature: values["Signature"],
86
+ version: values["Signature-Version"], envelope: values["Envelope"], metadata: values["Metadata"],
87
+ now: now, window: window}
88
+ status = Verification.verify_headers(**options)
89
+ return Result.new(status: status).freeze unless status == :ok
90
+ if content_length
91
+ length = Integer(content_length.to_s, exception: false)
92
+ return Result.new(status: :bad_signature).freeze unless length && length >= 0
93
+ return Result.new(status: :too_large).freeze if length > max_email_bytes
94
+ end
95
+ raw = if body.is_a?(String)
96
+ return Result.new(status: :too_large).freeze if body.bytesize > max_email_bytes
97
+ body.dup.b
98
+ elsif body.respond_to?(:read)
99
+ buffer = +"".b
100
+ while buffer.bytesize <= max_email_bytes
101
+ chunk = body.read(max_email_bytes + 1 - buffer.bytesize)
102
+ break if chunk.nil? || chunk.empty?
103
+ buffer << chunk.b
104
+ end
105
+ buffer
106
+ else
107
+ raise ArgumentError, "body must be a String or readable IO"
108
+ end
109
+ return Result.new(status: :too_large).freeze if raw.bytesize > max_email_bytes
110
+ status = Verification.verify(body: raw, **options)
111
+ return Result.new(status: status, bytes: raw.bytesize).freeze unless status == :ok
112
+ message = VerifiedMessage.send(:new, body: raw, version: values["Signature-Version"],
113
+ envelope: Envelope.decode(values["Envelope"]), provider_metadata: ProviderMetadata.decode(values["Metadata"]))
114
+ Result.new(status: :ok, message: message, bytes: raw.bytesize).freeze
115
+ end
116
+
117
+ def self.deep_freeze(value)
118
+ case value
119
+ when Hash then value.each { |key, item| deep_freeze(key); deep_freeze(item) }
120
+ when Array then value.each { |item| deep_freeze(item) }
121
+ end
122
+ value.freeze
123
+ end
124
+ end
125
+ end
126
+ end
@@ -1,28 +1,23 @@
1
- require "active_record"
1
+ require "mailbox_kit/mailboxes/configuration"
2
2
  require "cloudflare/email/error"
3
3
 
4
4
  module Cloudflare
5
5
  module Email
6
+ Mailboxes = MailboxKit::Mailboxes unless const_defined?(:Mailboxes, false)
6
7
  module Mailboxes
7
- class Unavailable < Cloudflare::Email::Error; end
8
8
 
9
- class << self
9
+ module CloudflareConfiguration
10
10
  def configure(directory_base: ::ActiveRecord::Base, client_resolver: nil)
11
- if const_defined?(:ReceivingDomain, false)
12
- raise ConfigurationError, "configure mailboxes before loading mailbox models"
13
- end
14
- unless directory_base.is_a?(Class) && directory_base <= ::ActiveRecord::Base
15
- raise ConfigurationError, "directory_base must be an ActiveRecord base class"
16
- end
17
11
  if client_resolver && !client_resolver.respond_to?(:call)
18
12
  raise ConfigurationError, "client_resolver must be callable"
19
13
  end
20
- @directory_base = directory_base
14
+ super(directory_base: directory_base)
21
15
  @client_resolver = client_resolver
22
16
  end
17
+ end
18
+ singleton_class.prepend(CloudflareConfiguration)
23
19
 
24
- def directory_base = @directory_base || ::ActiveRecord::Base
25
-
20
+ class << self
26
21
  def delivery_handler = rails_setting(:outbox_delivery_handler)
27
22
  def recipient_handler = rails_setting(:outbox_recipient_handler)
28
23
 
@@ -42,9 +37,6 @@ module Cloudflare
42
37
  client
43
38
  end
44
39
 
45
- def enabled? = @enabled == true
46
- def enable! = @enabled = true
47
-
48
40
  private
49
41
 
50
42
  def rails_setting(name)
@@ -1,14 +1 @@
1
- module Cloudflare
2
- module Email
3
- module Mailboxes
4
- # ActionMailbox normally deletes processed mail after its retention window.
5
- # A mailbox membership owns the raw source until explicit application purge.
6
- module InboundRetention
7
- def incinerate
8
- return if Mailboxes.enabled? && Message.where(inbound_email_id: id).exists?
9
- super
10
- end
11
- end
12
- end
13
- end
14
- end
1
+ require "mailbox_kit/mailboxes/inbound_retention"