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
@@ -0,0 +1,22 @@
1
+ require "cloudflare/email/active_record/base"
2
+
3
+ module Cloudflare
4
+ module Email
5
+ module ActiveRecord
6
+ class OutboundDelivery < Base
7
+ self.table_name = "cloudflare_email_outbound_deliveries"
8
+ has_many :outbound_recipients, class_name: "Cloudflare::Email::ActiveRecord::OutboundRecipient", dependent: :restrict_with_exception
9
+ has_many :outbound_reconciliations, class_name: "Cloudflare::Email::ActiveRecord::OutboundReconciliation", dependent: :restrict_with_exception
10
+ attr_readonly :account_id, :operation_key, :from_address, :recipients_json, :mime_message, :snapshot_digest
11
+
12
+ def recipients
13
+ JSON.parse(recipients_json)
14
+ end
15
+
16
+ def response
17
+ Response.new(JSON.parse(response_json)) if response_json
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,13 @@
1
+ require "cloudflare/email/active_record/base"
2
+
3
+ module Cloudflare
4
+ module Email
5
+ module ActiveRecord
6
+ class OutboundRecipient < Base
7
+ self.table_name = "cloudflare_email_outbound_recipients"
8
+ belongs_to :outbound_delivery, class_name: "Cloudflare::Email::ActiveRecord::OutboundDelivery"
9
+ attr_readonly :outbound_delivery_id, :recipient
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ require "cloudflare/email/active_record/base"
2
+
3
+ module Cloudflare
4
+ module Email
5
+ module ActiveRecord
6
+ class OutboundReconciliation < Base
7
+ self.table_name = "cloudflare_email_outbound_reconciliations"
8
+ belongs_to :outbound_delivery, class_name: "Cloudflare::Email::ActiveRecord::OutboundDelivery"
9
+ def readonly?
10
+ persisted?
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -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,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cloudflare/email/routing_analytics"
4
+ require "cloudflare/email/active_record/outbox"
5
+
6
+ module Cloudflare
7
+ module Email
8
+ module ActiveRecord
9
+ class RoutingDeliveryReceipt < Base
10
+ self.table_name = "cloudflare_email_routing_delivery_receipts"
11
+ belongs_to :outbound_delivery, class_name: "Cloudflare::Email::ActiveRecord::OutboundDelivery"
12
+ encrypts :payload_json
13
+ attr_readonly :account_id, :zone_id, :event_key, :outbound_delivery_id, :payload_json, :received_at
14
+
15
+ def evidence
16
+ RoutingAnalytics::Evidence.new(JSON.parse(payload_json))
17
+ rescue JSON::ParserError, TypeError
18
+ raise RoutingAnalytics::Error, "Stored Routing analytics evidence is malformed", cause: nil
19
+ end
20
+ end
21
+
22
+ # An opt-in delivery projection, separate from Sending webhook receipts and
23
+ # acceptance reconciliation. No method here can send or authorize a resend.
24
+ class RoutingDeliveries
25
+ class InvalidEvidence < RoutingAnalytics::Error; end
26
+ ReplayResult = Struct.new(:after_id, :processed, :errors, :finished, keyword_init: true)
27
+
28
+ class << self
29
+ def record(delivery:, evidence:)
30
+ unless evidence.is_a?(RoutingAnalytics::Evidence) && delivery.is_a?(OutboundDelivery) && delivery.persisted?
31
+ raise ArgumentError, "A persisted outbound delivery and RoutingAnalytics::Evidence are required"
32
+ end
33
+ if RoutingDeliveryReceipt.connection.transaction_open?
34
+ raise ArgumentError, "record must commit outside an existing transaction before projection"
35
+ end
36
+ validate!(delivery.reload, evidence)
37
+ receipt = RoutingDeliveryReceipt.create_or_find_by!(account_id: evidence.account_id, event_key: evidence.identity) do |row|
38
+ row.outbound_delivery = delivery
39
+ row.zone_id = evidence.zone_id
40
+ row.payload_json = JSON.generate(evidence.payload)
41
+ row.received_at = Time.now.utc
42
+ end
43
+ unless receipt.outbound_delivery_id == delivery.id && receipt.evidence.same_event?(evidence)
44
+ raise InvalidEvidence, "Routing analytics evidence identity conflict"
45
+ end
46
+ receipt
47
+ end
48
+
49
+ # Returns :applied (including idempotent replay). An existing terminal
50
+ # fact is preserved; inspect recipient.state instead of assuming delivery.
51
+ # Only new recipient changes invoke the same-database callback.
52
+ def apply(receipt)
53
+ unless receipt.is_a?(RoutingDeliveryReceipt) && receipt.persisted?
54
+ raise ArgumentError, "A persisted RoutingDeliveryReceipt is required"
55
+ end
56
+ completed = false
57
+ receipt.with_lock(requires_new: true) do
58
+ if receipt.state == "applied"
59
+ completed = true
60
+ next
61
+ end
62
+ evidence = receipt.evidence
63
+ unless receipt.account_id == evidence.account_id && receipt.zone_id == evidence.zone_id && receipt.event_key == evidence.identity
64
+ raise InvalidEvidence, "Routing analytics receipt identity mismatch"
65
+ end
66
+ delivery = receipt.outbound_delivery
67
+ delivery.with_lock do
68
+ recipient = validate!(delivery, evidence)
69
+ unless recipient.terminal?
70
+ recipient.update!(state: "delivered", occurred_at: evidence.occurred_at, terminal: true)
71
+ yield delivery, recipient if block_given?
72
+ end
73
+ receipt.update!(state: "applied", applied_at: Time.now.utc)
74
+ completed = true
75
+ end
76
+ # ActiveRecord::Rollback can be swallowed by the nested lock's
77
+ # transaction. Fail before this outer transaction can commit.
78
+ raise ArgumentError, "Projection rolled back instead of completing" unless completed
79
+ end
80
+ raise ArgumentError, "Projection rolled back instead of completing" unless completed
81
+ :applied
82
+ end
83
+
84
+ # Each failed receipt advances the cursor too. Persist after_id in the
85
+ # host job and reset it to zero after finished; discovery gets its own budget.
86
+ def replay(account_id:, limit: 100, after_id: 0, &on_change)
87
+ unless account_id.is_a?(String) && !account_id.empty? && limit.is_a?(Integer) && (1..1000).cover?(limit) &&
88
+ after_id.is_a?(Integer) && after_id >= 0
89
+ raise ArgumentError, "An account, limit of 1..1000 and nonnegative cursor are required"
90
+ end
91
+ scope = RoutingDeliveryReceipt.where(account_id: account_id, state: "pending").where("id > ?", after_id).order(:id)
92
+ rows = scope.limit(limit).to_a
93
+ errors = []
94
+ rows.each do |receipt|
95
+ apply(receipt, &on_change)
96
+ rescue StandardError => error
97
+ errors << {receipt_id: receipt.id, error: error}
98
+ end
99
+ cursor = rows.last&.id || after_id
100
+ ReplayResult.new(after_id: cursor, processed: rows.length, errors: errors,
101
+ finished: !RoutingDeliveryReceipt.where(account_id: account_id, state: "pending").where("id > ?", cursor).exists?)
102
+ end
103
+
104
+ private
105
+
106
+ def validate!(delivery, evidence)
107
+ ids = OutboundDelivery.where(account_id: evidence.account_id,
108
+ provider_message_id: [evidence.message_id, "<#{evidence.message_id}>"]).limit(2).pluck(:id)
109
+ unless delivery.state == "accepted" && delivery.account_id == evidence.account_id && ids == [delivery.id] &&
110
+ MessageId.normalize(delivery.provider_message_id) == evidence.message_id &&
111
+ delivery.from_address.casecmp?(evidence.event.fetch("from")) && delivery.request_started_at &&
112
+ evidence.occurred_at >= Time.at(delivery.request_started_at.to_i).utc
113
+ raise InvalidEvidence, "Routing analytics requires one matching accepted outbound operation"
114
+ end
115
+ recipients = delivery.outbound_recipients.to_a
116
+ envelope = delivery.recipients
117
+ unless envelope.is_a?(Array) && envelope.one? && recipients.one? &&
118
+ recipients.first.recipient == Outbox.normalize_recipient(envelope.first) &&
119
+ recipients.first.acceptance_state == "accepted"
120
+ raise InvalidEvidence, "Routing analytics requires one accepted saved envelope recipient"
121
+ end
122
+ recipients.first
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,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"
@@ -0,0 +1,21 @@
1
+ require "cloudflare/email/credentials"
2
+ require "cloudflare/email/routing_diagnostics"
3
+ require "cloudflare/email/worker_deployer"
4
+
5
+ module Cloudflare
6
+ module Email
7
+ class CheckRouteTask
8
+ def self.call(address:, worker_name: nil, account_id: nil, io: $stdout)
9
+ report = RoutingDiagnostics.new(api_token: Credentials.management_token).check(
10
+ address: address, worker_name: worker_name || WorkerDeployer.default_script_name,
11
+ account_id: account_id || Credentials.account_id)
12
+ report[:checks].each { |check| io.puts "#{check[:status].upcase} #{check[:name]}: #{check[:message]}" }
13
+ io.puts report[:limitations]
14
+ report[:status] == "pass" ? 0 : 1
15
+ rescue StandardError
16
+ io.puts "UNKNOWN: Unable to inspect routing. Check ADDRESS, WORKER_NAME, and Cloudflare management credentials."
17
+ 1
18
+ end
19
+ end
20
+ end
21
+ end