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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -1
- data/README.md +265 -388
- data/SECURITY.md +51 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +62 -6
- data/docs/activerecord-tenanted.md +135 -0
- data/docs/architecture.md +95 -0
- data/docs/delivery-events.md +177 -0
- data/docs/features.md +107 -0
- data/docs/getting-started.md +298 -0
- data/docs/mailboxes.md +299 -0
- data/docs/outbox.md +175 -0
- data/docs/tenant-mailboxes-plan.md +149 -0
- data/docs/thread-correlation.md +27 -0
- data/docs/troubleshooting.md +103 -0
- data/docs/upgrading-0.2.md +96 -0
- data/docs/verification/2026-09-10-followup.md +49 -0
- data/docs/verification/2026-09-10-install-upgrade.md +86 -0
- data/docs/verification/2026-09-10-live.md +102 -0
- data/docs/verification/2026-09-10.md +175 -0
- data/docs/verification/2026-09-11-extraction.md +32 -0
- data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
- data/docs/verification/2026-09-11-security.md +76 -0
- data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
- data/examples/plain_ruby.rb +24 -0
- data/lib/cloudflare/email/active_record/base.rb +48 -0
- data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
- data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
- data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
- data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
- data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
- data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
- data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
- data/lib/cloudflare/email/active_record/outbox.rb +219 -0
- data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
- data/lib/cloudflare/email/active_record.rb +5 -0
- data/lib/cloudflare/email/client.rb +51 -11
- data/lib/cloudflare/email/consume_events_task.rb +22 -0
- data/lib/cloudflare/email/delivery_event.rb +70 -0
- data/lib/cloudflare/email/delivery_method.rb +3 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +3 -0
- data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
- data/lib/cloudflare/email/dev_tunnel.rb +31 -5
- data/lib/cloudflare/email/doctor.rb +16 -38
- data/lib/cloudflare/email/endpoint.rb +24 -0
- data/lib/cloudflare/email/engine.rb +28 -0
- data/lib/cloudflare/email/envelope.rb +58 -0
- data/lib/cloudflare/email/event_consumer.rb +86 -0
- data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
- data/lib/cloudflare/email/mailboxes/events.rb +112 -0
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
- data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
- data/lib/cloudflare/email/mailboxes/models.rb +148 -0
- data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
- data/lib/cloudflare/email/mailboxes/service.rb +288 -0
- data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
- data/lib/cloudflare/email/mailboxes.rb +11 -0
- data/lib/cloudflare/email/message_id.rb +12 -0
- data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
- data/lib/cloudflare/email/provision_route_task.rb +2 -1
- data/lib/cloudflare/email/replay_events_job.rb +18 -0
- data/lib/cloudflare/email/response.rb +21 -3
- data/lib/cloudflare/email/routing_provisioner.rb +71 -45
- data/lib/cloudflare/email/send_job.rb +41 -0
- data/lib/cloudflare/email/send_test.rb +5 -29
- data/lib/cloudflare/email/signing.rb +2 -2
- data/lib/cloudflare/email/tenancy.rb +78 -0
- data/lib/cloudflare/email/tenant_job_context.rb +90 -0
- data/lib/cloudflare/email/verification.rb +30 -8
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +5 -3
- data/lib/cloudflare-email.rb +4 -1
- data/lib/generators/cloudflare/email/install_generator.rb +48 -33
- data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +54 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
- data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
- data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
- data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
- data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
- data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
- data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
- data/lib/tasks/cloudflare_email.rake +41 -4
- data/templates/worker/README.md +61 -11
- data/templates/worker/package-lock.json +2627 -0
- data/templates/worker/package.json +8 -5
- data/templates/worker/scripts/wrangler.mjs +16 -0
- data/templates/worker/src/index.js +83 -4
- data/templates/worker/test/index.test.ts +117 -5
- data/templates/worker/wrangler.toml +13 -4
- metadata +107 -6
- 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
|
|
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=
|
|
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
|
|
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
|
|
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
|
|
13
|
-
@
|
|
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
|