cloudflare-email 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -1
  3. data/README.md +265 -388
  4. data/SECURITY.md +51 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +62 -6
  6. data/docs/activerecord-tenanted.md +135 -0
  7. data/docs/architecture.md +95 -0
  8. data/docs/delivery-events.md +177 -0
  9. data/docs/features.md +107 -0
  10. data/docs/getting-started.md +298 -0
  11. data/docs/mailboxes.md +299 -0
  12. data/docs/outbox.md +175 -0
  13. data/docs/tenant-mailboxes-plan.md +149 -0
  14. data/docs/thread-correlation.md +27 -0
  15. data/docs/troubleshooting.md +103 -0
  16. data/docs/upgrading-0.2.md +96 -0
  17. data/docs/verification/2026-09-10-followup.md +49 -0
  18. data/docs/verification/2026-09-10-install-upgrade.md +86 -0
  19. data/docs/verification/2026-09-10-live.md +102 -0
  20. data/docs/verification/2026-09-10.md +175 -0
  21. data/docs/verification/2026-09-11-extraction.md +32 -0
  22. data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
  23. data/docs/verification/2026-09-11-security.md +76 -0
  24. data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
  25. data/examples/plain_ruby.rb +24 -0
  26. data/lib/cloudflare/email/active_record/base.rb +48 -0
  27. data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
  28. data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
  29. data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
  30. data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
  31. data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
  32. data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
  33. data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
  34. data/lib/cloudflare/email/active_record/outbox.rb +219 -0
  35. data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
  36. data/lib/cloudflare/email/active_record.rb +5 -0
  37. data/lib/cloudflare/email/client.rb +51 -11
  38. data/lib/cloudflare/email/consume_events_task.rb +22 -0
  39. data/lib/cloudflare/email/delivery_event.rb +70 -0
  40. data/lib/cloudflare/email/delivery_method.rb +3 -0
  41. data/lib/cloudflare/email/deploy_worker_task.rb +3 -0
  42. data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
  43. data/lib/cloudflare/email/dev_tunnel.rb +31 -5
  44. data/lib/cloudflare/email/doctor.rb +16 -38
  45. data/lib/cloudflare/email/endpoint.rb +24 -0
  46. data/lib/cloudflare/email/engine.rb +28 -0
  47. data/lib/cloudflare/email/envelope.rb +58 -0
  48. data/lib/cloudflare/email/event_consumer.rb +86 -0
  49. data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
  50. data/lib/cloudflare/email/mailboxes/events.rb +112 -0
  51. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
  52. data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
  53. data/lib/cloudflare/email/mailboxes/models.rb +148 -0
  54. data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
  55. data/lib/cloudflare/email/mailboxes/service.rb +288 -0
  56. data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
  57. data/lib/cloudflare/email/mailboxes.rb +11 -0
  58. data/lib/cloudflare/email/message_id.rb +12 -0
  59. data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
  60. data/lib/cloudflare/email/provision_route_task.rb +2 -1
  61. data/lib/cloudflare/email/replay_events_job.rb +18 -0
  62. data/lib/cloudflare/email/response.rb +21 -3
  63. data/lib/cloudflare/email/routing_provisioner.rb +71 -45
  64. data/lib/cloudflare/email/send_job.rb +41 -0
  65. data/lib/cloudflare/email/send_test.rb +5 -29
  66. data/lib/cloudflare/email/signing.rb +2 -2
  67. data/lib/cloudflare/email/tenancy.rb +78 -0
  68. data/lib/cloudflare/email/tenant_job_context.rb +90 -0
  69. data/lib/cloudflare/email/verification.rb +30 -8
  70. data/lib/cloudflare/email/version.rb +1 -1
  71. data/lib/cloudflare/email/worker_deployer.rb +5 -3
  72. data/lib/cloudflare-email.rb +4 -1
  73. data/lib/generators/cloudflare/email/install_generator.rb +48 -33
  74. data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
  75. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +54 -0
  76. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
  77. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
  78. data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
  79. data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
  80. data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
  81. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
  82. data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
  83. data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
  84. data/lib/tasks/cloudflare_email.rake +41 -4
  85. data/templates/worker/README.md +61 -11
  86. data/templates/worker/package-lock.json +2627 -0
  87. data/templates/worker/package.json +8 -5
  88. data/templates/worker/scripts/wrangler.mjs +16 -0
  89. data/templates/worker/src/index.js +83 -4
  90. data/templates/worker/test/index.test.ts +117 -5
  91. data/templates/worker/wrangler.toml +13 -4
  92. metadata +107 -6
  93. data/lib/cloudflare/email/secure_message_id.rb +0 -89
@@ -0,0 +1,148 @@
1
+ require "active_record"
2
+ require "cloudflare/email/active_record/base"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ module Mailboxes
7
+ # Domains are an administrator-maintained receiving directory. An active
8
+ # record is an assertion by the application, not DNS ownership verification.
9
+ class ReceivingDomain < Mailboxes.directory_base
10
+ self.table_name = "cloudflare_email_receiving_domains"
11
+ STATES = %w[pending active suspended].freeze
12
+ attr_readonly :domain, :tenant_key, :account_id
13
+ before_validation { self.domain = domain.to_s.strip.downcase }
14
+ validates :tenant_key, :account_id, presence: true
15
+ validates :domain, presence: true, uniqueness: true,
16
+ length: { maximum: 253 },
17
+ format: { with: /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+\z/ }
18
+ validates :state, inclusion: { in: STATES }
19
+ validate do
20
+ errors.add(:domain, "has an oversized label") if domain.to_s.split(".").any? { |label| label.length > 63 }
21
+ end
22
+ scope :active, -> { where(state: "active") }
23
+ end
24
+
25
+ module TenantIdentity
26
+ extend ActiveSupport::Concern
27
+ included do
28
+ attr_readonly :tenant_key
29
+ validates :tenant_key, presence: true
30
+ before_validation :assign_tenant_identity
31
+ validate :validate_tenant_identity
32
+ before_destroy :assert_tenant_identity!
33
+ end
34
+
35
+ private
36
+
37
+ def assign_tenant_identity
38
+ self.tenant_key ||= Tenancy.current_key
39
+ end
40
+
41
+ def validate_tenant_identity
42
+ return unless Tenancy.enabled? || Tenancy.current_key
43
+ Tenancy.require_context!
44
+ errors.add(:tenant_key, "does not match the current tenant") unless tenant_key == Tenancy.current_key
45
+ end
46
+
47
+ def assert_tenant_identity!
48
+ return unless Tenancy.enabled? || Tenancy.current_key
49
+ Tenancy.require_context!
50
+ raise ArgumentError, "record belongs to another tenant" unless tenant_key == Tenancy.current_key
51
+ end
52
+
53
+ def validate_parent_tenant(parent, attribute)
54
+ errors.add(attribute, "belongs to another tenant") if parent && parent.tenant_key != tenant_key
55
+ end
56
+ end
57
+
58
+ class Mailbox < Cloudflare::Email::ActiveRecord::Base
59
+ include TenantIdentity
60
+ self.table_name = "cloudflare_email_mailboxes"
61
+ has_many :addresses, class_name: "Cloudflare::Email::Mailboxes::Address", dependent: :restrict_with_exception
62
+ has_many :messages, class_name: "Cloudflare::Email::Mailboxes::Message", dependent: :restrict_with_exception
63
+ has_many :outbound_messages, class_name: "Cloudflare::Email::Mailboxes::OutboundMessage", dependent: :restrict_with_exception
64
+ validates :name, presence: true, length: { maximum: 255 }
65
+ validates :state, inclusion: { in: %w[active suspended] }
66
+ scope :active, -> { where(state: "active") }
67
+ end
68
+
69
+ class Address < Cloudflare::Email::ActiveRecord::Base
70
+ include TenantIdentity
71
+ self.table_name = "cloudflare_email_addresses"
72
+ belongs_to :mailbox, class_name: "Cloudflare::Email::Mailboxes::Mailbox"
73
+ attr_readonly :mailbox_id, :receiving_domain_id, :local_part, :domain, :address
74
+ before_validation :normalize_address
75
+ validates :mailbox, :receiving_domain_id, presence: true
76
+ validates :local_part, length: { in: 1..64 },
77
+ format: { with: /\A[a-z0-9!\#$%&'*+\/=\?^_`{|}~-]+(?:\.[a-z0-9!\#$%&'*+\/=\?^_`{|}~-]+)*\z/ }
78
+ validates :address, uniqueness: true, length: { maximum: 254 }
79
+ validates :state, inclusion: { in: %w[pending active suspended] }
80
+ validate :validate_directory
81
+ validate { validate_parent_tenant(mailbox, :mailbox) }
82
+ scope :active, -> { where(state: "active") }
83
+
84
+ def receiving_domain
85
+ ReceivingDomain.find_by(id: receiving_domain_id)
86
+ end
87
+
88
+ private
89
+
90
+ def normalize_address
91
+ self.local_part = local_part.to_s.strip.downcase
92
+ self.domain = domain.to_s.strip.downcase
93
+ self.address = "#{local_part}@#{domain}"
94
+ end
95
+
96
+ def validate_directory
97
+ registered = receiving_domain
98
+ unless registered && registered.domain == domain && registered.tenant_key == tenant_key
99
+ errors.add(:receiving_domain_id, "must match this tenant and domain")
100
+ return
101
+ end
102
+ if new_record? && registered.state != "active"
103
+ errors.add(:receiving_domain_id, "must be active before creating addresses")
104
+ end
105
+ end
106
+ end
107
+
108
+ class Message < Cloudflare::Email::ActiveRecord::Base
109
+ include TenantIdentity
110
+ self.table_name = "cloudflare_email_mailbox_messages"
111
+ belongs_to :mailbox, class_name: "Cloudflare::Email::Mailboxes::Mailbox"
112
+ attr_readonly :mailbox_id, :inbound_email_id, :recipient
113
+ validates :mailbox, :inbound_email_id, :recipient, presence: true
114
+ # The unique database index also supports create_or_find_by! retries.
115
+ validate { validate_parent_tenant(mailbox, :mailbox) }
116
+ scope :unread, -> { where(read_at: nil) }
117
+ scope :inbox, -> { where(archived_at: nil) }
118
+
119
+ def mark_read!
120
+ update!(read_at: Time.current)
121
+ end
122
+
123
+ def mark_unread!
124
+ update!(read_at: nil)
125
+ end
126
+
127
+ def archive!
128
+ update!(archived_at: Time.current)
129
+ end
130
+
131
+ def unarchive!
132
+ update!(archived_at: nil)
133
+ end
134
+ end
135
+
136
+ class OutboundMessage < Cloudflare::Email::ActiveRecord::Base
137
+ include TenantIdentity
138
+ self.table_name = "cloudflare_email_mailbox_outbound_messages"
139
+ belongs_to :mailbox, class_name: "Cloudflare::Email::Mailboxes::Mailbox"
140
+ belongs_to :outbound_delivery, class_name: "Cloudflare::Email::ActiveRecord::OutboundDelivery"
141
+ attr_readonly :mailbox_id, :outbound_delivery_id
142
+ validates :mailbox, :outbound_delivery, presence: true
143
+ # Enforce uniqueness in the database so idempotent link creation works.
144
+ validate { validate_parent_tenant(mailbox, :mailbox) }
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,11 @@
1
+ module Cloudflare
2
+ module Email
3
+ module Mailboxes
4
+ # Shared routing evidence, never inferred from a sender or event domain.
5
+ class ProviderCorrelation < Mailboxes.directory_base
6
+ self.table_name = "cloudflare_email_provider_correlations"
7
+ attr_readonly :account_id, :message_id, :recipient, :tenant_key, :outbound_delivery_id
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,288 @@
1
+ require "digest"
2
+ require "cloudflare/email/envelope"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ module Mailboxes
7
+ class << self
8
+ # Call after host authorization. The directory is a control-plane API,
9
+ # not a public endpoint accepting arbitrary customer domain claims.
10
+ def register_domain(domain:, tenant_key:, account_id:)
11
+ Tenancy.normalize_key(tenant_key)
12
+ ReceivingDomain.create!(domain: domain, tenant_key: tenant_key, account_id: account_id)
13
+ end
14
+
15
+ def activate_domain!(id, evidence:, sending_enabled: false)
16
+ raise ArgumentError, "verification evidence is required" if evidence.to_s.strip.empty?
17
+ domain = ReceivingDomain.find(id)
18
+ domain.update!(state: "active", provisioning_evidence: evidence,
19
+ verified_at: Time.now.utc, sending_enabled: sending_enabled)
20
+ domain
21
+ end
22
+
23
+ def for_tenant(key)
24
+ raise ArgumentError, "a block is required" unless block_given?
25
+ Tenancy.with(key) { yield Session.new(key) }
26
+ end
27
+
28
+ # Only call after verifying the complete ingress HMAC. Resolve before
29
+ # ActionMailbox or ActiveStorage accesses a tenant connection.
30
+ def receive(recipient:)
31
+ raise ArgumentError, "persistence block required" unless block_given?
32
+ address = canonical_address(recipient)
33
+ directory = ReceivingDomain.find_by(domain: address.split("@", 2).last, state: "active")
34
+ raise Unavailable, "receiving domain unavailable" unless directory
35
+ for_tenant(directory.tenant_key) do |session|
36
+ session.receive(address, directory) { yield }
37
+ end
38
+ rescue ::ActiveRecord::RecordNotFound
39
+ raise Unavailable, "mailbox address unavailable"
40
+ end
41
+
42
+ def canonical_address(value)
43
+ unless Envelope.valid_address?(value)
44
+ raise ValidationError, "mailbox address must be an ASCII dot-atom address"
45
+ end
46
+ value.downcase
47
+ end
48
+ end
49
+
50
+ class Session
51
+ attr_reader :tenant_key
52
+
53
+ def initialize(tenant_key)
54
+ @tenant_key = tenant_key
55
+ end
56
+
57
+ def mailboxes
58
+ context!
59
+ Mailbox.where(tenant_key: tenant_key)
60
+ end
61
+
62
+ def create(name:, address:, owner_ref: nil)
63
+ context!
64
+ Mailbox.transaction do
65
+ mailbox = Mailbox.create!(tenant_key: tenant_key, name: name, owner_ref: owner_ref)
66
+ add_address(mailbox.id, address: address)
67
+ mailbox
68
+ end
69
+ end
70
+
71
+ def add_address(mailbox_id, address:)
72
+ mailbox = active_mailbox!(mailbox_id)
73
+ canonical = Mailboxes.canonical_address(address)
74
+ local, domain = canonical.split("@", 2)
75
+ directory = ReceivingDomain.find_by!(domain: domain, tenant_key: tenant_key, state: "active")
76
+ Address.create!(tenant_key: tenant_key, mailbox_id: mailbox.id,
77
+ receiving_domain_id: directory.id, local_part: local, domain: domain,
78
+ address: canonical, state: "pending")
79
+ end
80
+
81
+ def activate_address!(address_id, evidence:)
82
+ context!
83
+ raise ArgumentError, "route verification evidence is required" if evidence.to_s.strip.empty?
84
+ address = Address.where(tenant_key: tenant_key).find(address_id)
85
+ active_mailbox!(address.mailbox_id)
86
+ active_domain!(address)
87
+ address.update!(state: "active", provisioning_evidence: evidence)
88
+ address
89
+ end
90
+
91
+ def provision_address!(address_id, provisioner:, worker_name:)
92
+ context!
93
+ raise ArgumentError, "provision outside a database transaction" if Address.connection.transaction_open?
94
+ address = Address.where(tenant_key: tenant_key).find(address_id)
95
+ active_mailbox!(address.mailbox_id)
96
+ active_domain!(address)
97
+ provisioner.provision(address: address.address, worker_name: worker_name)
98
+ # Failed requests leave the address pending; the existing provisioner
99
+ # upserts a rule, making a deliberate retry safe.
100
+ activate_address!(address.id, evidence: "Cloudflare address rule provisioned for Worker #{worker_name}")
101
+ end
102
+
103
+ def suspend(mailbox_id)
104
+ mailboxes.find(mailbox_id).tap { |mailbox| mailbox.update!(state: "suspended") }
105
+ end
106
+
107
+ def resume(mailbox_id)
108
+ mailboxes.find(mailbox_id).tap { |mailbox| mailbox.update!(state: "active") }
109
+ end
110
+
111
+ def messages(mailbox_id)
112
+ mailbox = mailboxes.find(mailbox_id)
113
+ Message.where(tenant_key: tenant_key, mailbox_id: mailbox.id)
114
+ end
115
+
116
+ def addresses(mailbox_id)
117
+ mailbox = mailboxes.find(mailbox_id)
118
+ Address.where(tenant_key: tenant_key, mailbox_id: mailbox.id)
119
+ end
120
+
121
+ def suspend_address(mailbox_id, address_id)
122
+ addresses(mailbox_id).find(address_id).tap { |address| address.update!(state: "suspended") }
123
+ end
124
+
125
+ def outbound_messages(mailbox_id)
126
+ mailbox = mailboxes.find(mailbox_id)
127
+ OutboundMessage.where(tenant_key: tenant_key, mailbox_id: mailbox.id)
128
+ end
129
+
130
+ def inbound_email(mailbox_id, message_id)
131
+ message = messages(mailbox_id).find(message_id)
132
+ ensure_storage_connection!
133
+ ::ActionMailbox::InboundEmail.find(message.inbound_email_id)
134
+ end
135
+
136
+ # Explicit permanent removal. Archive is the reversible default.
137
+ # Other mailbox memberships retain their shared raw source.
138
+ def purge_message(mailbox_id, message_id)
139
+ context!
140
+ ensure_storage_connection!
141
+ Message.transaction do
142
+ message = messages(mailbox_id).find(message_id)
143
+ inbound_id = message.inbound_email_id
144
+ message.destroy!
145
+ unless Message.where(inbound_email_id: inbound_id).exists?
146
+ ::ActionMailbox::InboundEmail.find_by(id: inbound_id)&.destroy!
147
+ end
148
+ end
149
+ end
150
+
151
+ def mark_read(mailbox_id, message_id, read: true)
152
+ messages(mailbox_id).find(message_id).update!(read_at: read ? Time.now.utc : nil)
153
+ end
154
+
155
+ def archive(mailbox_id, message_id, archived: true)
156
+ messages(mailbox_id).find(message_id).update!(archived_at: archived ? Time.now.utc : nil)
157
+ end
158
+
159
+ def prepare(mailbox_id, operation_key:, mail:)
160
+ context!
161
+ raise ArgumentError, "operation_key is required" unless operation_key.is_a?(String) && !operation_key.strip.empty?
162
+ mail = mail.message if mail.respond_to?(:message) && !mail.respond_to?(:encoded)
163
+ raise ValidationError, "mail must be a rendered Mail message" unless mail.respond_to?(:encoded)
164
+ mailbox = active_mailbox!(mailbox_id)
165
+ from = Array(mail.from)
166
+ raise ValidationError, "mail must have one mailbox From address" unless from.length == 1
167
+ sender = Array(mail.sender)
168
+ address = authorized_sender!(mailbox, from.first)
169
+ envelope_from = mail.respond_to?(:smtp_envelope_from) ? mail.smtp_envelope_from : from.first
170
+ unless Mailboxes.canonical_address(envelope_from) == address.address &&
171
+ sender.all? { |value| Mailboxes.canonical_address(value) == address.address }
172
+ raise ValidationError, "MIME and envelope sender must match the mailbox address"
173
+ end
174
+ domain = active_domain!(address)
175
+ raise Unavailable, "domain is not enabled for sending" unless domain.sending_enabled
176
+ key = "mailbox:#{Digest::SHA256.hexdigest(JSON.generate([tenant_key, mailbox.id, operation_key]))}"
177
+ Mailbox.transaction do
178
+ delivery = ActiveRecord::Outbox.prepare_mail(account_id: domain.account_id, operation_key: key, mail: mail)
179
+ OutboundMessage.create_or_find_by!(tenant_key: tenant_key, mailbox_id: mailbox.id,
180
+ outbound_delivery_id: delivery.id)
181
+ delivery
182
+ end
183
+ end
184
+
185
+ # Enqueue only after the outer transaction commits. No mail or model
186
+ # GlobalIDs are serialized into this job, just stable identities.
187
+ def enqueue(mailbox_id, operation_key:)
188
+ context!
189
+ raise ArgumentError, "enqueue after database commit" if Mailbox.connection.transaction_open?
190
+ linked_delivery!(mailbox_id, operation_key)
191
+ SendJob.perform_later(tenant_key, mailbox_id, operation_key)
192
+ end
193
+
194
+ def deliver(mailbox_id, operation_key:)
195
+ mailbox = active_mailbox!(mailbox_id)
196
+ delivery = linked_delivery!(mailbox.id, operation_key)
197
+ address = authorized_sender!(mailbox, delivery.from_address)
198
+ domain = active_domain!(address)
199
+ raise Unavailable, "domain is not enabled for sending" unless domain.sending_enabled && domain.account_id == delivery.account_id
200
+ ActiveRecord::Outbox.deliver(delivery, client: Mailboxes.client_for(tenant_key, delivery.account_id))
201
+ handler = Mailboxes.delivery_handler
202
+ delivery.with_lock { handler.call(delivery) } if handler.respond_to?(:call)
203
+ Events.register(delivery, tenant_key: tenant_key) if %w[accepted partial].include?(delivery.state) && delivery.provider_message_id
204
+ delivery
205
+ end
206
+
207
+ def reconcile(mailbox_id, operation_key:, **evidence, &handler)
208
+ delivery = linked_delivery!(mailbox_id, operation_key)
209
+ raise ArgumentError, "reconcile outside an outer transaction" if Mailbox.connection.transaction_open?
210
+ ActiveRecord::Outbox.reconcile(delivery, **evidence, &handler)
211
+ Events.register(delivery, tenant_key: tenant_key) if %w[accepted partial].include?(delivery.state) && delivery.provider_message_id
212
+ delivery
213
+ end
214
+
215
+ # A bounded repair pass after an enqueue gap or cross-database commit
216
+ # gap. Does not automatically resend sending/unknown/rejected rows.
217
+ def recover(limit: 100, after_id: 0)
218
+ context!
219
+ raise ArgumentError, "recover outside a database transaction" if Mailbox.connection.transaction_open?
220
+ raise ArgumentError, "limit must be 1..1000" unless limit.is_a?(Integer) && (1..1000).cover?(limit)
221
+ raise ArgumentError, "after_id must be nonnegative" unless after_id.is_a?(Integer) && after_id >= 0
222
+ links = OutboundMessage.where(tenant_key: tenant_key).where("id > ?", after_id).order(:id).limit(limit).to_a
223
+ links.each do |link|
224
+ delivery = ActiveRecord::OutboundDelivery.find(link.outbound_delivery_id)
225
+ if delivery.state == "prepared"
226
+ enqueue(link.mailbox_id, operation_key: delivery.operation_key) if mailboxes.find(link.mailbox_id).state == "active"
227
+ elsif %w[accepted partial].include?(delivery.state)
228
+ handler = Mailboxes.delivery_handler
229
+ delivery.with_lock { handler.call(delivery) } if handler.respond_to?(:call)
230
+ Events.register(delivery, tenant_key: tenant_key) if delivery.provider_message_id
231
+ end
232
+ end
233
+ links.last&.id
234
+ end
235
+
236
+ def receive(address, directory)
237
+ context!
238
+ ensure_storage_connection! if defined?(::ActionMailbox::InboundEmail)
239
+ Mailbox.transaction do
240
+ destination = Address.where(tenant_key: tenant_key, address: address, receiving_domain_id: directory.id, state: "active").first
241
+ raise Unavailable, "mailbox address unavailable" unless destination
242
+ mailbox = active_mailbox!(destination.mailbox_id)
243
+ active_domain!(destination)
244
+ inbound = yield
245
+ Message.create_or_find_by!(tenant_key: tenant_key, mailbox_id: mailbox.id,
246
+ inbound_email_id: inbound.id) { |row| row.recipient = address } if inbound
247
+ inbound
248
+ end
249
+ end
250
+
251
+ private
252
+
253
+ def context!
254
+ raise ConfigurationError, "mailbox session used outside its tenant context" unless Tenancy.require_context! == tenant_key
255
+ end
256
+
257
+ def ensure_storage_connection!
258
+ unless defined?(::ActionMailbox::InboundEmail) && ::ActionMailbox::InboundEmail.connection_pool == Mailbox.connection_pool &&
259
+ ::ActiveStorage::Blob.connection_pool == Mailbox.connection_pool
260
+ raise ConfigurationError, "ActionMailbox, ActiveStorage and mailbox records must share the tenant connection"
261
+ end
262
+ end
263
+
264
+ def active_mailbox!(id)
265
+ context!
266
+ mailboxes.where(state: "active").find(id)
267
+ end
268
+
269
+ def active_domain!(address)
270
+ ReceivingDomain.find_by!(id: address.receiving_domain_id, domain: address.domain,
271
+ tenant_key: tenant_key, state: "active")
272
+ end
273
+
274
+ def authorized_sender!(mailbox, address)
275
+ Address.where(tenant_key: tenant_key, mailbox_id: mailbox.id, state: "active")
276
+ .find_by!(address: Mailboxes.canonical_address(address))
277
+ end
278
+
279
+ def linked_delivery!(mailbox_id, operation_key)
280
+ mailbox = mailboxes.find(mailbox_id)
281
+ delivery = ActiveRecord::OutboundDelivery.find_by!(operation_key: operation_key)
282
+ OutboundMessage.find_by!(tenant_key: tenant_key, mailbox_id: mailbox.id, outbound_delivery_id: delivery.id)
283
+ delivery
284
+ end
285
+ end
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,14 @@
1
+ module Cloudflare
2
+ module Email
3
+ module Mailboxes
4
+ class SharedEventReceipt < Mailboxes.directory_base
5
+ self.table_name = "cloudflare_email_shared_event_receipts"
6
+ attr_readonly :account_id, :event_id, :message_id, :recipient, :payload_json
7
+
8
+ def event
9
+ DeliveryEvent.new(JSON.parse(payload_json))
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ # Explicit opt-in. Configure Tenancy and Mailboxes before loading this file.
2
+ require "cloudflare-email"
3
+ require "cloudflare/email/tenancy"
4
+ require "cloudflare/email/mailboxes/configuration"
5
+ require "cloudflare/email/active_record"
6
+ require "cloudflare/email/mailboxes/models"
7
+ require "cloudflare/email/mailboxes/events"
8
+ require "cloudflare/email/mailboxes/service"
9
+ require "cloudflare/email/mailboxes/jobs"
10
+
11
+ Cloudflare::Email::Mailboxes.enable!
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudflare
4
+ module Email
5
+ # Correlation only: a Message-ID does not authorize access to a thread.
6
+ module MessageId
7
+ def self.normalize(value)
8
+ value.to_s.strip.sub(/\A<([^<>]+)>\z/, '\1')
9
+ end
10
+ end
11
+ end
12
+ end
@@ -6,7 +6,7 @@ module Cloudflare
6
6
  module Email
7
7
  # `bin/rails cloudflare:email:provision_catchall` — points a zone's
8
8
  # catch-all rule at the env-scoped ingress Worker. Useful for bounce
9
- # handling, dev subdomains, and alias routing.
9
+ # handling and alias routing. Applies to the entire Cloudflare zone.
10
10
  class ProvisionCatchallTask < TaskBase
11
11
  def self.call(domain:, worker_name: nil, io: $stdout)
12
12
  new(io: io, domain: domain, worker_name: worker_name).call
@@ -16,11 +16,11 @@ module Cloudflare
16
16
 
17
17
  def run
18
18
  require_value!(management_token, "cloudflare.api_token or cloudflare.management_token")
19
- require_value!(opts[:domain], "DOMAIN=in.example.com")
19
+ require_value!(opts[:domain], "DOMAIN=example.com (the Cloudflare zone name)")
20
20
 
21
21
  worker = opts[:worker_name] || Cloudflare::Email::WorkerDeployer.default_script_name
22
22
 
23
- say "Provisioning catch-all:"
23
+ say "Provisioning zone-wide catch-all:"
24
24
  say " Domain: #{opts[:domain]}"
25
25
  say " Worker: #{worker}"
26
26
  say ""
@@ -29,7 +29,7 @@ module Cloudflare
29
29
  provisioner.provision_catch_all_for_domain(domain: opts[:domain], worker_name: worker)
30
30
 
31
31
  say " ✓ Catch-all on #{opts[:domain]} now points at #{worker}."
32
- say " All unrouted addresses on this domain will hit your Worker."
32
+ say " All unrouted addresses covered by this zone's catch-all will hit your Worker."
33
33
  end
34
34
  end
35
35
  end
@@ -27,7 +27,8 @@ module Cloudflare
27
27
  provisioner = Cloudflare::Email::RoutingProvisioner.new(api_token: management_token)
28
28
  provisioner.provision(address: opts[:address], worker_name: worker)
29
29
 
30
- say " ✓ Route created/updated mail to #{opts[:address]} will hit Worker #{worker}."
30
+ say " ✓ Route created/updated for #{opts[:address]} Worker #{worker}."
31
+ say " Verify DNS propagation and send a test message to confirm delivery."
31
32
  end
32
33
  end
33
34
  end
@@ -0,0 +1,18 @@
1
+ require "active_job"
2
+ require "cloudflare/email/active_record"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ class ReplayEventsJob < ::ActiveJob::Base
7
+ queue_as :mailers
8
+
9
+ def perform(account_id, message_id = nil)
10
+ handler = if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
11
+ Rails.application.config.x.cloudflare_email.outbox_recipient_handler
12
+ end
13
+ ActiveRecord::DeliveryEvents.replay(account_id: account_id, message_id: message_id,
14
+ &(handler.method(:call) if handler.respond_to?(:call)))
15
+ end
16
+ end
17
+ end
18
+ end
@@ -1,3 +1,5 @@
1
+ require "cloudflare/email/message_id"
2
+
1
3
  module Cloudflare
2
4
  module Email
3
5
  class Response
@@ -9,18 +11,30 @@ module Cloudflare
9
11
  end
10
12
 
11
13
  def success?
12
- return !!@raw["success"] if @raw.key?("success") && !@raw["success"].nil?
13
- @status >= 200 && @status < 300
14
+ return false unless @status.is_a?(Integer) && @status >= 200 && @status < 300
15
+ return @raw["success"] == true if @raw.key?("success")
16
+ true
14
17
  end
15
18
 
16
19
  def result
17
20
  @raw["result"].is_a?(Hash) ? @raw["result"] : {}
18
21
  end
19
22
 
23
+ # Provider acceptance is not final delivery. A partial acceptance is true;
24
+ # inspect recipient outcomes before retrying any rejected recipients.
25
+ def accepted?
26
+ return false if %w[delivered queued permanent_bounces suppressed_recipients].any? { |key| result.key?(key) && !result[key].is_a?(Array) }
27
+ success? && (delivered.any? || queued.any? ||
28
+ (!message_id.to_s.strip.empty? && permanent_bounces.empty? && suppressed_recipients.empty?))
29
+ end
30
+
20
31
  def message_id
21
- result["message_id"] ||
32
+ value = result["message_id"] ||
22
33
  dig_message_id(result["delivered"]) ||
23
34
  dig_message_id(result["queued"])
35
+ return nil unless value.is_a?(String)
36
+ normalized = MessageId.normalize(value)
37
+ value unless normalized.empty? || normalized.match?(/[\s<>[:cntrl:]]/)
24
38
  end
25
39
 
26
40
  def delivered
@@ -35,6 +49,10 @@ module Cloudflare
35
49
  Array(result["permanent_bounces"])
36
50
  end
37
51
 
52
+ def suppressed_recipients
53
+ Array(result["suppressed_recipients"])
54
+ end
55
+
38
56
  def errors
39
57
  Array(@raw["errors"])
40
58
  end