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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +109 -0
  3. data/README.md +44 -9
  4. data/app/controllers/cloudflare/email/ingress_controller.rb +15 -60
  5. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +9 -0
  6. data/app/controllers/cloudflare/email/management/styles_controller.rb +9 -0
  7. data/docs/activerecord-tenanted.md +46 -0
  8. data/docs/architecture.md +13 -3
  9. data/docs/custom-ingress.md +211 -0
  10. data/docs/delivery-events.md +9 -0
  11. data/docs/features.md +36 -5
  12. data/docs/getting-started.md +19 -8
  13. data/docs/mailboxes.md +74 -3
  14. data/docs/management-engine.md +190 -0
  15. data/docs/routing-deliveries.md +94 -0
  16. data/docs/routing-diagnostics.md +75 -0
  17. data/docs/troubleshooting.md +5 -4
  18. data/docs/upgrading-0.2.md +5 -0
  19. data/docs/upgrading-0.3.md +55 -0
  20. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  21. data/docs/verification/2026-09-12-destination-context.md +46 -0
  22. data/docs/verification/2026-09-13-action-mailbox-core.md +96 -0
  23. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  24. data/lib/cloudflare/email/active_record/base.rb +3 -41
  25. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  26. data/lib/cloudflare/email/check_route_task.rb +21 -0
  27. data/lib/cloudflare/email/client.rb +45 -10
  28. data/lib/cloudflare/email/delivery_method.rb +2 -0
  29. data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
  30. data/lib/cloudflare/email/engine.rb +6 -13
  31. data/lib/cloudflare/email/envelope.rb +4 -14
  32. data/lib/cloudflare/email/error.rb +5 -12
  33. data/lib/cloudflare/email/event_consumer.rb +12 -3
  34. data/lib/cloudflare/email/ingress.rb +126 -0
  35. data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
  36. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
  37. data/lib/cloudflare/email/mailboxes/models.rb +8 -130
  38. data/lib/cloudflare/email/mailboxes/service.rb +7 -174
  39. data/lib/cloudflare/email/mailboxes.rb +1 -0
  40. data/lib/cloudflare/email/management/adapter.rb +9 -0
  41. data/lib/cloudflare/email/management/configuration.rb +10 -0
  42. data/lib/cloudflare/email/management/engine.rb +17 -0
  43. data/lib/cloudflare/email/management/routes.rb +14 -0
  44. data/lib/cloudflare/email/management.rb +7 -0
  45. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  46. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  47. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  48. data/lib/cloudflare/email/tenancy.rb +2 -73
  49. data/lib/cloudflare/email/tenant_job_context.rb +2 -85
  50. data/lib/cloudflare/email/verification.rb +17 -9
  51. data/lib/cloudflare/email/version.rb +1 -1
  52. data/lib/cloudflare/email/worker_deployer.rb +36 -5
  53. data/lib/cloudflare-email.rb +6 -0
  54. data/lib/generators/cloudflare/email/install_generator.rb +6 -5
  55. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  56. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  57. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +5 -0
  58. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
  59. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  60. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  61. data/lib/tasks/cloudflare_email.rake +2 -1
  62. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  63. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  64. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  65. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  66. data/templates/deploy-to-cloudflare/README.md +104 -0
  67. data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
  68. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  69. data/templates/deploy-to-cloudflare/package.json +37 -0
  70. data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
  71. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  72. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  73. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  74. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  75. data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
  76. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  77. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  78. data/templates/worker/README.md +186 -15
  79. data/templates/worker/docs/domain-setup.md +198 -0
  80. data/templates/worker/docs/durable-inbound.md +149 -0
  81. data/templates/worker/package-lock.json +2 -2
  82. data/templates/worker/package.json +2 -1
  83. data/templates/worker/scripts/check-subdomains.mjs +54 -0
  84. data/templates/worker/src/index.js +331 -38
  85. data/templates/worker/test/durable.test.ts +193 -0
  86. data/templates/worker/test/index.test.ts +78 -20
  87. data/templates/worker/test/relay.test.ts +226 -0
  88. data/templates/worker/test/subdomains.test.ts +51 -0
  89. data/templates/worker/wrangler.toml +50 -0
  90. 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
- # 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 }
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: "Cloudflare::Email::Mailboxes::Mailbox"
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 "cloudflare/email/envelope"
3
-
2
+ require "mailbox_kit/mailboxes/service"
4
3
  module Cloudflare
5
4
  module Email
6
5
  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
-
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
@@ -9,3 +9,4 @@ require "cloudflare/email/mailboxes/service"
9
9
  require "cloudflare/email/mailboxes/jobs"
10
10
 
11
11
  Cloudflare::Email::Mailboxes.enable!
12
+ require "mailbox_kit/railtie" if defined?(::Rails::Railtie)
@@ -0,0 +1,9 @@
1
+ require "mailbox-kit"
2
+ require "mailbox_kit/management/adapter"
3
+ module Cloudflare
4
+ module Email
5
+ module Management
6
+ Adapter = MailboxKit::Management::Adapter
7
+ end
8
+ end
9
+ 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