cloudflare-email 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +287 -385
  4. data/SECURITY.md +51 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +33 -21
  6. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
  7. data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
  8. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
  9. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
  10. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
  11. data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
  12. data/docs/activerecord-tenanted.md +181 -0
  13. data/docs/architecture.md +105 -0
  14. data/docs/custom-ingress.md +207 -0
  15. data/docs/delivery-events.md +186 -0
  16. data/docs/features.md +138 -0
  17. data/docs/getting-started.md +308 -0
  18. data/docs/mailboxes.md +363 -0
  19. data/docs/management-engine.md +190 -0
  20. data/docs/outbox.md +175 -0
  21. data/docs/routing-deliveries.md +94 -0
  22. data/docs/routing-diagnostics.md +68 -0
  23. data/docs/tenant-mailboxes-plan.md +149 -0
  24. data/docs/thread-correlation.md +27 -0
  25. data/docs/troubleshooting.md +104 -0
  26. data/docs/upgrading-0.2.md +101 -0
  27. data/docs/upgrading-0.3.md +55 -0
  28. data/docs/verification/2026-09-10-followup.md +49 -0
  29. data/docs/verification/2026-09-10-install-upgrade.md +86 -0
  30. data/docs/verification/2026-09-10-live.md +102 -0
  31. data/docs/verification/2026-09-10.md +175 -0
  32. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  33. data/docs/verification/2026-09-11-extraction.md +32 -0
  34. data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
  35. data/docs/verification/2026-09-11-security.md +76 -0
  36. data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
  37. data/docs/verification/2026-09-12-destination-context.md +46 -0
  38. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  39. data/examples/plain_ruby.rb +24 -0
  40. data/lib/cloudflare/email/active_record/base.rb +60 -0
  41. data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
  42. data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
  43. data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
  44. data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
  45. data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
  46. data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
  47. data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
  48. data/lib/cloudflare/email/active_record/outbox.rb +219 -0
  49. data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
  50. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  51. data/lib/cloudflare/email/active_record.rb +5 -0
  52. data/lib/cloudflare/email/check_route_task.rb +21 -0
  53. data/lib/cloudflare/email/client.rb +93 -18
  54. data/lib/cloudflare/email/consume_events_task.rb +22 -0
  55. data/lib/cloudflare/email/delivery_event.rb +70 -0
  56. data/lib/cloudflare/email/delivery_method.rb +5 -0
  57. data/lib/cloudflare/email/deploy_worker_task.rb +6 -3
  58. data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
  59. data/lib/cloudflare/email/dev_tunnel.rb +31 -5
  60. data/lib/cloudflare/email/doctor.rb +16 -38
  61. data/lib/cloudflare/email/endpoint.rb +24 -0
  62. data/lib/cloudflare/email/engine.rb +33 -0
  63. data/lib/cloudflare/email/envelope.rb +58 -0
  64. data/lib/cloudflare/email/event_consumer.rb +95 -0
  65. data/lib/cloudflare/email/ingress.rb +115 -0
  66. data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
  67. data/lib/cloudflare/email/mailboxes/events.rb +112 -0
  68. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
  69. data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
  70. data/lib/cloudflare/email/mailboxes/models.rb +165 -0
  71. data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
  72. data/lib/cloudflare/email/mailboxes/service.rb +372 -0
  73. data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
  74. data/lib/cloudflare/email/mailboxes.rb +11 -0
  75. data/lib/cloudflare/email/management/adapter.rb +31 -0
  76. data/lib/cloudflare/email/management/configuration.rb +17 -0
  77. data/lib/cloudflare/email/management/engine.rb +15 -0
  78. data/lib/cloudflare/email/management/management.css +68 -0
  79. data/lib/cloudflare/email/management/routes.rb +14 -0
  80. data/lib/cloudflare/email/management.rb +7 -0
  81. data/lib/cloudflare/email/message_id.rb +12 -0
  82. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  83. data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
  84. data/lib/cloudflare/email/provision_route_task.rb +2 -1
  85. data/lib/cloudflare/email/replay_events_job.rb +18 -0
  86. data/lib/cloudflare/email/response.rb +21 -3
  87. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  88. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  89. data/lib/cloudflare/email/routing_provisioner.rb +71 -45
  90. data/lib/cloudflare/email/send_job.rb +41 -0
  91. data/lib/cloudflare/email/send_test.rb +5 -29
  92. data/lib/cloudflare/email/signing.rb +2 -2
  93. data/lib/cloudflare/email/tenancy.rb +78 -0
  94. data/lib/cloudflare/email/tenant_job_context.rb +90 -0
  95. data/lib/cloudflare/email/verification.rb +38 -8
  96. data/lib/cloudflare/email/version.rb +1 -1
  97. data/lib/cloudflare/email/worker_deployer.rb +41 -8
  98. data/lib/cloudflare-email.rb +4 -1
  99. data/lib/generators/cloudflare/email/install_generator.rb +53 -37
  100. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  101. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  102. data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
  103. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +58 -0
  104. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
  105. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
  106. data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
  107. data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
  108. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  109. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  110. data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
  111. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
  112. data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
  113. data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
  114. data/lib/tasks/cloudflare_email.rake +42 -4
  115. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  116. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  117. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  118. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  119. data/templates/deploy-to-cloudflare/README.md +84 -0
  120. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  121. data/templates/deploy-to-cloudflare/package.json +36 -0
  122. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  123. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  124. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  125. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  126. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  127. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  128. data/templates/worker/README.md +231 -16
  129. data/templates/worker/docs/durable-inbound.md +149 -0
  130. data/templates/worker/package-lock.json +2627 -0
  131. data/templates/worker/package.json +8 -5
  132. data/templates/worker/scripts/wrangler.mjs +16 -0
  133. data/templates/worker/src/index.js +397 -25
  134. data/templates/worker/test/durable.test.ts +193 -0
  135. data/templates/worker/test/index.test.ts +183 -13
  136. data/templates/worker/test/relay.test.ts +226 -0
  137. data/templates/worker/wrangler.toml +63 -4
  138. metadata +154 -7
  139. data/lib/cloudflare/email/secure_message_id.rb +0 -89
@@ -1,6 +1,10 @@
1
1
  require "net/http"
2
2
  require "json"
3
3
  require "uri"
4
+ require "time"
5
+ require "timeout"
6
+ require "zlib"
7
+ require "cloudflare/email/endpoint"
4
8
 
5
9
  module Cloudflare
6
10
  module Email
@@ -9,42 +13,67 @@ module Cloudflare
9
13
  DEFAULT_RETRIES = 3
10
14
  DEFAULT_TIMEOUT = 30
11
15
  DEFAULT_BACKOFF = 0.5
16
+ DEFAULT_MAX_RESPONSE_BYTES = 1_048_576
12
17
  MAX_RETRY_AFTER = 60 # seconds; never sleep longer than this even if server says so
13
18
 
14
19
  RETRYABLE_NETWORK = [
15
- Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET,
20
+ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNRESET,
16
21
  Errno::ECONNREFUSED, Errno::EHOSTUNREACH, EOFError, SocketError,
17
- IOError
22
+ IOError, Timeout::Error, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
23
+ OpenSSL::SSL::SSLError, Zlib::Error
18
24
  ].freeze
25
+ PRE_SEND_NETWORK = [Net::OpenTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError].freeze
19
26
 
20
- attr_reader :account_id, :base_url, :retries, :timeout
27
+ attr_reader :account_id, :base_url, :retries, :timeout, :retry_ambiguous,
28
+ :total_timeout, :max_response_bytes
21
29
 
22
30
  def initialize(account_id:, api_token:, base_url: DEFAULT_BASE_URL,
23
31
  retries: DEFAULT_RETRIES, timeout: DEFAULT_TIMEOUT,
24
32
  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?
33
+ retry_ambiguous: false, logger: nil,
34
+ total_timeout: timeout, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES)
35
+ unless account_id.is_a?(String) && account_id.match?(/\A[a-zA-Z0-9_-]+\z/)
36
+ raise ConfigurationError, "account_id is required and must be a single account identifier"
37
+ end
38
+ unless api_token.is_a?(String) && !api_token.empty? && !api_token.match?(/\s/)
39
+ raise ConfigurationError, "api_token is required and must not contain whitespace"
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
28
49
 
29
50
  @account_id = account_id
30
51
  @api_token = api_token
31
- @base_url = base_url
52
+ @base_url = Endpoint.parse(base_url).to_s.delete_suffix("/")
32
53
  @retries = retries
33
54
  @timeout = timeout
55
+ @total_timeout = total_timeout
56
+ @max_response_bytes = max_response_bytes
34
57
  @initial_backoff = initial_backoff
35
58
  @max_retry_after = max_retry_after
36
59
  @logger = logger
60
+ @retry_ambiguous = retry_ambiguous
61
+ end
62
+
63
+ def inspect
64
+ "#<#{self.class.name} account_id=#{@account_id.inspect} api_token=[REDACTED]>"
37
65
  end
38
66
 
39
- def send(from:, to:, subject:, text: nil, html: nil, cc: nil, bcc: nil,
67
+ def send(from:, subject:, to: nil, text: nil, html: nil, cc: nil, bcc: nil,
40
68
  reply_to: nil, headers: nil, attachments: nil)
41
69
  raise ValidationError, "must provide :text or :html" if text.nil? && html.nil?
70
+ raise ValidationError, "must provide a recipient in :to, :cc, or :bcc" if [to, cc, bcc].all? { |v| wrap(v).empty? }
42
71
 
43
72
  body = {
44
73
  from: normalize_address(from),
45
- to: wrap(to).map { |addr| normalize_address(addr) },
46
74
  subject: subject,
47
75
  }
76
+ body[:to] = wrap(to).map { |addr| normalize_address(addr) } if to
48
77
  body[:text] = text if text
49
78
  body[:html] = html if html
50
79
  body[:cc] = wrap(cc).map { |a| normalize_address(a) } if cc
@@ -104,6 +133,10 @@ module Cloudflare
104
133
  response = request(:post, path, body)
105
134
  payload[:status] = response.status
106
135
  payload[:message_id] = response.message_id
136
+ payload[:delivered] = response.delivered
137
+ payload[:queued] = response.queued
138
+ payload[:permanent_bounces] = response.permanent_bounces
139
+ payload[:suppressed_recipients] = response.suppressed_recipients
107
140
  response
108
141
  end
109
142
  end
@@ -125,7 +158,10 @@ module Cloudflare
125
158
  attempts += 1
126
159
  do_request(method, uri, body)
127
160
  rescue *RETRYABLE_NETWORK => e
128
- raise NetworkError.new(e.message) if attempts > @retries
161
+ unless PRE_SEND_NETWORK.any? { |type| e.is_a?(type) } || @retry_ambiguous
162
+ raise NetworkError.new("provider response could not be read; delivery outcome is unknown; automatic retry disabled"), cause: nil
163
+ end
164
+ raise NetworkError.new("provider request could not be completed; request outcome is unknown"), cause: nil if attempts > @retries
129
165
  log_retry(attempts, e)
130
166
  sleep(backoff); backoff *= 2
131
167
  retry
@@ -135,7 +171,7 @@ module Cloudflare
135
171
  sleep(retry_after_from(e, backoff)); backoff *= 2
136
172
  retry
137
173
  rescue ServerError => e
138
- raise if attempts > @retries
174
+ raise unless @retry_ambiguous && attempts <= @retries
139
175
  log_retry(attempts, e)
140
176
  sleep(backoff); backoff *= 2
141
177
  retry
@@ -145,7 +181,15 @@ module Cloudflare
145
181
  def retry_after_from(error, fallback_backoff)
146
182
  header = error.response.is_a?(Hash) ? error.response["retry_after"] : nil
147
183
  value = header || fallback_backoff
148
- seconds = value.to_f
184
+ seconds = if value.to_s.match?(/\A\d+(?:\.\d+)?\z/)
185
+ value.to_f
186
+ else
187
+ begin
188
+ Time.httpdate(value.to_s) - Time.now
189
+ rescue ArgumentError
190
+ fallback_backoff
191
+ end
192
+ end
149
193
  return fallback_backoff if seconds <= 0
150
194
  [seconds, @max_retry_after].min
151
195
  end
@@ -155,6 +199,8 @@ module Cloudflare
155
199
  http.use_ssl = (uri.scheme == "https")
156
200
  http.open_timeout = @timeout
157
201
  http.read_timeout = @timeout
202
+ http.write_timeout = @timeout
203
+ http.max_retries = 0
158
204
 
159
205
  req_class = { post: Net::HTTP::Post, get: Net::HTTP::Get }.fetch(method)
160
206
  req = req_class.new(uri.request_uri)
@@ -164,13 +210,32 @@ module Cloudflare
164
210
  req["User-Agent"] = "cloudflare-email-ruby/#{Cloudflare::Email::VERSION}"
165
211
  req.body = JSON.generate(body) if body
166
212
 
167
- response = http.request(req)
168
- 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
169
234
  end
170
235
 
171
- def handle_response(response)
236
+ def handle_response(response, raw = response.body)
172
237
  status = response.code.to_i
173
- body = parse_body(response.body)
238
+ body = parse_body(raw)
174
239
  retry_after = response["Retry-After"]
175
240
 
176
241
  # Stash Retry-After on the error response so retry logic can use it.
@@ -178,6 +243,15 @@ module Cloudflare
178
243
 
179
244
  case status
180
245
  when 200..299
246
+ if body.is_a?(Hash) && body.key?("success") && body["success"] != true
247
+ raise Error.new(extract_message(body), status: status, response: body)
248
+ end
249
+ unless body.is_a?(Hash) && body["result"].is_a?(Hash)
250
+ raise Error.new("invalid successful API response; request outcome is unknown", status: status, response: body)
251
+ end
252
+ if body["errors"].is_a?(Array) && body["errors"].any?
253
+ raise Error.new(extract_message(body), status: status, response: body)
254
+ end
181
255
  Response.new(body, status: status)
182
256
  when 400, 422
183
257
  raise ValidationError.new(extract_message(body), status: status, response: body)
@@ -197,7 +271,7 @@ module Cloudflare
197
271
  return {} if raw.nil? || raw.empty?
198
272
  JSON.parse(raw)
199
273
  rescue JSON::ParserError
200
- { "errors" => [{ "message" => raw.to_s[0, 200] }] }
274
+ { "errors" => [{ "message" => "invalid JSON API response; request outcome is unknown" }] }
201
275
  end
202
276
 
203
277
  def extract_message(body)
@@ -209,7 +283,8 @@ module Cloudflare
209
283
 
210
284
  def log_retry(attempt, error)
211
285
  return unless @logger
212
- @logger.warn("[cloudflare-email] retry #{attempt}: #{error.class}: #{error.message}")
286
+ # Provider messages can echo message content or other sensitive data.
287
+ @logger.warn("[cloudflare-email] retry #{attempt}: #{error.class}")
213
288
  end
214
289
  end
215
290
  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,11 @@ 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),
30
+ initial_backoff: settings.fetch(:initial_backoff, Cloudflare::Email::Client::DEFAULT_BACKOFF),
31
+ max_retry_after: settings.fetch(:max_retry_after, Cloudflare::Email::Client::MAX_RETRY_AFTER),
32
+ retry_ambiguous: settings.fetch(:retry_ambiguous, false),
28
33
  logger: settings[:logger],
29
34
  )
30
35
 
@@ -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
@@ -20,12 +20,15 @@ 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
  )
26
29
 
27
30
  say " Deploying Worker '#{deployer.script_name}'..."
28
- deployer.deploy(script_path: path)
31
+ deployer.deploy(script_path: path, delivery_mode: opts[:delivery_mode])
29
32
  say " ✓ Worker script deployed"
30
33
 
31
34
  deployer.put_secret("INGRESS_SECRET", ingress_secret)
@@ -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
@@ -64,7 +64,7 @@ module Cloudflare
64
64
  if api_token.to_s.empty?
65
65
  record("credentials.cloudflare.api_token", FAIL, "missing — run bin/rails credentials:edit")
66
66
  else
67
- record("credentials.cloudflare.api_token", OK, "#{api_token[0, 8]}...")
67
+ record("credentials.cloudflare.api_token", OK, "set (redacted)")
68
68
  end
69
69
  end
70
70
 
@@ -89,47 +89,20 @@ module Cloudflare
89
89
  if response[:ok]
90
90
  record("Account accessible", OK, response[:body].dig("result", "name") || account_id)
91
91
  elsif response[:status] == 403
92
- # Narrowly-scoped send-only tokens don't have account read permission;
93
- # that's a feature, not a bug. We already confirmed the token is valid.
94
- record("Account accessible", OK, "send-scoped token (no account read — this is fine)")
92
+ # Send-scoped tokens may lack account read permission. A 403 cannot
93
+ # establish that this token can send for the configured account.
94
+ record("Account accessible", SKIP, "403 — account access unverified; token may lack account read permission")
95
95
  else
96
96
  record("Account accessible", FAIL, extract_error(response))
97
97
  end
98
98
  end
99
99
 
100
100
  def check_sending_domains
101
- token = credential(:api_token)
102
- account_id = credential(:account_id)
103
- return record("Sending domains", SKIP, "missing credentials") if token.to_s.empty? || account_id.to_s.empty?
104
-
105
- response = request("GET", "/accounts/#{account_id}/email/sending/domains", token: token)
106
- if response[:status] == 403 || response[:status] == 404
107
- record("Sending domains", SKIP, "send-scoped token can't list domains (check the dashboard instead)")
108
- return
109
- end
110
-
111
- unless response[:ok]
112
- record("Sending domains", WARN, "could not list: #{extract_error(response)}")
113
- return
114
- end
115
-
116
- domains = Array(response[:body]["result"])
117
- if domains.empty?
118
- record("Sending domains", WARN, "no sending domains set up — add one in the dashboard")
119
- return
120
- end
121
-
122
- verified = domains.select { |d| d["verified"] == true || d["status"] == "verified" }
123
- if verified.any?
124
- names = verified.map { |d| d["name"] || d["domain"] }.compact.join(", ")
125
- record("Sending domains", OK, "#{verified.size} verified (#{names})")
126
- else
127
- names = domains.map { |d| d["name"] || d["domain"] }.compact.join(", ")
128
- record("Sending domains", WARN, "#{domains.size} configured but none verified (#{names})")
129
- end
101
+ record("Sending domains", SKIP, "verify your sender domain in Email Sending in the Cloudflare dashboard")
130
102
  end
131
103
 
132
104
  def check_ingress_secret
105
+ return record("Ingress secret", SKIP, "Cloudflare inbound routing not selected") unless inbound_enabled?
133
106
  require "cloudflare/email/credentials"
134
107
  secret = Cloudflare::Email::Credentials.ingress_secret
135
108
 
@@ -143,6 +116,7 @@ module Cloudflare
143
116
  end
144
117
 
145
118
  def check_token_split
119
+ return record("Token split", SKIP, "send-only setup; management token optional") unless inbound_enabled?
146
120
  require "cloudflare/email/credentials"
147
121
  if Cloudflare::Email::Credentials.split_tokens?
148
122
  record("Token split", OK, "separate management_token set (good security posture)")
@@ -160,12 +134,16 @@ module Cloudflare
160
134
  when :cloudflare
161
135
  record("ActionMailbox ingress", OK, ":cloudflare")
162
136
  when nil
163
- record("ActionMailbox ingress", WARN, "nil — inbound will 404. Set config.action_mailbox.ingress = :cloudflare")
137
+ record("ActionMailbox ingress", SKIP, "inbound routing not configured")
164
138
  else
165
- record("ActionMailbox ingress", WARN, "#{ActionMailbox.ingress.inspect} — not :cloudflare, our controller will 404")
139
+ record("ActionMailbox ingress", SKIP, "#{ActionMailbox.ingress.inspect} — Cloudflare inbound routing not selected")
166
140
  end
167
141
  end
168
142
 
143
+ def inbound_enabled?
144
+ defined?(ActionMailbox) && ActionMailbox.respond_to?(:ingress) && ActionMailbox.ingress == :cloudflare
145
+ end
146
+
169
147
  def check_delivery_method_registered
170
148
  return record("Delivery method :cloudflare", SKIP, "ActionMailer not loaded") unless defined?(ActionMailer)
171
149
 
@@ -188,9 +166,9 @@ module Cloudflare
188
166
  warn_count = @results.count { |r| r[:status] == WARN }
189
167
 
190
168
  if fail_count.zero? && warn_count.zero?
191
- @io.puts " Everything looks good."
169
+ @io.puts " Completed checks passed. Review skipped checks; live delivery is not verified."
192
170
  elsif fail_count.zero?
193
- @io.puts " #{warn_count} warning(s). Setup is usable but incomplete."
171
+ @io.puts " #{warn_count} warning(s). Review warnings and skipped checks before relying on delivery."
194
172
  else
195
173
  @io.puts " #{fail_count} failure(s), #{warn_count} warning(s). Fix failures before sending."
196
174
  end
@@ -228,7 +206,7 @@ module Cloudflare
228
206
 
229
207
  response = http.request(req)
230
208
  body = JSON.parse(response.body) rescue {}
231
- { ok: response.code.to_i.between?(200, 299), status: response.code.to_i, body: body }
209
+ { ok: response.code.to_i.between?(200, 299) && body.is_a?(Hash) && body["success"] != false, status: response.code.to_i, body: body }
232
210
  rescue StandardError => e
233
211
  { ok: false, status: 0, body: { "errors" => [{ "message" => e.message }] } }
234
212
  end
@@ -0,0 +1,24 @@
1
+ require "uri"
2
+ require "cloudflare/email/error"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ # Endpoints are trusted operator configuration, never message-derived URLs.
7
+ # Plain HTTP is only suitable for literal loopback development fixtures.
8
+ module Endpoint
9
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
10
+
11
+ def self.parse(value, allow_loopback: true)
12
+ uri = URI.parse(value.to_s)
13
+ secure = uri.is_a?(URI::HTTPS)
14
+ local = allow_loopback && uri.is_a?(URI::HTTP) && LOOPBACK_HOSTS.include?(uri.hostname.to_s.downcase)
15
+ unless (secure || local) && !uri.hostname.to_s.empty? && !uri.userinfo && !uri.query && !uri.fragment
16
+ raise ConfigurationError, "endpoint must use HTTPS (HTTP only on literal loopback), without credentials, query or fragment"
17
+ end
18
+ uri
19
+ rescue URI::InvalidURIError
20
+ raise ConfigurationError, "endpoint must be a valid HTTPS URL"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,4 +1,5 @@
1
1
  require "rails/engine"
2
+ require "cloudflare/email/dev_ingress_guard"
2
3
 
3
4
  # Register the delivery method at engine load time (not inside an initializer)
4
5
  # so the `cloudflare_settings=` accessor exists before Rails' own
@@ -13,7 +14,39 @@ module Cloudflare
13
14
  class Engine < ::Rails::Engine
14
15
  isolate_namespace Cloudflare::Email
15
16
 
17
+ initializer "cloudflare-email.development_ingress_guard" do |app|
18
+ app.middleware.insert_before 0, DevIngressGuard if Rails.env.development?
19
+ end
20
+
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
+ config.before_initialize do
35
+ unless defined?(Cloudflare::Email::Management::Engine)
36
+ Rails.autoloaders.main.ignore(
37
+ File.expand_path("../../../app/controllers/cloudflare/email/management", __dir__),
38
+ )
39
+ end
40
+ unless defined?(::ActionMailbox::Engine)
41
+ Rails.autoloaders.main.ignore(
42
+ File.expand_path("../../../app/controllers/cloudflare/email/ingress_controller.rb", __dir__),
43
+ )
44
+ end
45
+ end
46
+
16
47
  initializer "cloudflare-email.routes" do |app|
48
+ next unless defined?(::ActionMailbox::Engine)
49
+
17
50
  app.routes.append do
18
51
  post "/rails/action_mailbox/cloudflare/inbound_emails",
19
52
  to: "cloudflare/email/ingress#create",