cloudflare-email 0.3.0 → 0.4.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +14 -4
  4. data/app/controllers/cloudflare/email/ingress_controller.rb +1 -2
  5. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +2 -166
  6. data/app/controllers/cloudflare/email/management/styles_controller.rb +2 -6
  7. data/docs/custom-ingress.md +5 -1
  8. data/docs/features.md +1 -1
  9. data/docs/getting-started.md +4 -3
  10. data/docs/mailboxes.md +8 -1
  11. data/docs/routing-diagnostics.md +7 -0
  12. data/docs/troubleshooting.md +1 -1
  13. data/docs/verification/2026-09-13-action-mailbox-core.md +96 -0
  14. data/lib/cloudflare/email/active_record/base.rb +3 -53
  15. data/lib/cloudflare/email/engine.rb +1 -13
  16. data/lib/cloudflare/email/envelope.rb +2 -12
  17. data/lib/cloudflare/email/error.rb +5 -12
  18. data/lib/cloudflare/email/ingress.rb +19 -8
  19. data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
  20. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
  21. data/lib/cloudflare/email/mailboxes/models.rb +8 -147
  22. data/lib/cloudflare/email/mailboxes/service.rb +7 -258
  23. data/lib/cloudflare/email/mailboxes.rb +1 -0
  24. data/lib/cloudflare/email/management/adapter.rb +3 -25
  25. data/lib/cloudflare/email/management/configuration.rb +4 -11
  26. data/lib/cloudflare/email/management/engine.rb +2 -0
  27. data/lib/cloudflare/email/tenancy.rb +2 -73
  28. data/lib/cloudflare/email/tenant_job_context.rb +2 -85
  29. data/lib/cloudflare/email/version.rb +1 -1
  30. data/lib/cloudflare-email.rb +6 -0
  31. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +1 -0
  32. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
  33. data/templates/deploy-to-cloudflare/README.md +21 -1
  34. data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
  35. data/templates/deploy-to-cloudflare/package.json +1 -0
  36. data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
  37. data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
  38. data/templates/worker/README.md +6 -0
  39. data/templates/worker/docs/domain-setup.md +198 -0
  40. data/templates/worker/package.json +1 -0
  41. data/templates/worker/scripts/check-subdomains.mjs +54 -0
  42. data/templates/worker/test/subdomains.test.ts +51 -0
  43. metadata +22 -6
  44. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +0 -65
  45. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +0 -34
  46. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +0 -86
  47. data/app/views/layouts/cloudflare/email/management.html.erb +0 -29
  48. data/lib/cloudflare/email/management/management.css +0 -68
@@ -35,19 +35,30 @@ module Cloudflare
35
35
  # framework records and memberships share the resolved tenant context.
36
36
  # Custom endpoints may instead persist body/metadata in their own store.
37
37
  def persist_action_mailbox!
38
- ::ActionMailbox::InboundEmail.transaction do
39
- inbound = ::ActionMailbox::InboundEmail.create_and_extract_message_id!(body,
40
- message_checksum: message_checksum, message_id: stable_message_id)
41
- if inbound
42
- blob = inbound.raw_email.blob
43
- blob.update!(metadata: blob.metadata.merge(storage_metadata))
44
- end
45
- inbound
38
+ result = persist_rails_record
39
+ result.record if result.created?
40
+ end
41
+
42
+ # Verify once, resolve the envelope recipient before storage, then use
43
+ # the kit's ordinary Rails membership path. A duplicate can repair a
44
+ # missing membership without scheduling Rails processing again.
45
+ def receive_into_mailbox!
46
+ result = nil
47
+ Mailboxes.receive(recipient: envelope.fetch("to")) do
48
+ result = persist_rails_record
49
+ result.record
46
50
  end
51
+ result.record if result.created?
47
52
  end
48
53
 
49
54
  private
50
55
 
56
+ def persist_rails_record
57
+ require "mailbox_kit/inbound_email"
58
+ MailboxKit::InboundEmail.persist(source: body, message_checksum: message_checksum,
59
+ message_id: stable_message_id, metadata: storage_metadata)
60
+ end
61
+
51
62
  # ActionMailbox's fallback includes Socket.gethostname. A replay on a
52
63
  # different Rails host must use the same database identity, while the
53
64
  # original MIME stays untouched. Preserve parseable provider IDs.
@@ -1,28 +1,23 @@
1
- require "active_record"
1
+ require "mailbox_kit/mailboxes/configuration"
2
2
  require "cloudflare/email/error"
3
3
 
4
4
  module Cloudflare
5
5
  module Email
6
+ Mailboxes = MailboxKit::Mailboxes unless const_defined?(:Mailboxes, false)
6
7
  module Mailboxes
7
- class Unavailable < Cloudflare::Email::Error; end
8
8
 
9
- class << self
9
+ module CloudflareConfiguration
10
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
11
  if client_resolver && !client_resolver.respond_to?(:call)
18
12
  raise ConfigurationError, "client_resolver must be callable"
19
13
  end
20
- @directory_base = directory_base
14
+ super(directory_base: directory_base)
21
15
  @client_resolver = client_resolver
22
16
  end
17
+ end
18
+ singleton_class.prepend(CloudflareConfiguration)
23
19
 
24
- def directory_base = @directory_base || ::ActiveRecord::Base
25
-
20
+ class << self
26
21
  def delivery_handler = rails_setting(:outbox_delivery_handler)
27
22
  def recipient_handler = rails_setting(:outbox_recipient_handler)
28
23
 
@@ -42,9 +37,6 @@ module Cloudflare
42
37
  client
43
38
  end
44
39
 
45
- def enabled? = @enabled == true
46
- def enable! = @enabled = true
47
-
48
40
  private
49
41
 
50
42
  def rails_setting(name)
@@ -1,14 +1 @@
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
1
+ require "mailbox_kit/mailboxes/inbound_retention"
@@ -1,159 +1,20 @@
1
- require "active_record"
2
1
  require "cloudflare/email/active_record/base"
3
-
2
+ require "mailbox_kit/mailboxes/configuration"
3
+ require "mailbox_kit/mailboxes/models"
4
4
  module Cloudflare
5
5
  module Email
6
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(on: :create) { 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 }
7
+ Mailbox.has_many :outbound_messages, class_name: "MailboxKit::Mailboxes::OutboundMessage"
8
+ # A process may also serve core-only tenants without the outbound schema.
9
+ Mailbox.before_destroy do
10
+ if self.class.connection.data_source_exists?(OutboundMessage.table_name) && outbound_messages.exists?
11
+ raise ::ActiveRecord::DeleteRestrictionError, :outbound_messages
21
12
  end
22
- scope :active, -> { where(state: "active") }
23
13
  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, on: :create
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_catch_all
82
- validate { validate_parent_tenant(mailbox, :mailbox) }
83
- scope :active, -> { where(state: "active") }
84
-
85
- def receiving_domain
86
- ReceivingDomain.find_by(id: receiving_domain_id)
87
- end
88
-
89
- private
90
-
91
- def normalize_address
92
- self.local_part = local_part.to_s.strip.downcase
93
- self.domain = domain.to_s.strip.downcase
94
- self.address = "#{local_part}@#{domain}"
95
- end
96
-
97
- def validate_directory
98
- registered = receiving_domain
99
- unless registered && registered.domain == domain && registered.tenant_key == tenant_key
100
- errors.add(:receiving_domain_id, "must match this tenant and domain")
101
- return
102
- end
103
- if new_record? && registered.state != "active"
104
- errors.add(:receiving_domain_id, "must be active before creating addresses")
105
- end
106
- end
107
-
108
- def validate_catch_all
109
- # Existing exact-address installations need not migrate until they
110
- # opt into catch-all receiving.
111
- return unless has_attribute?(:catch_all) && self[:catch_all]
112
- errors.add(:catch_all_evidence, "is required") if self[:catch_all_evidence].to_s.strip.empty?
113
- if will_save_change_to_catch_all? && state != "active"
114
- errors.add(:catch_all, "requires an active address")
115
- end
116
- return unless state == "active"
117
- errors.add(:catch_all, "requires an active mailbox") unless mailbox&.state == "active"
118
- errors.add(:catch_all, "requires an active receiving domain") unless receiving_domain&.state == "active"
119
- if self.class.where(receiving_domain_id: receiving_domain_id, catch_all: true, state: "active").where.not(id: id).exists?
120
- errors.add(:catch_all, "already exists for this receiving domain")
121
- end
122
- end
123
- end
124
-
125
- class Message < Cloudflare::Email::ActiveRecord::Base
126
- include TenantIdentity
127
- self.table_name = "cloudflare_email_mailbox_messages"
128
- belongs_to :mailbox, class_name: "Cloudflare::Email::Mailboxes::Mailbox"
129
- attr_readonly :mailbox_id, :inbound_email_id, :recipient
130
- validates :mailbox, :inbound_email_id, :recipient, presence: true
131
- # The unique database index also supports create_or_find_by! retries.
132
- validate { validate_parent_tenant(mailbox, :mailbox) }
133
- scope :unread, -> { where(read_at: nil) }
134
- scope :inbox, -> { where(archived_at: nil) }
135
-
136
- def mark_read!
137
- update!(read_at: Time.current)
138
- end
139
-
140
- def mark_unread!
141
- update!(read_at: nil)
142
- end
143
-
144
- def archive!
145
- update!(archived_at: Time.current)
146
- end
147
-
148
- def unarchive!
149
- update!(archived_at: nil)
150
- end
151
- end
152
-
153
14
  class OutboundMessage < Cloudflare::Email::ActiveRecord::Base
154
15
  include TenantIdentity
155
16
  self.table_name = "cloudflare_email_mailbox_outbound_messages"
156
- belongs_to :mailbox, class_name: "Cloudflare::Email::Mailboxes::Mailbox"
17
+ belongs_to :mailbox, class_name: "MailboxKit::Mailboxes::Mailbox"
157
18
  belongs_to :outbound_delivery, class_name: "Cloudflare::Email::ActiveRecord::OutboundDelivery"
158
19
  attr_readonly :mailbox_id, :outbound_delivery_id
159
20
  validates :mailbox, :outbound_delivery, presence: true
@@ -1,121 +1,11 @@
1
1
  require "digest"
2
- require "cloudflare/email/envelope"
3
-
2
+ require "mailbox_kit/mailboxes/service"
4
3
  module Cloudflare
5
4
  module Email
6
5
  module Mailboxes
7
- # A routing snapshot, not a model or an authorization token. Use it inside
8
- # the yielded tenant context; later jobs must resolve/check policy again.
9
- Destination = Struct.new(:tenant_key, :mailbox_id, :address_id,
10
- :receiving_domain_id, :recipient, :owner_ref, :catch_all, keyword_init: true) do
11
- def initialize(**attributes)
12
- super(**{ catch_all: false }.merge(attributes).transform_values { |value| value.is_a?(String) ? value.dup.freeze : value })
13
- freeze
14
- end
15
- end
16
-
17
- class << self
18
- # Call after host authorization. The directory is a control-plane API,
19
- # not a public endpoint accepting arbitrary customer domain claims.
20
- def register_domain(domain:, tenant_key:, account_id:)
21
- Tenancy.normalize_key(tenant_key)
22
- ReceivingDomain.create!(domain: domain, tenant_key: tenant_key, account_id: account_id)
23
- end
24
-
25
- def activate_domain!(id, evidence:, sending_enabled: false)
26
- raise ArgumentError, "verification evidence is required" if evidence.to_s.strip.empty?
27
- domain = ReceivingDomain.find(id)
28
- domain.update!(state: "active", provisioning_evidence: evidence,
29
- verified_at: Time.now.utc, sending_enabled: sending_enabled)
30
- domain
31
- end
32
-
33
- def for_tenant(key)
34
- raise ArgumentError, "a block is required" unless block_given?
35
- Tenancy.with(key) { yield Session.new(key) }
36
- end
37
-
38
- # Only call after verifying the complete ingress HMAC. Resolve before
39
- # ActionMailbox or ActiveStorage accesses a tenant connection.
40
- def receive(recipient:, &block)
41
- raise ArgumentError, "persistence block required" unless block
42
- in_recipient_tenant(recipient) do |session, address, directory|
43
- session.receive(address, directory) do |destination|
44
- # Preserve strict callbacks accepted before destinations were yielded.
45
- block.lambda? && block.arity.zero? ? block.call : block.call(destination)
46
- end
47
- end
48
- end
49
-
50
- # Lookup only: the host owns persistence and the block's return value.
51
- # Verify ingress before calling this API. No ActionMailbox is required.
52
- def with_recipient(recipient:)
53
- raise ArgumentError, "a block is required" unless block_given?
54
- in_recipient_tenant(recipient) do |session, address, directory|
55
- session.with_recipient(address, directory) { |destination| yield destination }
56
- end
57
- end
58
-
59
- def canonical_address(value)
60
- unless Envelope.valid_address?(value)
61
- raise ValidationError, "mailbox address must be an ASCII dot-atom address"
62
- end
63
- value.downcase
64
- end
65
-
66
- private
67
-
68
- def in_recipient_tenant(recipient)
69
- address = canonical_address(recipient)
70
- directory = ReceivingDomain.find_by(domain: address.split("@", 2).last, state: "active")
71
- raise Unavailable, "receiving domain unavailable" unless directory
72
- for_tenant(directory.tenant_key) do |session|
73
- yield session, address, directory
74
- end
75
- end
76
- end
77
-
78
- class Session
79
- attr_reader :tenant_key
80
-
81
- def initialize(tenant_key)
82
- @tenant_key = tenant_key
83
- end
84
-
85
- def mailboxes
86
- context!
87
- Mailbox.where(tenant_key: tenant_key)
88
- end
89
-
90
- def create(name:, address:, owner_ref: nil)
91
- context!
92
- Mailbox.transaction do
93
- mailbox = Mailbox.create!(tenant_key: tenant_key, name: name, owner_ref: owner_ref)
94
- add_address(mailbox.id, address: address)
95
- mailbox
96
- end
97
- end
98
-
99
- def add_address(mailbox_id, address:)
100
- mailbox = active_mailbox!(mailbox_id)
101
- canonical = Mailboxes.canonical_address(address)
102
- local, domain = canonical.split("@", 2)
103
- directory = ReceivingDomain.find_by!(domain: domain, tenant_key: tenant_key, state: "active")
104
- Address.create!(tenant_key: tenant_key, mailbox_id: mailbox.id,
105
- receiving_domain_id: directory.id, local_part: local, domain: domain,
106
- address: canonical, state: "pending")
107
- end
108
-
109
- def activate_address!(address_id, evidence:)
110
- context!
111
- raise ArgumentError, "route verification evidence is required" if evidence.to_s.strip.empty?
112
- address = Address.where(tenant_key: tenant_key).find(address_id)
113
- active_mailbox!(address.mailbox_id)
114
- active_domain!(address)
115
- address.update!(state: "active", provisioning_evidence: evidence)
116
- address
117
- end
118
-
6
+ # Provider-specific behavior is installed only when the Cloudflare
7
+ # integration is explicitly loaded. Core sessions remain receiving-only.
8
+ module CloudflareSession
119
9
  def provision_address!(address_id, provisioner:, worker_name:)
120
10
  context!
121
11
  raise ArgumentError, "provision outside a database transaction" if Address.connection.transaction_open?
@@ -128,85 +18,11 @@ module Cloudflare
128
18
  activate_address!(address.id, evidence: "Cloudflare address rule provisioned for Worker #{worker_name}")
129
19
  end
130
20
 
131
- # This records the host's verified provider routing decision; it does
132
- # not provision Cloudflare DNS/rules or authorize arbitrary From values.
133
- def enable_catch_all(address_id, evidence:)
134
- context!
135
- catch_all_schema!
136
- raise ArgumentError, "catch-all route verification evidence is required" if evidence.to_s.strip.empty?
137
- Address.transaction do
138
- address = Address.where(tenant_key: tenant_key, state: "active").find(address_id)
139
- active_mailbox!(address.mailbox_id)
140
- active_domain!(address)
141
- address.update!(catch_all: true, catch_all_evidence: evidence)
142
- address
143
- end
144
- end
145
-
146
- def disable_catch_all(address_id)
147
- context!
148
- catch_all_schema!
149
- # Disabling is permitted even after suspension; retain the previous
150
- # verification evidence for operator inspection.
151
- Address.where(tenant_key: tenant_key).find(address_id).tap { |address| address.update!(catch_all: false) }
152
- end
153
-
154
- def suspend(mailbox_id)
155
- mailboxes.find(mailbox_id).tap { |mailbox| mailbox.update!(state: "suspended") }
156
- end
157
-
158
- def resume(mailbox_id)
159
- mailboxes.find(mailbox_id).tap { |mailbox| mailbox.update!(state: "active") }
160
- end
161
-
162
- def messages(mailbox_id)
163
- mailbox = mailboxes.find(mailbox_id)
164
- Message.where(tenant_key: tenant_key, mailbox_id: mailbox.id)
165
- end
166
-
167
- def addresses(mailbox_id)
168
- mailbox = mailboxes.find(mailbox_id)
169
- Address.where(tenant_key: tenant_key, mailbox_id: mailbox.id)
170
- end
171
-
172
- def suspend_address(mailbox_id, address_id)
173
- addresses(mailbox_id).find(address_id).tap { |address| address.update!(state: "suspended") }
174
- end
175
-
176
21
  def outbound_messages(mailbox_id)
177
22
  mailbox = mailboxes.find(mailbox_id)
178
23
  OutboundMessage.where(tenant_key: tenant_key, mailbox_id: mailbox.id)
179
24
  end
180
25
 
181
- def inbound_email(mailbox_id, message_id)
182
- message = messages(mailbox_id).find(message_id)
183
- ensure_storage_connection!
184
- ::ActionMailbox::InboundEmail.find(message.inbound_email_id)
185
- end
186
-
187
- # Explicit permanent removal. Archive is the reversible default.
188
- # Other mailbox memberships retain their shared raw source.
189
- def purge_message(mailbox_id, message_id)
190
- context!
191
- ensure_storage_connection!
192
- Message.transaction do
193
- message = messages(mailbox_id).find(message_id)
194
- inbound_id = message.inbound_email_id
195
- message.destroy!
196
- unless Message.where(inbound_email_id: inbound_id).exists?
197
- ::ActionMailbox::InboundEmail.find_by(id: inbound_id)&.destroy!
198
- end
199
- end
200
- end
201
-
202
- def mark_read(mailbox_id, message_id, read: true)
203
- messages(mailbox_id).find(message_id).update!(read_at: read ? Time.now.utc : nil)
204
- end
205
-
206
- def archive(mailbox_id, message_id, archived: true)
207
- messages(mailbox_id).find(message_id).update!(archived_at: archived ? Time.now.utc : nil)
208
- end
209
-
210
26
  def prepare(mailbox_id, operation_key:, mail:)
211
27
  context!
212
28
  raise ArgumentError, "operation_key is required" unless operation_key.is_a?(String) && !operation_key.strip.empty?
@@ -223,6 +39,7 @@ module Cloudflare
223
39
  raise ValidationError, "MIME and envelope sender must match the mailbox address"
224
40
  end
225
41
  domain = active_domain!(address)
42
+ raise Unavailable, "domain is not enabled for Cloudflare sending" if domain.account_id.to_s.strip.empty?
226
43
  raise Unavailable, "domain is not enabled for sending" unless domain.sending_enabled
227
44
  key = "mailbox:#{Digest::SHA256.hexdigest(JSON.generate([tenant_key, mailbox.id, operation_key]))}"
228
45
  Mailbox.transaction do
@@ -247,6 +64,7 @@ module Cloudflare
247
64
  delivery = linked_delivery!(mailbox.id, operation_key)
248
65
  address = authorized_sender!(mailbox, delivery.from_address)
249
66
  domain = active_domain!(address)
67
+ raise Unavailable, "domain is not enabled for Cloudflare sending" if domain.account_id.to_s.strip.empty?
250
68
  raise Unavailable, "domain is not enabled for sending" unless domain.sending_enabled && domain.account_id == delivery.account_id
251
69
  ActiveRecord::Outbox.deliver(delivery, client: Mailboxes.client_for(tenant_key, delivery.account_id))
252
70
  handler = Mailboxes.delivery_handler
@@ -284,77 +102,7 @@ module Cloudflare
284
102
  links.last&.id
285
103
  end
286
104
 
287
- def receive(address, directory)
288
- context!
289
- ensure_storage_connection! if defined?(::ActionMailbox::InboundEmail)
290
- Mailbox.transaction do
291
- destination = resolve_destination(address, directory)
292
- inbound = yield destination
293
- Message.create_or_find_by!(tenant_key: tenant_key, mailbox_id: destination.mailbox_id,
294
- inbound_email_id: inbound.id) { |row| row.recipient = address } if inbound
295
- inbound
296
- end
297
- end
298
-
299
- def with_recipient(address, directory)
300
- context!
301
- yield resolve_destination(address, directory)
302
- end
303
-
304
105
  private
305
-
306
- def resolve_destination(address, directory)
307
- destination = Address.where(tenant_key: tenant_key, address: address,
308
- receiving_domain_id: directory.id).first
309
- fallback = destination.nil?
310
- if fallback && catch_all_schema_available?
311
- destination = Address.where(tenant_key: tenant_key, receiving_domain_id: directory.id,
312
- catch_all: true, state: "active").first
313
- end
314
- # An exact pending/suspended address intentionally reserves its name.
315
- # It must never fall through to another mailbox's catch-all.
316
- raise Unavailable, "mailbox address unavailable" unless destination&.state == "active"
317
- mailbox = active_mailbox!(destination.mailbox_id)
318
- active_domain!(destination)
319
- Destination.new(tenant_key: tenant_key, mailbox_id: mailbox.id,
320
- address_id: destination.id, receiving_domain_id: directory.id,
321
- recipient: address, owner_ref: mailbox.owner_ref, catch_all: fallback)
322
- rescue ::ActiveRecord::RecordNotFound
323
- raise Unavailable, "mailbox address unavailable"
324
- end
325
-
326
- def context!
327
- raise ConfigurationError, "mailbox session used outside its tenant context" unless Tenancy.require_context! == tenant_key
328
- end
329
-
330
- def catch_all_schema_available?
331
- Address.connection.column_exists?(Address.table_name, :catch_all) &&
332
- Address.connection.column_exists?(Address.table_name, :catch_all_evidence)
333
- end
334
-
335
- def catch_all_schema!
336
- unless catch_all_schema_available?
337
- raise ConfigurationError, "run cloudflare:email:mailboxes:catch_all migrations for this tenant first"
338
- end
339
- end
340
-
341
- def ensure_storage_connection!
342
- unless defined?(::ActionMailbox::InboundEmail) && ::ActionMailbox::InboundEmail.connection_pool == Mailbox.connection_pool &&
343
- ::ActiveStorage::Blob.connection_pool == Mailbox.connection_pool
344
- raise ConfigurationError, "ActionMailbox, ActiveStorage and mailbox records must share the tenant connection"
345
- end
346
- end
347
-
348
- def active_mailbox!(id)
349
- context!
350
- mailboxes.where(state: "active").find(id)
351
- end
352
-
353
- def active_domain!(address)
354
- ReceivingDomain.find_by!(id: address.receiving_domain_id, domain: address.domain,
355
- tenant_key: tenant_key, state: "active")
356
- end
357
-
358
106
  def authorized_sender!(mailbox, address)
359
107
  Address.where(tenant_key: tenant_key, mailbox_id: mailbox.id, state: "active")
360
108
  .find_by!(address: Mailboxes.canonical_address(address))
@@ -367,6 +115,7 @@ module Cloudflare
367
115
  delivery
368
116
  end
369
117
  end
118
+ Session.include(CloudflareSession)
370
119
  end
371
120
  end
372
121
  end
@@ -9,3 +9,4 @@ require "cloudflare/email/mailboxes/service"
9
9
  require "cloudflare/email/mailboxes/jobs"
10
10
 
11
11
  Cloudflare::Email::Mailboxes.enable!
12
+ require "mailbox_kit/railtie" if defined?(::Rails::Railtie)
@@ -1,31 +1,9 @@
1
+ require "mailbox-kit"
2
+ require "mailbox_kit/management/adapter"
1
3
  module Cloudflare
2
4
  module Email
3
5
  module Management
4
- # A host supplies authentication, mailbox ownership and domain entitlement.
5
- # Defaults deliberately grant no access. No principal comes from params.
6
- class Adapter
7
- attr_reader :controller
8
-
9
- def initialize(controller = nil)
10
- @controller = controller
11
- end
12
-
13
- def authenticate! = false
14
- def tenant_key = nil
15
- def mailboxes(session) = session.mailboxes.none
16
- def allowed?(action, mailbox = nil) = false
17
- def domains(session) = []
18
-
19
- # Override these two hooks when the host keeps a linked product model or
20
- # uses a deployment policy to activate/provision verified addresses.
21
- def create_mailbox(session, name:, address:)
22
- session.create(name: name, address: address)
23
- end
24
-
25
- def add_address(session, mailbox, address:)
26
- session.add_address(mailbox.id, address: address)
27
- end
28
- end
6
+ Adapter = MailboxKit::Management::Adapter
29
7
  end
30
8
  end
31
9
  end
@@ -1,17 +1,10 @@
1
+ require "mailbox_kit/management/configuration"
1
2
  module Cloudflare
2
3
  module Email
3
4
  module Management
4
- class Configuration
5
- attr_accessor :adapter, :back_path
6
- end
7
-
8
- def self.configuration
9
- @configuration ||= Configuration.new
10
- end
11
-
12
- def self.configure
13
- yield configuration
14
- end
5
+ Configuration = MailboxKit::Management::Configuration
6
+ def self.configuration = MailboxKit::Management.configuration
7
+ def self.configure(&block) = MailboxKit::Management.configure(&block)
15
8
  end
16
9
  end
17
10
  end
@@ -8,6 +8,8 @@ module Cloudflare
8
8
  isolate_namespace Cloudflare::Email::Management
9
9
  config.root = File.expand_path("../../../..", __dir__)
10
10
 
11
+ config.paths["app/views"] << File.join(MailboxKit::ROOT, "app/views")
12
+
11
13
  config.paths["config/routes.rb"] = "lib/cloudflare/email/management/routes.rb"
12
14
  end
13
15
  end