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
@@ -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,34 @@ 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?(::ActionMailbox::Engine)
36
+ Rails.autoloaders.main.ignore(
37
+ File.expand_path("../../../app/controllers/cloudflare/email/ingress_controller.rb", __dir__),
38
+ )
39
+ end
40
+ end
41
+
16
42
  initializer "cloudflare-email.routes" do |app|
43
+ next unless defined?(::ActionMailbox::Engine)
44
+
17
45
  app.routes.append do
18
46
  post "/rails/action_mailbox/cloudflare/inbound_emails",
19
47
  to: "cloudflare/email/ingress#create",
@@ -0,0 +1,58 @@
1
+ require "json"
2
+ require "cloudflare/email/signing"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ # Authenticated SMTP routing metadata, kept separate from sender-supplied MIME.
7
+ # v2 encodes exact {"from": string, "to": string} JSON as unpadded base64url.
8
+ module Envelope
9
+ METADATA_KEY = "cloudflare_email_envelope".freeze
10
+ 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
+ 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) }
24
+ end
25
+
26
+ def self.valid?(value)
27
+ value.is_a?(Hash) && value.size == 2 && value.key?("from") && value.key?("to") &&
28
+ valid_address?(value["from"], allow_empty: true) && valid_address?(value["to"])
29
+ end
30
+
31
+ def self.encode(from:, to:)
32
+ value = { "from" => from, "to" => to }
33
+ raise ArgumentError, "invalid SMTP envelope" unless valid?(value)
34
+ Signing.base64url_encode(JSON.generate(value))
35
+ end
36
+
37
+ def self.decode(encoded)
38
+ return nil unless encoded.is_a?(String) && encoded.bytesize.between?(1, MAX_ENCODED_BYTES)
39
+ return nil unless /\A[A-Za-z0-9_-]+\z/.match?(encoded)
40
+ decoded = Signing.base64url_decode(encoded)
41
+ return nil unless Signing.base64url_encode(decoded) == encoded
42
+ value = JSON.parse(decoded)
43
+ valid?(value) ? value : nil
44
+ rescue JSON::ParserError, ArgumentError
45
+ nil
46
+ end
47
+
48
+ # Only metadata written after v2 verification is trusted. MIME headers
49
+ # (including X-CF-* headers) never participate in this lookup.
50
+ def self.for(inbound_email)
51
+ metadata = inbound_email.raw_email.blob&.metadata&.fetch(METADATA_KEY, nil)
52
+ return nil unless metadata.is_a?(Hash) && metadata["version"] == 2
53
+ value = metadata.reject { |key, _| key == "version" }
54
+ valid?(value) ? value : nil
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,86 @@
1
+ require "base64"
2
+ require "cloudflare/email/client"
3
+ require "cloudflare/email/delivery_event"
4
+
5
+ module Cloudflare
6
+ module Email
7
+ # Short-poll a dedicated Email Sending event queue from Ruby/Rails.
8
+ # Each message is acknowledged only after the caller's block succeeds.
9
+ # Handler/parse/ack failures propagate; unacknowledged leases expire and
10
+ # are redelivered according to the queue's retry/dead-letter policy.
11
+ class EventConsumer < Client
12
+ def initialize(queue_id:, domains: nil, **options)
13
+ 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))
15
+ @queue_id = queue_id
16
+ @domains = domains && Array(domains).map(&:downcase)
17
+ end
18
+
19
+ def poll(batch_size: 5, visibility_timeout_ms: 300_000)
20
+ raise ArgumentError, "a delivery event handler block is required" unless block_given?
21
+ unless batch_size.is_a?(Integer) && (1..100).cover?(batch_size)
22
+ raise ArgumentError, "batch_size must be between 1 and 100"
23
+ end
24
+ unless visibility_timeout_ms.is_a?(Integer) && (1_000..43_200_000).cover?(visibility_timeout_ms)
25
+ raise ArgumentError, "visibility_timeout_ms must be between 1000 and 43200000"
26
+ end
27
+
28
+ response = request(:post, queue_path("pull"), {
29
+ batch_size: batch_size, visibility_timeout_ms: visibility_timeout_ms,
30
+ })
31
+ messages = response.result["messages"]
32
+ raise Error, "invalid queue pull response: messages must be an array" unless messages.is_a?(Array)
33
+
34
+ messages.each do |message|
35
+ unless message.is_a?(Hash) && message["lease_id"].is_a?(String) && !message["lease_id"].empty?
36
+ raise ValidationError, "queue message is missing its lease_id"
37
+ end
38
+ event = decode_event(message)
39
+ unless event.account_id == account_id && (!@domains || @domains.include?(event.domain.to_s.downcase))
40
+ raise ValidationError, "Email Sending event does not match configured account/domain"
41
+ end
42
+ instrument("cloudflare_email.delivery_event", event_id: event.event_id,
43
+ message_id: event.message_id, status: event.status) do
44
+ yield event
45
+ end
46
+ ack = request(:post, queue_path("ack"), { acks: [{ lease_id: message["lease_id"] }], retries: [] })
47
+ unless ack.result["ackCount"] == 1
48
+ raise Error.new("queue did not acknowledge the message; it may be redelivered", response: ack.raw, status: ack.status)
49
+ end
50
+ end
51
+ messages.length
52
+ end
53
+
54
+ private
55
+
56
+ def queue_path(action)
57
+ "/accounts/#{account_id}/queues/#{@queue_id}/messages/#{action}"
58
+ end
59
+
60
+ def decode_event(message)
61
+ body = message["body"]
62
+ metadata = message["metadata"]
63
+ content_type = metadata.is_a?(Hash) ? metadata["CF-Content-Type"] : nil
64
+ case content_type
65
+ when "json"
66
+ # Email Sending subscriptions return plain JSON strings from HTTP
67
+ # pull. Also accept the base64 JSON transport used by other producers.
68
+ begin
69
+ body = JSON.parse(body)
70
+ rescue JSON::ParserError
71
+ body = JSON.parse(Base64.strict_decode64(body))
72
+ end
73
+ when "bytes"
74
+ body = JSON.parse(Base64.strict_decode64(body))
75
+ when "text"
76
+ body = JSON.parse(body)
77
+ else
78
+ raise ValidationError, "unsupported queue content type; use json, bytes, or text"
79
+ end
80
+ DeliveryEvent.new(body)
81
+ rescue JSON::ParserError, ArgumentError, TypeError
82
+ raise ValidationError, "queue body must contain a valid JSON Email Sending event"
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,58 @@
1
+ require "active_record"
2
+ require "cloudflare/email/error"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ module Mailboxes
7
+ class Unavailable < Cloudflare::Email::Error; end
8
+
9
+ class << self
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
+ if client_resolver && !client_resolver.respond_to?(:call)
18
+ raise ConfigurationError, "client_resolver must be callable"
19
+ end
20
+ @directory_base = directory_base
21
+ @client_resolver = client_resolver
22
+ end
23
+
24
+ def directory_base = @directory_base || ::ActiveRecord::Base
25
+
26
+ def delivery_handler = rails_setting(:outbox_delivery_handler)
27
+ def recipient_handler = rails_setting(:outbox_recipient_handler)
28
+
29
+ def client_for(tenant_key, account_id)
30
+ client = if @client_resolver
31
+ @client_resolver.call(tenant_key, account_id)
32
+ else
33
+ require "cloudflare/email/credentials"
34
+ unless Credentials.account_id == account_id
35
+ raise ConfigurationError, "configure a tenant/account client_resolver for this sending account"
36
+ end
37
+ Client.new(account_id: account_id, api_token: Credentials.api_token, retry_ambiguous: false)
38
+ end
39
+ unless client.account_id == account_id && client.retry_ambiguous == false
40
+ raise ConfigurationError, "mailbox client must match account and disable ambiguous retries"
41
+ end
42
+ client
43
+ end
44
+
45
+ def enabled? = @enabled == true
46
+ def enable! = @enabled = true
47
+
48
+ private
49
+
50
+ def rails_setting(name)
51
+ if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
52
+ ::Rails.application.config.x.cloudflare_email.public_send(name)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,112 @@
1
+ require "cloudflare/email/active_record/delivery_events"
2
+ require "cloudflare/email/mailboxes/shared_event_receipt"
3
+ require "cloudflare/email/mailboxes/provider_correlation"
4
+
5
+ module Cloudflare
6
+ module Email
7
+ module Mailboxes
8
+ # Queue ACK follows shared intake. Projection is separately replayable:
9
+ # a crash between tenant commit and shared completion simply replays the
10
+ # tenant's idempotent receipt. No distributed transaction is assumed.
11
+ class Events
12
+ class << self
13
+ def record(event)
14
+ outside_shared_transaction!
15
+ event = DeliveryEvent.new(event.raw)
16
+ receipt = SharedEventReceipt.create_or_find_by!(account_id: event.account_id, event_id: event.event_id) do |row|
17
+ row.message_id = MessageId.normalize(event.message_id)
18
+ row.recipient = normalize_recipient(event.recipient)
19
+ row.payload_json = JSON.generate(event.raw)
20
+ row.state = "pending"
21
+ end
22
+ raise ValidationError, "event ID already recorded with a different payload" unless receipt.event.raw == event.raw
23
+ receipt
24
+ end
25
+
26
+ # Call after provider acceptance has committed in the tenant database.
27
+ # Duplicate registrations are harmless; collisions remain visible and
28
+ # deliberately prevent routing rather than selecting an arbitrary tenant.
29
+ def register(delivery, tenant_key: Tenancy.require_context!)
30
+ assert_context!(tenant_key)
31
+ outside_shared_transaction!
32
+ raise ArgumentError, "registration must follow the tenant commit" if delivery.class.connection.transaction_open?
33
+ delivery.reload
34
+ unless delivery.persisted? && %w[accepted partial].include?(delivery.state) && !delivery.provider_message_id.to_s.empty?
35
+ raise ArgumentError, "expected a committed accepted delivery with a provider message ID"
36
+ end
37
+ unless OutboundMessage.where(tenant_key: tenant_key, outbound_delivery_id: delivery.id).exists?
38
+ raise ArgumentError, "delivery does not belong to a mailbox in the current tenant"
39
+ end
40
+ delivery.outbound_recipients.map do |recipient|
41
+ ProviderCorrelation.create_or_find_by!(account_id: delivery.account_id,
42
+ message_id: MessageId.normalize(delivery.provider_message_id), recipient: normalize_recipient(recipient.recipient),
43
+ tenant_key: tenant_key.to_s, outbound_delivery_id: delivery.id)
44
+ end
45
+ end
46
+
47
+ def apply(receipt, &on_change)
48
+ outside_shared_transaction!
49
+ raise ArgumentError, "expected a persisted shared receipt" unless receipt.is_a?(SharedEventReceipt) && receipt.persisted?
50
+ receipt.reload
51
+ return receipt if receipt.state == "applied"
52
+
53
+ candidates = ProviderCorrelation.where(account_id: receipt.account_id,
54
+ message_id: receipt.message_id, recipient: receipt.recipient).limit(2).to_a
55
+ outcome = :unmatched
56
+ if candidates.length == 1
57
+ correlation = candidates.first
58
+ outcome = Tenancy.with(correlation.tenant_key) do
59
+ project(receipt.event, correlation, &on_change)
60
+ end
61
+ end
62
+ # Never overwrite another worker's completed projection.
63
+ SharedEventReceipt.where(id: receipt.id, state: %w[pending unmatched]).update_all(
64
+ state: outcome.to_s, applied_at: outcome == :applied ? Time.now.utc : nil, updated_at: Time.now.utc)
65
+ receipt.reload
66
+ end
67
+
68
+ # A bounded page, not an unbounded find_each scan. Use the last scanned
69
+ # ID as after_id for the next page, and restart at zero on the next pass.
70
+ def replay(limit: 100, after_id: 0, account_id: nil, &on_change)
71
+ raise ArgumentError, "limit must be between 1 and 1000" unless limit.is_a?(Integer) && (1..1000).cover?(limit)
72
+ raise ArgumentError, "after_id must be nonnegative" unless after_id.is_a?(Integer) && after_id >= 0
73
+ scope = SharedEventReceipt.where(state: %w[pending unmatched]).where("id > ?", after_id)
74
+ scope = scope.where(account_id: account_id) if account_id
75
+ scope.order(:id).limit(limit).to_a.each { |receipt| apply(receipt, &on_change) }
76
+ end
77
+
78
+ private
79
+
80
+ def outside_shared_transaction!
81
+ if SharedEventReceipt.connection.transaction_open?
82
+ raise ArgumentError, "shared event processing must run outside an existing database transaction"
83
+ end
84
+ end
85
+
86
+ def normalize_recipient(value)
87
+ ActiveRecord::Outbox.normalize_recipient(value)
88
+ end
89
+
90
+ def assert_context!(key)
91
+ raise ConfigurationError, "delivery belongs to another tenant" unless Tenancy.require_context! == key
92
+ end
93
+
94
+ def project(event, correlation, &on_change)
95
+ assert_context!(correlation.tenant_key)
96
+ return :unmatched unless OutboundMessage.where(tenant_key: correlation.tenant_key,
97
+ outbound_delivery_id: correlation.outbound_delivery_id).exists?
98
+ delivery = ActiveRecord::OutboundDelivery.find_by(id: correlation.outbound_delivery_id,
99
+ account_id: event.account_id, provider_message_id: MessageId.normalize(event.message_id), state: %w[accepted partial])
100
+ return :unmatched unless delivery && delivery.outbound_recipients.exists?(recipient: normalize_recipient(event.recipient))
101
+ return :unmatched unless ReceivingDomain.active.where(tenant_key: correlation.tenant_key,
102
+ account_id: event.account_id, domain: delivery.from_address.rpartition("@").last.downcase).exists?
103
+ # DeliveryEvents also refuses collisions within the tenant. Its
104
+ # transactional receipt protects callback writes against redelivery.
105
+ receipt = ActiveRecord::DeliveryEvents.record(event, &on_change)
106
+ receipt.state == "applied" ? :applied : :unmatched
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,14 @@
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
@@ -0,0 +1,34 @@
1
+ require "active_job"
2
+
3
+ module Cloudflare
4
+ module Email
5
+ module Mailboxes
6
+ class SendJob < ::ActiveJob::Base
7
+ queue_as :mailers
8
+
9
+ def perform(tenant_key, mailbox_id, operation_key)
10
+ Mailboxes.for_tenant(tenant_key) { |session| session.deliver(mailbox_id, operation_key: operation_key) }
11
+ end
12
+ end
13
+
14
+ class RecoverJob < ::ActiveJob::Base
15
+ queue_as :mailers
16
+
17
+ def perform(tenant_key, after_id = 0)
18
+ cursor = Mailboxes.for_tenant(tenant_key) { |session| session.recover(after_id: after_id) }
19
+ self.class.perform_later(tenant_key, cursor) if cursor
20
+ end
21
+ end
22
+
23
+ class ReplayEventsJob < ::ActiveJob::Base
24
+ queue_as :mailers
25
+
26
+ def perform(after_id = 0)
27
+ handler = Mailboxes.recipient_handler
28
+ page = Events.replay(after_id: after_id, &(handler.method(:call) if handler.respond_to?(:call)))
29
+ self.class.perform_later(page.last.id) if page.length == 100
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end