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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +14 -4
- data/app/controllers/cloudflare/email/ingress_controller.rb +1 -2
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +2 -166
- data/app/controllers/cloudflare/email/management/styles_controller.rb +2 -6
- data/docs/custom-ingress.md +5 -1
- data/docs/features.md +1 -1
- data/docs/getting-started.md +4 -3
- data/docs/mailboxes.md +8 -1
- data/docs/routing-diagnostics.md +7 -0
- data/docs/troubleshooting.md +1 -1
- data/docs/verification/2026-09-13-action-mailbox-core.md +96 -0
- data/lib/cloudflare/email/active_record/base.rb +3 -53
- data/lib/cloudflare/email/engine.rb +1 -13
- data/lib/cloudflare/email/envelope.rb +2 -12
- data/lib/cloudflare/email/error.rb +5 -12
- data/lib/cloudflare/email/ingress.rb +19 -8
- data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
- data/lib/cloudflare/email/mailboxes/models.rb +8 -147
- data/lib/cloudflare/email/mailboxes/service.rb +7 -258
- data/lib/cloudflare/email/mailboxes.rb +1 -0
- data/lib/cloudflare/email/management/adapter.rb +3 -25
- data/lib/cloudflare/email/management/configuration.rb +4 -11
- data/lib/cloudflare/email/management/engine.rb +2 -0
- data/lib/cloudflare/email/tenancy.rb +2 -73
- data/lib/cloudflare/email/tenant_job_context.rb +2 -85
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare-email.rb +6 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +1 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
- data/templates/deploy-to-cloudflare/README.md +21 -1
- data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
- data/templates/deploy-to-cloudflare/package.json +1 -0
- data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
- data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
- data/templates/worker/README.md +6 -0
- data/templates/worker/docs/domain-setup.md +198 -0
- data/templates/worker/package.json +1 -0
- data/templates/worker/scripts/check-subdomains.mjs +54 -0
- data/templates/worker/test/subdomains.test.ts +51 -0
- metadata +22 -6
- data/app/views/cloudflare/email/management/mailboxes/index.html.erb +0 -65
- data/app/views/cloudflare/email/management/mailboxes/message.html.erb +0 -34
- data/app/views/cloudflare/email/management/mailboxes/show.html.erb +0 -86
- data/app/views/layouts/cloudflare/email/management.html.erb +0 -29
- 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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
self.table_name
|
|
11
|
-
|
|
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: "
|
|
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 "
|
|
3
|
-
|
|
2
|
+
require "mailbox_kit/mailboxes/service"
|
|
4
3
|
module Cloudflare
|
|
5
4
|
module Email
|
|
6
5
|
module Mailboxes
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|