cloudflare-email 0.2.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 +109 -0
- data/README.md +44 -9
- data/app/controllers/cloudflare/email/ingress_controller.rb +15 -60
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +9 -0
- data/app/controllers/cloudflare/email/management/styles_controller.rb +9 -0
- data/docs/activerecord-tenanted.md +46 -0
- data/docs/architecture.md +13 -3
- data/docs/custom-ingress.md +211 -0
- data/docs/delivery-events.md +9 -0
- data/docs/features.md +36 -5
- data/docs/getting-started.md +19 -8
- data/docs/mailboxes.md +74 -3
- data/docs/management-engine.md +190 -0
- data/docs/routing-deliveries.md +94 -0
- data/docs/routing-diagnostics.md +75 -0
- data/docs/troubleshooting.md +5 -4
- data/docs/upgrading-0.2.md +5 -0
- data/docs/upgrading-0.3.md +55 -0
- data/docs/verification/2026-09-11-custom-ingress.md +49 -0
- data/docs/verification/2026-09-12-destination-context.md +46 -0
- data/docs/verification/2026-09-13-action-mailbox-core.md +96 -0
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
- data/lib/cloudflare/email/active_record/base.rb +3 -41
- data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
- data/lib/cloudflare/email/check_route_task.rb +21 -0
- data/lib/cloudflare/email/client.rb +45 -10
- data/lib/cloudflare/email/delivery_method.rb +2 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
- data/lib/cloudflare/email/engine.rb +6 -13
- data/lib/cloudflare/email/envelope.rb +4 -14
- data/lib/cloudflare/email/error.rb +5 -12
- data/lib/cloudflare/email/event_consumer.rb +12 -3
- data/lib/cloudflare/email/ingress.rb +126 -0
- 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 -130
- data/lib/cloudflare/email/mailboxes/service.rb +7 -174
- data/lib/cloudflare/email/mailboxes.rb +1 -0
- data/lib/cloudflare/email/management/adapter.rb +9 -0
- data/lib/cloudflare/email/management/configuration.rb +10 -0
- data/lib/cloudflare/email/management/engine.rb +17 -0
- data/lib/cloudflare/email/management/routes.rb +14 -0
- data/lib/cloudflare/email/management.rb +7 -0
- data/lib/cloudflare/email/provider_metadata.rb +77 -0
- data/lib/cloudflare/email/routing_analytics.rb +196 -0
- data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
- data/lib/cloudflare/email/tenancy.rb +2 -73
- data/lib/cloudflare/email/tenant_job_context.rb +2 -85
- data/lib/cloudflare/email/verification.rb +17 -9
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +36 -5
- data/lib/cloudflare-email.rb +6 -0
- data/lib/generators/cloudflare/email/install_generator.rb +6 -5
- data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
- data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +5 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
- data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
- data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
- data/lib/tasks/cloudflare_email.rake +2 -1
- data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
- data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
- data/templates/deploy-to-cloudflare/.gitignore +6 -0
- data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
- data/templates/deploy-to-cloudflare/README.md +104 -0
- data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
- data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
- data/templates/deploy-to-cloudflare/package.json +37 -0
- data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
- data/templates/deploy-to-cloudflare/src/index.js +445 -0
- data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
- data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
- data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
- data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
- data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
- data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
- data/templates/worker/README.md +186 -15
- data/templates/worker/docs/domain-setup.md +198 -0
- data/templates/worker/docs/durable-inbound.md +149 -0
- data/templates/worker/package-lock.json +2 -2
- data/templates/worker/package.json +2 -1
- data/templates/worker/scripts/check-subdomains.mjs +54 -0
- data/templates/worker/src/index.js +331 -38
- data/templates/worker/test/durable.test.ts +193 -0
- data/templates/worker/test/index.test.ts +78 -20
- data/templates/worker/test/relay.test.ts +226 -0
- data/templates/worker/test/subdomains.test.ts +51 -0
- data/templates/worker/wrangler.toml +50 -0
- metadata +65 -3
|
@@ -1,142 +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 { 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
|
|
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
14
|
class OutboundMessage < Cloudflare::Email::ActiveRecord::Base
|
|
137
15
|
include TenantIdentity
|
|
138
16
|
self.table_name = "cloudflare_email_mailbox_outbound_messages"
|
|
139
|
-
belongs_to :mailbox, class_name: "
|
|
17
|
+
belongs_to :mailbox, class_name: "MailboxKit::Mailboxes::Mailbox"
|
|
140
18
|
belongs_to :outbound_delivery, class_name: "Cloudflare::Email::ActiveRecord::OutboundDelivery"
|
|
141
19
|
attr_readonly :mailbox_id, :outbound_delivery_id
|
|
142
20
|
validates :mailbox, :outbound_delivery, presence: true
|
|
@@ -1,93 +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
|
-
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
|
-
|
|
6
|
+
# Provider-specific behavior is installed only when the Cloudflare
|
|
7
|
+
# integration is explicitly loaded. Core sessions remain receiving-only.
|
|
8
|
+
module CloudflareSession
|
|
91
9
|
def provision_address!(address_id, provisioner:, worker_name:)
|
|
92
10
|
context!
|
|
93
11
|
raise ArgumentError, "provision outside a database transaction" if Address.connection.transaction_open?
|
|
@@ -100,62 +18,11 @@ module Cloudflare
|
|
|
100
18
|
activate_address!(address.id, evidence: "Cloudflare address rule provisioned for Worker #{worker_name}")
|
|
101
19
|
end
|
|
102
20
|
|
|
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
21
|
def outbound_messages(mailbox_id)
|
|
126
22
|
mailbox = mailboxes.find(mailbox_id)
|
|
127
23
|
OutboundMessage.where(tenant_key: tenant_key, mailbox_id: mailbox.id)
|
|
128
24
|
end
|
|
129
25
|
|
|
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
26
|
def prepare(mailbox_id, operation_key:, mail:)
|
|
160
27
|
context!
|
|
161
28
|
raise ArgumentError, "operation_key is required" unless operation_key.is_a?(String) && !operation_key.strip.empty?
|
|
@@ -172,6 +39,7 @@ module Cloudflare
|
|
|
172
39
|
raise ValidationError, "MIME and envelope sender must match the mailbox address"
|
|
173
40
|
end
|
|
174
41
|
domain = active_domain!(address)
|
|
42
|
+
raise Unavailable, "domain is not enabled for Cloudflare sending" if domain.account_id.to_s.strip.empty?
|
|
175
43
|
raise Unavailable, "domain is not enabled for sending" unless domain.sending_enabled
|
|
176
44
|
key = "mailbox:#{Digest::SHA256.hexdigest(JSON.generate([tenant_key, mailbox.id, operation_key]))}"
|
|
177
45
|
Mailbox.transaction do
|
|
@@ -196,6 +64,7 @@ module Cloudflare
|
|
|
196
64
|
delivery = linked_delivery!(mailbox.id, operation_key)
|
|
197
65
|
address = authorized_sender!(mailbox, delivery.from_address)
|
|
198
66
|
domain = active_domain!(address)
|
|
67
|
+
raise Unavailable, "domain is not enabled for Cloudflare sending" if domain.account_id.to_s.strip.empty?
|
|
199
68
|
raise Unavailable, "domain is not enabled for sending" unless domain.sending_enabled && domain.account_id == delivery.account_id
|
|
200
69
|
ActiveRecord::Outbox.deliver(delivery, client: Mailboxes.client_for(tenant_key, delivery.account_id))
|
|
201
70
|
handler = Mailboxes.delivery_handler
|
|
@@ -233,44 +102,7 @@ module Cloudflare
|
|
|
233
102
|
links.last&.id
|
|
234
103
|
end
|
|
235
104
|
|
|
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
105
|
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
106
|
def authorized_sender!(mailbox, address)
|
|
275
107
|
Address.where(tenant_key: tenant_key, mailbox_id: mailbox.id, state: "active")
|
|
276
108
|
.find_by!(address: Mailboxes.canonical_address(address))
|
|
@@ -283,6 +115,7 @@ module Cloudflare
|
|
|
283
115
|
delivery
|
|
284
116
|
end
|
|
285
117
|
end
|
|
118
|
+
Session.include(CloudflareSession)
|
|
286
119
|
end
|
|
287
120
|
end
|
|
288
121
|
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
require "mailbox_kit/management/configuration"
|
|
2
|
+
module Cloudflare
|
|
3
|
+
module Email
|
|
4
|
+
module Management
|
|
5
|
+
Configuration = MailboxKit::Management::Configuration
|
|
6
|
+
def self.configuration = MailboxKit::Management.configuration
|
|
7
|
+
def self.configure(&block) = MailboxKit::Management.configure(&block)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "rails/engine"
|
|
2
|
+
require "action_controller/railtie"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
module Management
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace Cloudflare::Email::Management
|
|
9
|
+
config.root = File.expand_path("../../../..", __dir__)
|
|
10
|
+
|
|
11
|
+
config.paths["app/views"] << File.join(MailboxKit::ROOT, "app/views")
|
|
12
|
+
|
|
13
|
+
config.paths["config/routes.rb"] = "lib/cloudflare/email/management/routes.rb"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Cloudflare::Email::Management::Engine.routes.draw do
|
|
2
|
+
get "style.css", to: "styles#show", as: :style
|
|
3
|
+
root to: "mailboxes#index"
|
|
4
|
+
resources :mailboxes, only: [:index, :show, :create] do
|
|
5
|
+
member do
|
|
6
|
+
post :aliases, action: :add_address
|
|
7
|
+
post :suspend
|
|
8
|
+
post :resume
|
|
9
|
+
get "messages/:message_id", action: :message, as: :message
|
|
10
|
+
post :mark_read
|
|
11
|
+
post :archive
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Explicit opt-in: require this before Rails initializes, then configure the
|
|
2
|
+
# adapter in an initializer. Loading the core gem never mounts this interface.
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
require "cloudflare-email"
|
|
5
|
+
require "cloudflare/email/management/adapter"
|
|
6
|
+
require "cloudflare/email/management/configuration"
|
|
7
|
+
require "cloudflare/email/management/engine"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "cloudflare/email/signing"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
# Signed assertions supplied by the forwarding integration, not proof that
|
|
7
|
+
# the original sender passed SPF/DKIM/DMARC. Never read these from MIME.
|
|
8
|
+
module ProviderMetadata
|
|
9
|
+
METADATA_KEY = "cloudflare_email_provider_metadata".freeze
|
|
10
|
+
MAX_ENCODED_BYTES = 16 * 1024
|
|
11
|
+
MAX_DEPTH = 8
|
|
12
|
+
SOURCE = /\A[a-z][a-z0-9_.-]{0,127}\z/
|
|
13
|
+
|
|
14
|
+
def self.valid?(value)
|
|
15
|
+
value.is_a?(Hash) && value.size == 2 && value.key?("data") && value.key?("source") &&
|
|
16
|
+
value["source"].is_a?(String) && SOURCE.match?(value["source"]) &&
|
|
17
|
+
value["data"].is_a?(Hash) && json_value?(value, 0)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.json_value?(value, depth)
|
|
21
|
+
case value
|
|
22
|
+
when Hash
|
|
23
|
+
depth < MAX_DEPTH && value.all? { |key, item| key.is_a?(String) && utf8?(key) && json_value?(item, depth + 1) }
|
|
24
|
+
when Array
|
|
25
|
+
depth < MAX_DEPTH && value.all? { |item| json_value?(item, depth + 1) }
|
|
26
|
+
when String then utf8?(value)
|
|
27
|
+
when Integer, TrueClass, FalseClass, NilClass then true
|
|
28
|
+
when Float then value.finite?
|
|
29
|
+
else false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.utf8?(value)
|
|
34
|
+
value.encode(Encoding::UTF_8).valid_encoding?
|
|
35
|
+
rescue EncodingError
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.encode(source:, data:)
|
|
40
|
+
value = {"source" => source, "data" => data}
|
|
41
|
+
raise ArgumentError, "invalid provider metadata" unless valid?(value)
|
|
42
|
+
encoded = Signing.base64url_encode(JSON.generate(value))
|
|
43
|
+
raise ArgumentError, "provider metadata exceeds size limit" if encoded.bytesize > MAX_ENCODED_BYTES
|
|
44
|
+
encoded
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.decode(encoded)
|
|
48
|
+
return unless encoded.is_a?(String) && encoded.bytesize.between?(1, MAX_ENCODED_BYTES)
|
|
49
|
+
return unless /\A[A-Za-z0-9_-]+\z/.match?(encoded)
|
|
50
|
+
bytes = Signing.base64url_decode(encoded)
|
|
51
|
+
return unless Signing.base64url_encode(bytes) == encoded
|
|
52
|
+
text = bytes.force_encoding(Encoding::UTF_8)
|
|
53
|
+
return unless text.valid_encoding?
|
|
54
|
+
value = JSON.parse(text, max_nesting: MAX_DEPTH)
|
|
55
|
+
value if valid?(value)
|
|
56
|
+
rescue JSON::ParserError, ArgumentError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.for(inbound_email)
|
|
61
|
+
metadata = inbound_email.raw_email.blob&.metadata&.fetch(METADATA_KEY, nil)
|
|
62
|
+
return unless metadata.is_a?(Hash) && metadata["signature_version"] == 3
|
|
63
|
+
value = metadata.reject { |key, _| key == "signature_version" }
|
|
64
|
+
value if valid?(value)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Stable receipt identity if JSON object members arrive in another order.
|
|
68
|
+
def self.canonical(value)
|
|
69
|
+
case value
|
|
70
|
+
when Hash then value.keys.sort.to_h { |key| [key, canonical(value[key])] }
|
|
71
|
+
when Array then value.map { |item| canonical(item) }
|
|
72
|
+
else value
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|