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
@@ -0,0 +1,219 @@
1
+ require "active_record"
2
+ require "digest"
3
+ require "cloudflare-email"
4
+ require "cloudflare/email/active_record/outbound_delivery"
5
+ require "cloudflare/email/active_record/outbound_recipient"
6
+ require "cloudflare/email/active_record/outbound_reconciliation"
7
+
8
+ module Cloudflare
9
+ module Email
10
+ module ActiveRecord
11
+ # Durable, single-attempt boundary. A retry job may revisit an accepted
12
+ # operation, but uncertainty always requires an explicit operator decision.
13
+ class Outbox
14
+ class Error < Cloudflare::Email::Error; end
15
+ class InvalidTransition < Error; end
16
+ class SnapshotConflict < Error; end
17
+
18
+ class << self
19
+ def acceptance_state(states)
20
+ aggregate(states)
21
+ end
22
+
23
+ def normalize_recipient(value)
24
+ local, separator, domain = value.to_s.rpartition("@")
25
+ separator.empty? ? value.to_s : "#{local}@#{domain.downcase}"
26
+ end
27
+
28
+ def prepare(account_id:, operation_key:, from:, recipients:, mime_message:)
29
+ [account_id, operation_key, from].each do |value|
30
+ raise ArgumentError, "account, operation key and sender must be nonempty strings" unless value.is_a?(String) && !value.strip.empty?
31
+ end
32
+ unless recipients.is_a?(Array) && recipients.any? && recipients.all? { |v| v.is_a?(String) && !v.strip.empty? }
33
+ raise ArgumentError, "recipients must be a nonempty array of addresses"
34
+ end
35
+ raise ArgumentError, "mime_message must be a nonempty String" unless mime_message.is_a?(String) && !mime_message.empty?
36
+ addresses = recipients.map { |address| normalize_recipient(address) }.uniq
37
+ json = JSON.generate(addresses)
38
+ digest = Digest::SHA256.hexdigest(JSON.generate([account_id, from, addresses]) + "\0" + mime_message.b)
39
+ OutboundDelivery.transaction do
40
+ delivery = OutboundDelivery.create_or_find_by!(account_id: account_id, operation_key: operation_key) do |row|
41
+ row.assign_attributes(from_address: from, recipients_json: json, mime_message: mime_message.b,
42
+ snapshot_digest: digest, state: "prepared")
43
+ end
44
+ raise SnapshotConflict, "operation key already has a different immutable message" unless delivery.snapshot_digest == digest
45
+ addresses.each { |address| delivery.outbound_recipients.create_or_find_by!(recipient: address) }
46
+ delivery
47
+ end
48
+ end
49
+
50
+ def deliver(delivery, client: nil)
51
+ persisted!(delivery)
52
+ outside_transaction!
53
+ delivery.reload
54
+ return delivery if %w[accepted partial].include?(delivery.state)
55
+ client ||= Client.new(account_id: Credentials.account_id, api_token: Credentials.api_token)
56
+ unless client.account_id.to_s == delivery.account_id && client.respond_to?(:retry_ambiguous) && client.retry_ambiguous == false
57
+ raise ConfigurationError, "outbox client must match the account and disable ambiguous retries"
58
+ end
59
+ now = Time.now.utc
60
+ claimed = OutboundDelivery.where(id: delivery.id, state: "prepared").update_all(state: "sending", request_started_at: now, updated_at: now)
61
+ unless claimed == 1
62
+ delivery.reload
63
+ return delivery if %w[accepted partial].include?(delivery.state)
64
+ raise InvalidTransition, "cannot send an operation in #{delivery.state}; uncertainty requires reconciliation"
65
+ end
66
+
67
+ # Everything after this durable claim can leave an ambiguous outcome.
68
+ begin
69
+ response = client.send_raw(from: delivery.from_address, recipients: delivery.recipients, mime_message: delivery.mime_message)
70
+ rescue StandardError => error
71
+ record_failure(delivery, error)
72
+ raise
73
+ end
74
+ begin
75
+ outcomes, provider_message_id = response_outcomes(response, delivery.recipients)
76
+ delivery.with_lock do
77
+ raise InvalidTransition, "send claim changed during delivery" unless delivery.state == "sending"
78
+ outcomes.each do |address, state|
79
+ delivery.outbound_recipients.find_by!(recipient: address).update!(state: state, acceptance_state: state, occurred_at: nil,
80
+ terminal: %w[delivered bounced suppressed].include?(state))
81
+ end
82
+ delivery.update!(state: aggregate(outcomes.values), provider_message_id: provider_message_id,
83
+ response_json: JSON.generate(response.to_h), completed_at: Time.now.utc, error_class: nil)
84
+ end
85
+ rescue StandardError => error
86
+ # Even a local validation/persistence error here follows provider
87
+ # acceptance. Never convert it into permission to resend.
88
+ mark_unknown(delivery.id, error)
89
+ raise
90
+ end
91
+ delivery.reload
92
+ end
93
+
94
+ def reconcile(delivery, outcome:, actor:, reason:, evidence:, provider_message_id: nil, recipients: nil, confirm_sender_stopped: false)
95
+ persisted!(delivery)
96
+ raise ArgumentError, "outcome must be accepted or not_sent" unless %w[accepted not_sent].include?(outcome.to_s)
97
+ [actor, reason, evidence].each do |value|
98
+ raise ArgumentError, "actor, reason and evidence are required strings" unless value.is_a?(String) && !value.strip.empty?
99
+ end
100
+ provider_message_id = validated_message_id(provider_message_id)
101
+ if outcome.to_s == "accepted" && provider_message_id.nil?
102
+ raise ArgumentError, "accepted reconciliation requires a provider message ID"
103
+ end
104
+ completed = false
105
+ delivery.with_lock(requires_new: true) do
106
+ allowed = %w[unknown partial].include?(delivery.state) || (delivery.state == "sending" && confirm_sender_stopped == true &&
107
+ delivery.request_started_at && delivery.request_started_at <= Time.now.utc - 900)
108
+ raise InvalidTransition, "only uncertain operations may be reconciled; a sending process must be confirmed stopped and at least 15 minutes old" unless allowed
109
+ rows = delivery.outbound_recipients.to_a
110
+ unresolved = rows.select { |row| %w[unknown prepared sending].include?(row.acceptance_state) }.map(&:recipient)
111
+ selected = recipients || unresolved
112
+ selected = selected.map { |address| normalize_recipient(address) } if selected.is_a?(Array)
113
+ unless selected.is_a?(Array) && selected.any? && (selected - unresolved).empty? && selected.uniq == selected
114
+ raise ArgumentError, "recipients must be unique unresolved snapshot recipients"
115
+ end
116
+ existing_id = validated_message_id(delivery.provider_message_id.presence)
117
+ if provider_message_id && existing_id && provider_message_id != existing_id
118
+ raise ArgumentError, "provider message ID conflicts with the existing operation"
119
+ end
120
+ delivery.outbound_reconciliations.create!(actor: actor, reason: reason, evidence: evidence,
121
+ outcome: outcome.to_s, provider_message_id: provider_message_id, recipients_json: JSON.generate(selected))
122
+ states = rows.map do |recipient|
123
+ unless selected.include?(recipient.recipient)
124
+ recipient.update!(state: "unknown", acceptance_state: "unknown") if %w[prepared sending].include?(recipient.acceptance_state)
125
+ next recipient.acceptance_state
126
+ end
127
+ state = outcome.to_s == "not_sent" ? "not_sent" : "accepted"
128
+ recipient.update!(state: state, acceptance_state: state, occurred_at: nil, terminal: state == "not_sent")
129
+ state
130
+ end
131
+ delivery.update!(state: states.all? { |state| state == "not_sent" } ? "confirmed_not_sent" : aggregate(states),
132
+ provider_message_id: provider_message_id || existing_id, completed_at: Time.now.utc)
133
+ yield delivery if block_given?
134
+ completed = true
135
+ end
136
+ raise InvalidTransition, "reconciliation rolled back instead of completing" unless completed
137
+ delivery.reload
138
+ end
139
+
140
+ private
141
+
142
+ def persisted!(delivery)
143
+ raise ArgumentError, "expected a persisted OutboundDelivery" unless delivery.is_a?(OutboundDelivery) && delivery.persisted?
144
+ end
145
+
146
+ def outside_transaction!
147
+ raise ArgumentError, "outbox network delivery must run outside an existing database transaction" if OutboundDelivery.connection.transaction_open?
148
+ end
149
+
150
+ def record_failure(delivery, error)
151
+ # Only an explicit provider 4xx rejection proves nonacceptance.
152
+ rejected = error.is_a?(Cloudflare::Email::Error) && [400, 401, 403, 422, 429].include?(error.status)
153
+ OutboundDelivery.transaction do
154
+ count = OutboundDelivery.where(id: delivery.id, state: "sending").update_all(state: rejected ? "rejected" : "unknown",
155
+ error_class: error.class.name, completed_at: Time.now.utc, updated_at: Time.now.utc)
156
+ OutboundRecipient.where(outbound_delivery_id: delivery.id).update_all(state: rejected ? "rejected" : "unknown", acceptance_state: rejected ? "rejected" : "unknown") if count == 1
157
+ end
158
+ rescue StandardError
159
+ # A database outage leaves the already committed sending claim.
160
+ # It is deliberately just as non-retryable as unknown.
161
+ nil
162
+ end
163
+
164
+ def mark_unknown(id, error)
165
+ OutboundDelivery.where(id: id, state: "sending").update_all(state: "unknown", error_class: error.class.name, updated_at: Time.now.utc)
166
+ rescue StandardError
167
+ nil
168
+ end
169
+
170
+ def response_outcomes(response, addresses)
171
+ raise ValidationError, "invalid provider response" unless response.is_a?(Response) && response.success?
172
+ outcomes = addresses.to_h { |address| [address, "unknown"] }
173
+ result = response.result
174
+ ids = [validated_message_id(result["message_id"])]
175
+ groups = { "delivered" => "delivered", "queued" => "queued", "permanent_bounces" => "bounced", "suppressed_recipients" => "suppressed" }
176
+ seen = {}
177
+ groups.each do |key, state|
178
+ entries = result.fetch(key, [])
179
+ raise ValidationError, "invalid recipient outcomes" unless entries.is_a?(Array)
180
+ entries.each do |entry|
181
+ ids << validated_message_id(entry["message_id"]) if entry.is_a?(Hash)
182
+ address = entry.is_a?(Hash) ? (entry["to"] || entry["email"] || entry["recipient"] || entry["address"]) : entry
183
+ address = normalize_recipient(address)
184
+ raise ValidationError, "unknown or conflicting provider recipient" unless addresses.include?(address) && !seen[address]
185
+ seen[address] = true
186
+ outcomes[address] = state
187
+ end
188
+ end
189
+ raise ValidationError, "multiple provider message IDs cannot be correlated to one operation" if ids.compact.uniq.size > 1
190
+ if seen.empty? && ids.compact.any?
191
+ outcomes.transform_values! { "accepted" }
192
+ end
193
+ [outcomes, ids.compact.first]
194
+ end
195
+
196
+ def validated_message_id(value)
197
+ return nil if value.nil?
198
+ unless value.is_a?(String)
199
+ raise ValidationError, "provider message ID must be a nonempty string"
200
+ end
201
+ normalized = MessageId.normalize(value)
202
+ if normalized.empty? || normalized.match?(/[\s<>]/)
203
+ raise ValidationError, "provider message ID must not contain whitespace or brackets"
204
+ end
205
+ normalized
206
+ end
207
+
208
+ def aggregate(states)
209
+ accepted = states.count { |state| %w[accepted delivered queued].include?(state) }
210
+ return "accepted" if accepted == states.size
211
+ return "partial" if accepted.positive?
212
+ return "rejected" if states.all? { |state| %w[bounced suppressed rejected not_sent].include?(state) }
213
+ "unknown"
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,34 @@
1
+ module Cloudflare
2
+ module Email
3
+ module ActiveRecord
4
+ module OutboxNotifications
5
+ def prepare(**options)
6
+ notify_outbox("prepare", account_id: options[:account_id], operation_key: options[:operation_key]) { super }
7
+ end
8
+
9
+ def deliver(delivery, **options)
10
+ notify_outbox("send", account_id: delivery.account_id, operation_key: delivery.operation_key) { super }
11
+ end
12
+
13
+ def reconcile(delivery, **options, &block)
14
+ notify_outbox("reconcile", account_id: delivery.account_id, operation_key: delivery.operation_key) { super }
15
+ end
16
+
17
+ private
18
+
19
+ def notify_outbox(action, **payload)
20
+ return yield unless defined?(::ActiveSupport::Notifications)
21
+
22
+ ::ActiveSupport::Notifications.instrument("cloudflare_email.outbox_#{action}", payload) do |details|
23
+ result = yield
24
+ details[:delivery_id] = result.id
25
+ details[:state] = result.state
26
+ result
27
+ end
28
+ end
29
+ end
30
+
31
+ Outbox.singleton_class.prepend(OutboxNotifications)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,5 @@
1
+ # Optional durable Rails/ActiveRecord integration. Jobs are a separate opt-in.
2
+ require "cloudflare/email/active_record/outbox"
3
+ require "cloudflare/email/active_record/mail_snapshot"
4
+ require "cloudflare/email/active_record/delivery_events"
5
+ require "cloudflare/email/active_record/outbox_notifications"
@@ -1,6 +1,8 @@
1
1
  require "net/http"
2
2
  require "json"
3
3
  require "uri"
4
+ require "time"
5
+ require "cloudflare/email/endpoint"
4
6
 
5
7
  module Cloudflare
6
8
  module Email
@@ -12,39 +14,50 @@ module Cloudflare
12
14
  MAX_RETRY_AFTER = 60 # seconds; never sleep longer than this even if server says so
13
15
 
14
16
  RETRYABLE_NETWORK = [
15
- Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET,
17
+ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNRESET,
16
18
  Errno::ECONNREFUSED, Errno::EHOSTUNREACH, EOFError, SocketError,
17
19
  IOError
18
20
  ].freeze
21
+ PRE_SEND_NETWORK = [Net::OpenTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError].freeze
19
22
 
20
- attr_reader :account_id, :base_url, :retries, :timeout
23
+ attr_reader :account_id, :base_url, :retries, :timeout, :retry_ambiguous
21
24
 
22
25
  def initialize(account_id:, api_token:, base_url: DEFAULT_BASE_URL,
23
26
  retries: DEFAULT_RETRIES, timeout: DEFAULT_TIMEOUT,
24
27
  initial_backoff: DEFAULT_BACKOFF, max_retry_after: MAX_RETRY_AFTER,
25
- logger: nil)
26
- raise ConfigurationError, "account_id is required" if account_id.nil? || account_id.to_s.empty?
27
- raise ConfigurationError, "api_token is required" if api_token.nil? || api_token.to_s.empty?
28
+ retry_ambiguous: false, logger: nil)
29
+ unless account_id.is_a?(String) && account_id.match?(/\A[a-zA-Z0-9_-]+\z/)
30
+ raise ConfigurationError, "account_id is required and must be a single account identifier"
31
+ end
32
+ unless api_token.is_a?(String) && !api_token.empty? && !api_token.match?(/\s/)
33
+ raise ConfigurationError, "api_token is required and must not contain whitespace"
34
+ end
28
35
 
29
36
  @account_id = account_id
30
37
  @api_token = api_token
31
- @base_url = base_url
38
+ @base_url = Endpoint.parse(base_url).to_s.delete_suffix("/")
32
39
  @retries = retries
33
40
  @timeout = timeout
34
41
  @initial_backoff = initial_backoff
35
42
  @max_retry_after = max_retry_after
36
43
  @logger = logger
44
+ @retry_ambiguous = retry_ambiguous
45
+ end
46
+
47
+ def inspect
48
+ "#<#{self.class.name} account_id=#{@account_id.inspect} api_token=[REDACTED]>"
37
49
  end
38
50
 
39
- def send(from:, to:, subject:, text: nil, html: nil, cc: nil, bcc: nil,
51
+ def send(from:, subject:, to: nil, text: nil, html: nil, cc: nil, bcc: nil,
40
52
  reply_to: nil, headers: nil, attachments: nil)
41
53
  raise ValidationError, "must provide :text or :html" if text.nil? && html.nil?
54
+ raise ValidationError, "must provide a recipient in :to, :cc, or :bcc" if [to, cc, bcc].all? { |v| wrap(v).empty? }
42
55
 
43
56
  body = {
44
57
  from: normalize_address(from),
45
- to: wrap(to).map { |addr| normalize_address(addr) },
46
58
  subject: subject,
47
59
  }
60
+ body[:to] = wrap(to).map { |addr| normalize_address(addr) } if to
48
61
  body[:text] = text if text
49
62
  body[:html] = html if html
50
63
  body[:cc] = wrap(cc).map { |a| normalize_address(a) } if cc
@@ -104,6 +117,10 @@ module Cloudflare
104
117
  response = request(:post, path, body)
105
118
  payload[:status] = response.status
106
119
  payload[:message_id] = response.message_id
120
+ payload[:delivered] = response.delivered
121
+ payload[:queued] = response.queued
122
+ payload[:permanent_bounces] = response.permanent_bounces
123
+ payload[:suppressed_recipients] = response.suppressed_recipients
107
124
  response
108
125
  end
109
126
  end
@@ -125,6 +142,9 @@ module Cloudflare
125
142
  attempts += 1
126
143
  do_request(method, uri, body)
127
144
  rescue *RETRYABLE_NETWORK => e
145
+ 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")
147
+ end
128
148
  raise NetworkError.new(e.message) if attempts > @retries
129
149
  log_retry(attempts, e)
130
150
  sleep(backoff); backoff *= 2
@@ -135,7 +155,7 @@ module Cloudflare
135
155
  sleep(retry_after_from(e, backoff)); backoff *= 2
136
156
  retry
137
157
  rescue ServerError => e
138
- raise if attempts > @retries
158
+ raise unless @retry_ambiguous && attempts <= @retries
139
159
  log_retry(attempts, e)
140
160
  sleep(backoff); backoff *= 2
141
161
  retry
@@ -145,7 +165,15 @@ module Cloudflare
145
165
  def retry_after_from(error, fallback_backoff)
146
166
  header = error.response.is_a?(Hash) ? error.response["retry_after"] : nil
147
167
  value = header || fallback_backoff
148
- seconds = value.to_f
168
+ seconds = if value.to_s.match?(/\A\d+(?:\.\d+)?\z/)
169
+ value.to_f
170
+ else
171
+ begin
172
+ Time.httpdate(value.to_s) - Time.now
173
+ rescue ArgumentError
174
+ fallback_backoff
175
+ end
176
+ end
149
177
  return fallback_backoff if seconds <= 0
150
178
  [seconds, @max_retry_after].min
151
179
  end
@@ -155,6 +183,8 @@ module Cloudflare
155
183
  http.use_ssl = (uri.scheme == "https")
156
184
  http.open_timeout = @timeout
157
185
  http.read_timeout = @timeout
186
+ http.write_timeout = @timeout
187
+ http.max_retries = 0
158
188
 
159
189
  req_class = { post: Net::HTTP::Post, get: Net::HTTP::Get }.fetch(method)
160
190
  req = req_class.new(uri.request_uri)
@@ -178,6 +208,15 @@ module Cloudflare
178
208
 
179
209
  case status
180
210
  when 200..299
211
+ if body.is_a?(Hash) && body.key?("success") && body["success"] != true
212
+ raise Error.new(extract_message(body), status: status, response: body)
213
+ end
214
+ unless body.is_a?(Hash) && body["result"].is_a?(Hash)
215
+ raise Error.new("invalid successful API response; request outcome is unknown", status: status, response: body)
216
+ end
217
+ if body["errors"].is_a?(Array) && body["errors"].any?
218
+ raise Error.new(extract_message(body), status: status, response: body)
219
+ end
181
220
  Response.new(body, status: status)
182
221
  when 400, 422
183
222
  raise ValidationError.new(extract_message(body), status: status, response: body)
@@ -209,7 +248,8 @@ module Cloudflare
209
248
 
210
249
  def log_retry(attempt, error)
211
250
  return unless @logger
212
- @logger.warn("[cloudflare-email] retry #{attempt}: #{error.class}: #{error.message}")
251
+ # Provider messages can echo message content or other sensitive data.
252
+ @logger.warn("[cloudflare-email] retry #{attempt}: #{error.class}")
213
253
  end
214
254
  end
215
255
  end
@@ -0,0 +1,22 @@
1
+ require "cloudflare/email/task_base"
2
+ require "cloudflare/email/event_consumer"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ class ConsumeEventsTask < TaskBase
7
+ def run
8
+ handler = opts[:handler]
9
+ raise ConfigurationError, "configure config.x.cloudflare_email.event_handler with a callable" unless handler.respond_to?(:call)
10
+
11
+ consumer = EventConsumer.new(
12
+ account_id: account_id,
13
+ api_token: require_value!(credential(:queues_token), "cloudflare.queues_token / CLOUDFLARE_QUEUES_TOKEN"),
14
+ queue_id: require_value!(credential(:event_queue_id), "cloudflare.event_queue_id / CLOUDFLARE_EVENT_QUEUE_ID"),
15
+ domains: opts[:domains],
16
+ )
17
+ count = consumer.poll(batch_size: opts.fetch(:batch_size, 5), &handler.method(:call))
18
+ say " Processed and acknowledged #{count} delivery event(s)."
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,70 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ # An outbound Email Sending event, delivered through Cloudflare Queues.
7
+ # Treat event_id as an idempotency key; events may arrive more than once
8
+ # or out of order. This is distinct from inbound ActionMailbox messages.
9
+ class DeliveryEvent
10
+ TYPES = %w[delivered deferred bounced failed rejected complained].freeze
11
+ attr_reader :raw
12
+
13
+ def initialize(raw)
14
+ @raw = raw
15
+ unless raw.is_a?(Hash) && raw["type"].is_a?(String) &&
16
+ raw["type"].match?(/\Acf\.email\.sending\.message\.[a-z][a-z0-9_]*\z/) &&
17
+ raw["payload"].is_a?(Hash) && raw["source"].is_a?(Hash) &&
18
+ raw["source"]["type"] == "email.sending" && raw["metadata"].is_a?(Hash) &&
19
+ raw["metadata"]["eventSchemaVersion"] == 1 &&
20
+ [event_id, message_id, account_id, domain, recipient].all? { |value| valid_identifier?(value) } &&
21
+ [true, false].include?(payload["terminal"]) &&
22
+ %w[delivery bounce complaint rejection failure].all? { |key| !payload.key?(key) || payload[key].is_a?(Hash) }
23
+ raise ValidationError, "invalid Email Sending event or unsupported schema version"
24
+ end
25
+ unless occurred_at.is_a?(String) && valid_identifier?(occurred_at)
26
+ raise ValidationError, "delivery event timestamp must be ISO8601"
27
+ end
28
+ Time.iso8601(occurred_at)
29
+ rescue ArgumentError
30
+ raise ValidationError, "delivery event timestamp must be ISO8601"
31
+ end
32
+
33
+ def type = raw["type"]
34
+ def status = type.delete_prefix("cf.email.sending.message.")
35
+ def known? = TYPES.include?(status)
36
+ def payload = raw["payload"]
37
+ def event_id = payload["eventId"]
38
+ def message_id = payload["messageId"]
39
+ def recipient = payload["recipient"]
40
+ def sender = payload["sender"]
41
+ def terminal? = payload["terminal"] == true
42
+ def domain = raw["source"]["domain"]
43
+ def account_id = raw["metadata"]["accountId"]
44
+ def occurred_at = raw["metadata"]["eventTimestamp"]
45
+ def delivery = payload["delivery"] || {}
46
+ def bounce = payload["bounce"] || {}
47
+ def complaint = payload["complaint"] || {}
48
+ def rejection = payload["rejection"] || {}
49
+ def failure = payload["failure"] || {}
50
+
51
+ # Use only after matching account, provider message ID, and recipient.
52
+ # Equal timestamps keep the existing state; unknown future types remain
53
+ # available in receipts but must not replace a recognized delivery state.
54
+ def supersedes?(occurred_at:, terminal:)
55
+ return false unless known?
56
+ return false if terminal && !terminal?
57
+
58
+ incoming = Time.iso8601(self.occurred_at.to_s)
59
+ previous = occurred_at.is_a?(Time) ? occurred_at : Time.iso8601(occurred_at.to_s) unless occurred_at.nil?
60
+ previous.nil? || incoming > previous
61
+ end
62
+
63
+ private
64
+
65
+ def valid_identifier?(value)
66
+ value.is_a?(String) && !value.strip.empty? && !value.match?(/[[:cntrl:]]/)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -25,6 +25,9 @@ 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
+ initial_backoff: settings.fetch(:initial_backoff, Cloudflare::Email::Client::DEFAULT_BACKOFF),
29
+ max_retry_after: settings.fetch(:max_retry_after, Cloudflare::Email::Client::MAX_RETRY_AFTER),
30
+ retry_ambiguous: settings.fetch(:retry_ambiguous, false),
28
31
  logger: settings[:logger],
29
32
  )
30
33
 
@@ -20,6 +20,9 @@ module Cloudflare
20
20
  path = script_path
21
21
  raise "Worker script not found at #{path} — re-run `bin/rails g cloudflare:email:install`" unless File.exist?(path)
22
22
 
23
+ # Validate before uploading code or rotating any deployed secrets.
24
+ Endpoint.parse(url) unless url.to_s.empty?
25
+
23
26
  deployer = Cloudflare::Email::WorkerDeployer.new(
24
27
  account_id: account_id, api_token: management_token,
25
28
  )
@@ -0,0 +1,27 @@
1
+ module Cloudflare
2
+ module Email
3
+ # cloudflared forces this origin Host. Restrict tunnel traffic before Rails
4
+ # routing, debug middleware and normal host authorization execute.
5
+ class DevIngressGuard
6
+ HOST = "cloudflare-email-ingress.localhost".freeze
7
+ PATH = "/rails/action_mailbox/cloudflare/inbound_emails".freeze
8
+ RESPONSE_HEADER = "x-cloudflare-email-ingress-only".freeze
9
+
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ def call(env)
15
+ return @app.call(env) unless env["HTTP_HOST"].to_s.downcase.split(":", 2).first == HOST
16
+ unless env["REQUEST_METHOD"] == "POST" && env["PATH_INFO"] == PATH
17
+ return [404, { "content-type" => "text/plain", "content-length" => "0", RESPONSE_HEADER => "1" }, []]
18
+ end
19
+ forwarded = env.dup
20
+ forwarded["HTTP_HOST"] = "localhost"
21
+ forwarded.delete("HTTP_X_FORWARDED_HOST")
22
+ forwarded.delete("HTTP_FORWARDED")
23
+ @app.call(forwarded)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,4 +1,6 @@
1
1
  require "cloudflare/email/worker_deployer"
2
+ require "tempfile"
3
+ require "cloudflare/email/dev_ingress_guard"
2
4
 
3
5
  module Cloudflare
4
6
  module Email
@@ -13,6 +15,7 @@ module Cloudflare
13
15
  end
14
16
 
15
17
  def initialize(port:, io: $stdout)
18
+ raise ArgumentError, "port must be between 1 and 65535" unless port.is_a?(Integer) && (1..65_535).cover?(port)
16
19
  @port = port
17
20
  @io = io
18
21
  @tunnel_pid = nil
@@ -51,22 +54,45 @@ module Cloudflare
51
54
  private
52
55
 
53
56
  def check_prerequisites
57
+ unless defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
58
+ raise "cloudflare:email:dev only runs in development; use RAILS_ENV=development"
59
+ end
60
+ unless Rails.application.config.respond_to?(:action_mailbox) &&
61
+ Rails.application.config.action_mailbox.ingress == :cloudflare
62
+ raise "Set config.action_mailbox.ingress = :cloudflare in config/environments/development.rb"
63
+ end
64
+
54
65
  unless system("command -v cloudflared >/dev/null 2>&1")
55
66
  raise "cloudflared not found in PATH — install from https://developers.cloudflare.com/cloudflared/"
56
67
  end
57
68
  require "cloudflare/email/credentials"
58
- if Cloudflare::Email::Credentials.account_id.empty? ||
59
- Cloudflare::Email::Credentials.management_token.empty?
69
+ if Cloudflare::Email::Credentials.account_id.to_s.empty? ||
70
+ Cloudflare::Email::Credentials.management_token.to_s.empty?
60
71
  raise "Missing cloudflare.account_id or cloudflare.api_token in credentials " \
61
72
  "(or CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN env vars)"
62
73
  end
74
+ verify_ingress_guard!
75
+ end
76
+
77
+ def verify_ingress_guard!
78
+ http = Net::HTTP.new("127.0.0.1", @port, nil)
79
+ http.open_timeout = http.read_timeout = 2
80
+ request = Net::HTTP::Get.new("/")
81
+ request["Host"] = DevIngressGuard::HOST
82
+ response = http.request(request)
83
+ unless response.code == "404" && response[DevIngressGuard::RESPONSE_HEADER] == "1"
84
+ raise "Restart the development Rails server with the ingress-only middleware before opening a tunnel"
85
+ end
86
+ rescue SystemCallError, IOError, Timeout::Error
87
+ raise "Start the development Rails server with the ingress-only middleware before opening a tunnel"
63
88
  end
64
89
 
65
90
  def start_tunnel
66
91
  @io.puts " Starting cloudflared tunnel on :#{@port}..."
67
- @tunnel_log = File.open("/tmp/cloudflare-email-dev-tunnel.log", "w")
92
+ @tunnel_log = Tempfile.new(["cloudflare-email-dev-tunnel", ".log"])
68
93
  @tunnel_pid = spawn(
69
94
  "cloudflared", "tunnel", "--url", "http://127.0.0.1:#{@port}",
95
+ "--http-host-header", DevIngressGuard::HOST,
70
96
  out: @tunnel_log, err: @tunnel_log,
71
97
  )
72
98
  end
@@ -75,7 +101,7 @@ module Cloudflare
75
101
  deadline = Time.now + 30
76
102
  while Time.now < deadline
77
103
  sleep 0.5
78
- log = File.read("/tmp/cloudflare-email-dev-tunnel.log") rescue ""
104
+ log = File.read(@tunnel_log.path) rescue ""
79
105
  if (match = log.match(%r{https://[a-z0-9\-]+\.trycloudflare\.com}))
80
106
  return match[0]
81
107
  end
@@ -89,7 +115,7 @@ module Cloudflare
89
115
  Process.wait(@tunnel_pid) rescue nil
90
116
  @tunnel_pid = nil
91
117
  end
92
- @tunnel_log&.close
118
+ @tunnel_log&.close!
93
119
  end
94
120
 
95
121
  end