mailbox-kit 0.1.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 (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +104 -0
  4. data/app/controllers/mailbox_kit/management/mailboxes_controller.rb +171 -0
  5. data/app/controllers/mailbox_kit/management/styles_controller.rb +11 -0
  6. data/app/views/layouts/mailbox_kit/management.html.erb +29 -0
  7. data/app/views/mailbox_kit/management/mailboxes/index.html.erb +65 -0
  8. data/app/views/mailbox_kit/management/mailboxes/message.html.erb +34 -0
  9. data/app/views/mailbox_kit/management/mailboxes/show.html.erb +86 -0
  10. data/docs/integration.md +283 -0
  11. data/docs/upgrading.md +60 -0
  12. data/lib/generators/mailbox_kit/install/install_generator.rb +24 -0
  13. data/lib/generators/mailbox_kit/install/templates/create_mailbox_kit_mailboxes.rb +51 -0
  14. data/lib/generators/mailbox_kit/install/templates/create_mailbox_kit_receiving_domains.rb +16 -0
  15. data/lib/generators/mailbox_kit/upgrade/templates/allow_provider_neutral_receiving_domains.rb +9 -0
  16. data/lib/generators/mailbox_kit/upgrade/templates/index_mailbox_kit_inbound_messages.rb +12 -0
  17. data/lib/generators/mailbox_kit/upgrade/upgrade_generator.rb +23 -0
  18. data/lib/mailbox-kit.rb +11 -0
  19. data/lib/mailbox_kit/active_record/base.rb +58 -0
  20. data/lib/mailbox_kit/address_syntax.rb +19 -0
  21. data/lib/mailbox_kit/error.rb +14 -0
  22. data/lib/mailbox_kit/inbound_email.rb +56 -0
  23. data/lib/mailbox_kit/mailboxes/configuration.rb +19 -0
  24. data/lib/mailbox_kit/mailboxes/inbound_retention.rb +16 -0
  25. data/lib/mailbox_kit/mailboxes/models.rb +152 -0
  26. data/lib/mailbox_kit/mailboxes/service.rb +312 -0
  27. data/lib/mailbox_kit/mailboxes.rb +9 -0
  28. data/lib/mailbox_kit/management/adapter.rb +29 -0
  29. data/lib/mailbox_kit/management/configuration.rb +15 -0
  30. data/lib/mailbox_kit/management/engine.rb +13 -0
  31. data/lib/mailbox_kit/management/management.css +68 -0
  32. data/lib/mailbox_kit/management/routes.rb +14 -0
  33. data/lib/mailbox_kit/management.rb +6 -0
  34. data/lib/mailbox_kit/railtie.rb +21 -0
  35. data/lib/mailbox_kit/tenancy.rb +76 -0
  36. data/lib/mailbox_kit/tenant_job_context.rb +88 -0
  37. data/lib/mailbox_kit/version.rb +3 -0
  38. metadata +81 -0
@@ -0,0 +1,16 @@
1
+ module MailboxKit
2
+ module Mailboxes
3
+ # ActionMailbox normally deletes processed mail after its retention window.
4
+ # A mailbox membership owns the raw source until explicit application purge.
5
+ module InboundRetention
6
+ def incinerate
7
+ return super unless Mailboxes.enabled?
8
+ # Attachment and purge use the same Rails row lock. A cleanup job
9
+ # must not delete raw mail between the membership check and insert.
10
+ with_lock do
11
+ super unless Message.where(inbound_email_id: id).exists?
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,152 @@
1
+ require "active_record"
2
+ require "mailbox_kit/active_record/base"
3
+
4
+ module MailboxKit
5
+ module Mailboxes
6
+ # Domains are an administrator-maintained receiving directory. An active
7
+ # record is an assertion by the application, not DNS ownership verification.
8
+ class ReceivingDomain < Mailboxes.directory_base
9
+ self.table_name = "cloudflare_email_receiving_domains"
10
+ STATES = %w[pending active suspended].freeze
11
+ attr_readonly :domain, :tenant_key, :account_id
12
+ before_validation(on: :create) { self.domain = domain.to_s.strip.downcase }
13
+ validates :tenant_key, presence: true
14
+ validates :domain, presence: true, uniqueness: true,
15
+ length: { maximum: 253 },
16
+ format: { with: /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+\z/ }
17
+ validates :state, inclusion: { in: STATES }
18
+ validate do
19
+ errors.add(:domain, "has an oversized label") if domain.to_s.split(".").any? { |label| label.length > 63 }
20
+ end
21
+ scope :active, -> { where(state: "active") }
22
+ end
23
+
24
+ module TenantIdentity
25
+ extend ActiveSupport::Concern
26
+ included do
27
+ attr_readonly :tenant_key
28
+ validates :tenant_key, presence: true
29
+ before_validation :assign_tenant_identity
30
+ validate :validate_tenant_identity
31
+ before_destroy :assert_tenant_identity!
32
+ end
33
+
34
+ private
35
+
36
+ def assign_tenant_identity
37
+ self.tenant_key ||= Tenancy.current_key
38
+ end
39
+
40
+ def validate_tenant_identity
41
+ return unless Tenancy.enabled? || Tenancy.current_key
42
+ Tenancy.require_context!
43
+ errors.add(:tenant_key, "does not match the current tenant") unless tenant_key == Tenancy.current_key
44
+ end
45
+
46
+ def assert_tenant_identity!
47
+ return unless Tenancy.enabled? || Tenancy.current_key
48
+ Tenancy.require_context!
49
+ raise ArgumentError, "record belongs to another tenant" unless tenant_key == Tenancy.current_key
50
+ end
51
+
52
+ def validate_parent_tenant(parent, attribute)
53
+ errors.add(attribute, "belongs to another tenant") if parent && parent.tenant_key != tenant_key
54
+ end
55
+ end
56
+
57
+ class Mailbox < MailboxKit::ActiveRecord::Base
58
+ include TenantIdentity
59
+ self.table_name = "cloudflare_email_mailboxes"
60
+ has_many :addresses, class_name: "MailboxKit::Mailboxes::Address", dependent: :restrict_with_exception
61
+ has_many :messages, class_name: "MailboxKit::Mailboxes::Message", dependent: :restrict_with_exception
62
+ validates :name, presence: true, length: { maximum: 255 }
63
+ validates :state, inclusion: { in: %w[active suspended] }
64
+ scope :active, -> { where(state: "active") }
65
+ end
66
+
67
+ class Address < MailboxKit::ActiveRecord::Base
68
+ include TenantIdentity
69
+ self.table_name = "cloudflare_email_addresses"
70
+ belongs_to :mailbox, class_name: "MailboxKit::Mailboxes::Mailbox"
71
+ attr_readonly :mailbox_id, :receiving_domain_id, :local_part, :domain, :address
72
+ before_validation :normalize_address, on: :create
73
+ validates :mailbox, :receiving_domain_id, presence: true
74
+ validates :local_part, length: { in: 1..64 },
75
+ format: { with: /\A[a-z0-9!\#$%&'*+\/=\?^_`{|}~-]+(?:\.[a-z0-9!\#$%&'*+\/=\?^_`{|}~-]+)*\z/ }
76
+ validates :address, uniqueness: true, length: { maximum: 254 }
77
+ validates :state, inclusion: { in: %w[pending active suspended] }
78
+ validate :validate_directory
79
+ validate :validate_catch_all
80
+ validate { validate_parent_tenant(mailbox, :mailbox) }
81
+ scope :active, -> { where(state: "active") }
82
+
83
+ def receiving_domain
84
+ ReceivingDomain.find_by(id: receiving_domain_id)
85
+ end
86
+
87
+ private
88
+
89
+ def normalize_address
90
+ self.local_part = local_part.to_s.strip.downcase
91
+ self.domain = domain.to_s.strip.downcase
92
+ self.address = "#{local_part}@#{domain}"
93
+ end
94
+
95
+ def validate_directory
96
+ registered = receiving_domain
97
+ unless registered && registered.domain == domain && registered.tenant_key == tenant_key
98
+ errors.add(:receiving_domain_id, "must match this tenant and domain")
99
+ return
100
+ end
101
+ if new_record? && registered.state != "active"
102
+ errors.add(:receiving_domain_id, "must be active before creating addresses")
103
+ end
104
+ end
105
+
106
+ def validate_catch_all
107
+ # Existing exact-address installations need not migrate until they
108
+ # opt into catch-all receiving.
109
+ return unless has_attribute?(:catch_all) && self[:catch_all]
110
+ errors.add(:catch_all_evidence, "is required") if self[:catch_all_evidence].to_s.strip.empty?
111
+ if will_save_change_to_catch_all? && state != "active"
112
+ errors.add(:catch_all, "requires an active address")
113
+ end
114
+ return unless state == "active"
115
+ errors.add(:catch_all, "requires an active mailbox") unless mailbox&.state == "active"
116
+ errors.add(:catch_all, "requires an active receiving domain") unless receiving_domain&.state == "active"
117
+ if self.class.where(receiving_domain_id: receiving_domain_id, catch_all: true, state: "active").where.not(id: id).exists?
118
+ errors.add(:catch_all, "already exists for this receiving domain")
119
+ end
120
+ end
121
+ end
122
+
123
+ class Message < MailboxKit::ActiveRecord::Base
124
+ include TenantIdentity
125
+ self.table_name = "cloudflare_email_mailbox_messages"
126
+ belongs_to :mailbox, class_name: "MailboxKit::Mailboxes::Mailbox"
127
+ attr_readonly :mailbox_id, :inbound_email_id, :recipient
128
+ validates :mailbox, :inbound_email_id, :recipient, presence: true
129
+ # The unique database index also supports create_or_find_by! retries.
130
+ validate { validate_parent_tenant(mailbox, :mailbox) }
131
+ scope :unread, -> { where(read_at: nil) }
132
+ scope :inbox, -> { where(archived_at: nil) }
133
+
134
+ def mark_read!
135
+ update!(read_at: Time.current)
136
+ end
137
+
138
+ def mark_unread!
139
+ update!(read_at: nil)
140
+ end
141
+
142
+ def archive!
143
+ update!(archived_at: Time.current)
144
+ end
145
+
146
+ def unarchive!
147
+ update!(archived_at: nil)
148
+ end
149
+ end
150
+
151
+ end
152
+ end
@@ -0,0 +1,312 @@
1
+ require "mailbox_kit/address_syntax"
2
+
3
+ module MailboxKit
4
+ module Mailboxes
5
+ # A routing snapshot, not a model or an authorization token. Use it inside
6
+ # the yielded tenant context; later jobs must resolve/check policy again.
7
+ Destination = Struct.new(:tenant_key, :mailbox_id, :address_id,
8
+ :receiving_domain_id, :recipient, :owner_ref, :catch_all, keyword_init: true) do
9
+ def initialize(**attributes)
10
+ super(**{ catch_all: false }.merge(attributes).transform_values { |value| value.is_a?(String) ? value.dup.freeze : value })
11
+ freeze
12
+ end
13
+ end
14
+
15
+ class << self
16
+ # Call after host authorization. The directory is a control-plane API,
17
+ # not a public endpoint accepting arbitrary customer domain claims.
18
+ def register_domain(domain:, tenant_key:, account_id: nil)
19
+ Tenancy.normalize_key(tenant_key)
20
+ ReceivingDomain.create!(domain: domain, tenant_key: tenant_key, account_id: account_id)
21
+ end
22
+
23
+ def activate_domain!(id, evidence:, sending_enabled: false)
24
+ raise ArgumentError, "verification evidence is required" if evidence.to_s.strip.empty?
25
+ domain = ReceivingDomain.find(id)
26
+ domain.update!(state: "active", provisioning_evidence: evidence,
27
+ verified_at: Time.now.utc, sending_enabled: sending_enabled)
28
+ domain
29
+ end
30
+
31
+ def for_tenant(key)
32
+ raise ArgumentError, "a block is required" unless block_given?
33
+ Tenancy.with(key) { yield Session.new(key) }
34
+ end
35
+
36
+ # Only call after authenticating ingress and its envelope. Resolve before
37
+ # ActionMailbox or ActiveStorage accesses a tenant connection.
38
+ def receive(recipient:, source: nil, &block)
39
+ raise ArgumentError, "provide source or a persistence block, not both" if source && block
40
+ raise ArgumentError, "source or persistence block required" unless source || block
41
+ in_recipient_tenant(recipient) do |session, address, directory|
42
+ session.receive(address, directory) do |destination|
43
+ if block
44
+ # Legacy custom persistence blocks must return the existing Rails
45
+ # record on duplicates when membership attachment is desired.
46
+ block.lambda? && block.arity.zero? ? block.call : block.call(destination)
47
+ else
48
+ require "mailbox_kit/inbound_email"
49
+ InboundEmail.persist(source: source).record
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ # Lookup only: the host owns persistence and the block's return value.
56
+ # Verify ingress before calling this API. No ActionMailbox is required.
57
+ def with_recipient(recipient:)
58
+ raise ArgumentError, "a block is required" unless block_given?
59
+ in_recipient_tenant(recipient) do |session, address, directory|
60
+ session.with_recipient(address, directory) { |destination| yield destination }
61
+ end
62
+ end
63
+
64
+ def canonical_address(value)
65
+ unless AddressSyntax.valid_address?(value)
66
+ raise ValidationError, "mailbox address must be an ASCII dot-atom address"
67
+ end
68
+ value.downcase
69
+ end
70
+
71
+ private
72
+
73
+ def in_recipient_tenant(recipient)
74
+ address = canonical_address(recipient)
75
+ directory = ReceivingDomain.find_by(domain: address.split("@", 2).last, state: "active")
76
+ raise Unavailable, "receiving domain unavailable" unless directory
77
+ for_tenant(directory.tenant_key) do |session|
78
+ yield session, address, directory
79
+ end
80
+ end
81
+ end
82
+
83
+ class Session
84
+ attr_reader :tenant_key
85
+
86
+ def initialize(tenant_key)
87
+ @tenant_key = tenant_key
88
+ end
89
+
90
+ def mailboxes
91
+ context!
92
+ Mailbox.where(tenant_key: tenant_key)
93
+ end
94
+
95
+ def create(name:, address:, owner_ref: nil)
96
+ context!
97
+ Mailbox.transaction do
98
+ mailbox = Mailbox.create!(tenant_key: tenant_key, name: name, owner_ref: owner_ref)
99
+ add_address(mailbox.id, address: address)
100
+ mailbox
101
+ end
102
+ end
103
+
104
+ def add_address(mailbox_id, address:)
105
+ mailbox = active_mailbox!(mailbox_id)
106
+ canonical = Mailboxes.canonical_address(address)
107
+ local, domain = canonical.split("@", 2)
108
+ directory = ReceivingDomain.find_by!(domain: domain, tenant_key: tenant_key, state: "active")
109
+ Address.create!(tenant_key: tenant_key, mailbox_id: mailbox.id,
110
+ receiving_domain_id: directory.id, local_part: local, domain: domain,
111
+ address: canonical, state: "pending")
112
+ end
113
+
114
+ def activate_address!(address_id, evidence:)
115
+ context!
116
+ raise ArgumentError, "route verification evidence is required" if evidence.to_s.strip.empty?
117
+ address = Address.where(tenant_key: tenant_key).find(address_id)
118
+ active_mailbox!(address.mailbox_id)
119
+ active_domain!(address)
120
+ address.update!(state: "active", provisioning_evidence: evidence)
121
+ address
122
+ end
123
+
124
+ # This records the host's verified provider routing decision; it does
125
+ # not provision provider DNS/rules or authorize arbitrary From values.
126
+ def enable_catch_all(address_id, evidence:)
127
+ context!
128
+ catch_all_schema!
129
+ raise ArgumentError, "catch-all route verification evidence is required" if evidence.to_s.strip.empty?
130
+ Address.transaction do
131
+ address = Address.where(tenant_key: tenant_key, state: "active").find(address_id)
132
+ active_mailbox!(address.mailbox_id)
133
+ active_domain!(address)
134
+ address.update!(catch_all: true, catch_all_evidence: evidence)
135
+ address
136
+ end
137
+ end
138
+
139
+ def disable_catch_all(address_id)
140
+ context!
141
+ catch_all_schema!
142
+ # Disabling is permitted even after suspension; retain the previous
143
+ # verification evidence for operator inspection.
144
+ Address.where(tenant_key: tenant_key).find(address_id).tap { |address| address.update!(catch_all: false) }
145
+ end
146
+
147
+ def suspend(mailbox_id)
148
+ mailboxes.find(mailbox_id).tap { |mailbox| mailbox.update!(state: "suspended") }
149
+ end
150
+
151
+ def resume(mailbox_id)
152
+ mailboxes.find(mailbox_id).tap { |mailbox| mailbox.update!(state: "active") }
153
+ end
154
+
155
+ def messages(mailbox_id)
156
+ mailbox = mailboxes.find(mailbox_id)
157
+ Message.where(tenant_key: tenant_key, mailbox_id: mailbox.id)
158
+ end
159
+
160
+ def addresses(mailbox_id)
161
+ mailbox = mailboxes.find(mailbox_id)
162
+ Address.where(tenant_key: tenant_key, mailbox_id: mailbox.id)
163
+ end
164
+
165
+ def suspend_address(mailbox_id, address_id)
166
+ addresses(mailbox_id).find(address_id).tap { |address| address.update!(state: "suspended") }
167
+ end
168
+
169
+ def inbound_email(mailbox_id, message_id)
170
+ message = messages(mailbox_id).find(message_id)
171
+ ensure_storage_connection!
172
+ ::ActionMailbox::InboundEmail.find(message.inbound_email_id)
173
+ end
174
+
175
+ # For an existing Rails ingress or ApplicationMailbox handler. The caller
176
+ # has already selected this trusted tenant and authorized the inbound ID.
177
+ # Never accept an unscoped ID from a customer or switch tenants around a
178
+ # model object loaded from another database.
179
+ def attach(recipient:, inbound_email_id:)
180
+ context!
181
+ ensure_storage_connection!
182
+ address = Mailboxes.canonical_address(recipient)
183
+ directory = ReceivingDomain.find_by!(domain: address.split("@", 2).last,
184
+ tenant_key: tenant_key, state: "active")
185
+ destination = resolve_destination(address, directory)
186
+ attach_destination(destination, inbound_email_id)
187
+ end
188
+
189
+ # Explicit permanent removal. Archive is the reversible default.
190
+ # Other mailbox memberships retain their shared raw source.
191
+ def purge_message(mailbox_id, message_id)
192
+ context!
193
+ ensure_storage_connection!
194
+ Message.transaction do
195
+ message = messages(mailbox_id).find(message_id)
196
+ inbound_id = message.inbound_email_id
197
+ inbound = ::ActionMailbox::InboundEmail.find_by(id: inbound_id)
198
+ if inbound
199
+ inbound.with_lock do
200
+ message.destroy!
201
+ inbound.destroy! unless Message.where(inbound_email_id: inbound_id).exists?
202
+ end
203
+ else
204
+ message.destroy!
205
+ end
206
+ end
207
+ end
208
+
209
+ def mark_read(mailbox_id, message_id, read: true)
210
+ messages(mailbox_id).find(message_id).update!(read_at: read ? Time.now.utc : nil)
211
+ end
212
+
213
+ def archive(mailbox_id, message_id, archived: true)
214
+ messages(mailbox_id).find(message_id).update!(archived_at: archived ? Time.now.utc : nil)
215
+ end
216
+
217
+ def receive(address, directory)
218
+ context!
219
+ ensure_storage_connection! if defined?(::ActionMailbox::InboundEmail)
220
+ Mailbox.transaction do
221
+ destination = resolve_destination(address, directory)
222
+ inbound = yield destination
223
+ if inbound
224
+ if defined?(::ActionMailbox::InboundEmail)
225
+ attach_destination(destination, inbound.id)
226
+ else
227
+ # Compatibility for host-owned storage. New integrations should
228
+ # use with_recipient for custom stores, or attach for Rails mail.
229
+ record_membership(destination, inbound.id)
230
+ end
231
+ end
232
+ inbound
233
+ end
234
+ end
235
+
236
+ def with_recipient(address, directory)
237
+ context!
238
+ yield resolve_destination(address, directory)
239
+ end
240
+
241
+ private
242
+
243
+ def attach_destination(destination, inbound_email_id)
244
+ inbound = ::ActionMailbox::InboundEmail.find(inbound_email_id)
245
+ inbound.with_lock do
246
+ if Message.where(inbound_email_id: inbound.id).where.not(tenant_key: tenant_key).exists?
247
+ raise ConfigurationError, "inbound email already belongs to another tenant"
248
+ end
249
+ record_membership(destination, inbound.id)
250
+ end
251
+ end
252
+
253
+ def record_membership(destination, inbound_email_id)
254
+ Message.create_or_find_by!(tenant_key: tenant_key, mailbox_id: destination.mailbox_id,
255
+ inbound_email_id: inbound_email_id) { |row| row.recipient = destination.recipient }
256
+ end
257
+
258
+ def resolve_destination(address, directory)
259
+ destination = Address.where(tenant_key: tenant_key, address: address,
260
+ receiving_domain_id: directory.id).first
261
+ fallback = destination.nil?
262
+ if fallback && catch_all_schema_available?
263
+ destination = Address.where(tenant_key: tenant_key, receiving_domain_id: directory.id,
264
+ catch_all: true, state: "active").first
265
+ end
266
+ # An exact pending/suspended address intentionally reserves its name.
267
+ # It must never fall through to another mailbox's catch-all.
268
+ raise Unavailable, "mailbox address unavailable" unless destination&.state == "active"
269
+ mailbox = active_mailbox!(destination.mailbox_id)
270
+ active_domain!(destination)
271
+ Destination.new(tenant_key: tenant_key, mailbox_id: mailbox.id,
272
+ address_id: destination.id, receiving_domain_id: directory.id,
273
+ recipient: address, owner_ref: mailbox.owner_ref, catch_all: fallback)
274
+ rescue ::ActiveRecord::RecordNotFound
275
+ raise Unavailable, "mailbox address unavailable"
276
+ end
277
+
278
+ def context!
279
+ raise ConfigurationError, "mailbox session used outside its tenant context" unless Tenancy.require_context! == tenant_key
280
+ end
281
+
282
+ def catch_all_schema_available?
283
+ Address.connection.column_exists?(Address.table_name, :catch_all) &&
284
+ Address.connection.column_exists?(Address.table_name, :catch_all_evidence)
285
+ end
286
+
287
+ def catch_all_schema!
288
+ unless catch_all_schema_available?
289
+ raise ConfigurationError, "run mailbox_kit:install migrations for this tenant first"
290
+ end
291
+ end
292
+
293
+ def ensure_storage_connection!
294
+ unless defined?(::ActionMailbox::InboundEmail) && ::ActionMailbox::InboundEmail.connection_pool == Mailbox.connection_pool &&
295
+ ::ActiveStorage::Blob.connection_pool == Mailbox.connection_pool
296
+ raise ConfigurationError, "ActionMailbox, ActiveStorage and mailbox records must share the tenant connection"
297
+ end
298
+ end
299
+
300
+ def active_mailbox!(id)
301
+ context!
302
+ mailboxes.where(state: "active").find(id)
303
+ end
304
+
305
+ def active_domain!(address)
306
+ ReceivingDomain.find_by!(id: address.receiving_domain_id, domain: address.domain,
307
+ tenant_key: tenant_key, state: "active")
308
+ end
309
+
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,9 @@
1
+ # Explicit Active Record opt-in. Configure tenancy before requiring this file.
2
+ require "mailbox-kit"
3
+ require "mailbox_kit/tenancy"
4
+ require "mailbox_kit/mailboxes/configuration"
5
+ require "mailbox_kit/mailboxes/models"
6
+ require "mailbox_kit/mailboxes/service"
7
+ MailboxKit::Mailboxes.enable!
8
+
9
+ require "mailbox_kit/railtie" if defined?(::Rails::Railtie)
@@ -0,0 +1,29 @@
1
+ module MailboxKit
2
+ module Management
3
+ # A host supplies authentication, mailbox ownership and domain entitlement.
4
+ # Defaults deliberately grant no access. No principal comes from params.
5
+ class Adapter
6
+ attr_reader :controller
7
+
8
+ def initialize(controller = nil)
9
+ @controller = controller
10
+ end
11
+
12
+ def authenticate! = false
13
+ def tenant_key = nil
14
+ def mailboxes(session) = session.mailboxes.none
15
+ def allowed?(action, mailbox = nil) = false
16
+ def domains(session) = []
17
+
18
+ # Override these two hooks when the host keeps a linked product model or
19
+ # uses a deployment policy to activate/provision verified addresses.
20
+ def create_mailbox(session, name:, address:)
21
+ session.create(name: name, address: address)
22
+ end
23
+
24
+ def add_address(session, mailbox, address:)
25
+ session.add_address(mailbox.id, address: address)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,15 @@
1
+ module MailboxKit
2
+ module Management
3
+ class Configuration
4
+ attr_accessor :adapter, :back_path
5
+ end
6
+
7
+ def self.configuration
8
+ @configuration ||= Configuration.new
9
+ end
10
+
11
+ def self.configure
12
+ yield configuration
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ require "rails/engine"
2
+ require "action_controller/railtie"
3
+
4
+ module MailboxKit
5
+ module Management
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace MailboxKit::Management
8
+ config.root = File.expand_path("../../..", __dir__)
9
+
10
+ config.paths["config/routes.rb"] = "lib/mailbox_kit/management/routes.rb"
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,68 @@
1
+ :root { color-scheme: light; --ink: #172c35; --muted: #536871; --line: #dce4e6; --accent: #12655c; --surface: #fff; }
2
+ * { box-sizing: border-box; }
3
+ body { margin: 0; background: #f5f7f7; color: var(--ink); font: 16px/1.55 system-ui, sans-serif; }
4
+ a { color: var(--accent); text-underline-offset: 3px; }
5
+ a:hover { text-decoration-thickness: 2px; }
6
+ :focus-visible { outline: 3px solid #b66a17; outline-offset: 4px; }
7
+ .shell { width: min(1160px, 100% - 48px); margin: 0 auto; }
8
+ .site-header { background: var(--surface); border-bottom: 1px solid var(--line); }
9
+ .header-content { display: flex; justify-content: space-between; align-items: center; gap: 16px; min-height: 76px; }
10
+ .brand { color: var(--ink); font-weight: 750; font-size: 18px; text-decoration: none; }
11
+ .header-caption, .muted, .field-help { color: var(--muted); }
12
+ main.shell { padding-top: 32px; padding-bottom: 64px; }
13
+ h1, h2, h3, p { margin: 0; }
14
+ h1 { font-size: clamp(26px, 4vw, 36px); line-height: 1.2; letter-spacing: -.035em; overflow-wrap: anywhere; }
15
+ h2 { font-size: 17px; line-height: 1.4; }
16
+ h3 { font-size: 17px; }
17
+ .small, .field-help { font-size: 13px; }
18
+ .eyebrow { color: var(--accent); font-size: 12px; font-weight: 750; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 8px; }
19
+ .page-heading { display: flex; justify-content: space-between; align-items: center; gap: 24px; margin-bottom: 28px; }
20
+ .page-heading .muted { margin-top: 12px; max-width: 640px; }
21
+ .workspace-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(280px, 1fr); gap: 24px; align-items: start; }
22
+ .panel { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; min-width: 0; }
23
+ .panel-heading { padding: 20px 24px; border-bottom: 1px solid var(--line); }
24
+ .panel-body { padding: 24px; }
25
+ .stack { display: flex; flex-direction: column; gap: 20px; }
26
+ .field { display: flex; flex-direction: column; gap: 8px; }
27
+ label { font-weight: 650; font-size: 14px; }
28
+ input[type="text"], input[type="email"] { width: 100%; border: 1px solid #a6b7be; border-radius: 7px; padding: 11px 12px; background: #fff; color: var(--ink); font: inherit; }
29
+ .button { display: inline-flex; align-items: center; justify-content: center; min-height: 42px; padding: 9px 15px; border: 1px solid var(--accent); border-radius: 7px; background: var(--accent); color: #fff; font: inherit; font-size: 14px; font-weight: 650; text-decoration: none; cursor: pointer; text-align: center; }
30
+ .button:hover { filter: brightness(.93); }
31
+ .button-secondary { background: #fff; color: var(--ink); border-color: #a6b7be; }
32
+ .record-list { list-style: none; margin: 0; padding: 0; }
33
+ .record-row { padding: 20px 24px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
34
+ .record-row + .record-row { border-top: 1px solid var(--line); }
35
+ .record-content { min-width: 0; }
36
+ .record-title { font-weight: 650; overflow-wrap: anywhere; }
37
+ .record-content p { margin-top: 4px; }
38
+ .badge { display: inline-block; border: 1px solid var(--line); border-radius: 5px; background: #f3f6f6; color: #364f58; padding: 3px 8px; font-size: 12px; font-weight: 650; white-space: nowrap; }
39
+ .status-list { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
40
+ .address-row { align-items: flex-start; flex-direction: column; gap: 8px; }
41
+ .empty-state { padding: 48px 24px; text-align: center; }
42
+ .empty-state p { color: var(--muted); max-width: 340px; margin: 8px auto 0; }
43
+ .pagination { padding: 16px 24px; border-top: 1px solid var(--line); }
44
+ .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
45
+ .actions form { margin: 0; }
46
+ .breadcrumbs { display: flex; flex-wrap: wrap; gap: 10px; color: var(--muted); font-size: 14px; margin-bottom: 28px; overflow-wrap: anywhere; }
47
+ .notice { border: 1px solid #a7ccc0; border-radius: 8px; background: #edf8f2; color: #205340; padding: 14px 18px; margin-bottom: 24px; overflow-wrap: anywhere; }
48
+ .notice-error { border-color: #e8b9b3; background: #fff2ef; color: #8d2a21; }
49
+ .message-metadata { display: grid; grid-template-columns: 84px minmax(0, 1fr); gap: 10px 20px; margin: 0; font-size: 14px; }
50
+ .message-metadata dt { color: var(--muted); }
51
+ .message-metadata dd { margin: 0; overflow-wrap: anywhere; }
52
+ .message-body { border-top: 1px solid var(--line); padding: 28px 24px; }
53
+ .message-body pre { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; margin: 0; }
54
+ .attachments { border-top: 1px solid var(--line); }
55
+ .attachments p { margin-top: 8px; }
56
+ .attachments li { overflow-wrap: anywhere; }
57
+ .skip-link { position: absolute; left: 16px; top: -100px; background: #fff; padding: 12px; z-index: 1; }
58
+ .skip-link:focus { top: 12px; }
59
+ @media (max-width: 760px) {
60
+ .shell { width: calc(100% - 32px); }
61
+ .header-content { min-height: 64px; }
62
+ .header-caption { display: none; }
63
+ .workspace-grid { grid-template-columns: minmax(0, 1fr); }
64
+ .page-heading { align-items: flex-start; flex-direction: column; }
65
+ .panel-heading, .panel-body, .record-row, .message-body { padding: 20px; }
66
+ .record-row { align-items: flex-start; }
67
+ .message-metadata { grid-template-columns: 64px minmax(0, 1fr); gap: 10px; }
68
+ }
@@ -0,0 +1,14 @@
1
+ MailboxKit::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,6 @@
1
+ require "rails/engine"
2
+ require "mailbox-kit"
3
+ require "mailbox_kit/railtie"
4
+ require "mailbox_kit/management/adapter"
5
+ require "mailbox_kit/management/configuration"
6
+ require "mailbox_kit/management/engine"
@@ -0,0 +1,21 @@
1
+ require "rails/railtie"
2
+ module MailboxKit
3
+ class Railtie < ::Rails::Railtie
4
+ # Rails runs this public hook again when the model is reloaded. Loading the
5
+ # kit does not itself load ActionMailbox or replace its routing lifecycle.
6
+ initializer "mailbox-kit.inbound_retention" do
7
+ ActiveSupport.on_load(:action_mailbox_inbound_email) do
8
+ require "mailbox_kit/mailboxes/inbound_retention"
9
+ prepend MailboxKit::Mailboxes::InboundRetention unless
10
+ ancestors.include?(MailboxKit::Mailboxes::InboundRetention)
11
+ end
12
+ end
13
+
14
+ config.to_prepare do
15
+ if (defined?(MailboxKit::Tenancy) && MailboxKit::Tenancy.enabled?) || MailboxKit::Mailboxes.respond_to?(:enabled?) && MailboxKit::Mailboxes.enabled?
16
+ require "mailbox_kit/tenant_job_context"
17
+ MailboxKit::TenantJobContext.install_framework_jobs!
18
+ end
19
+ end
20
+ end
21
+ end