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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +104 -0
- data/app/controllers/mailbox_kit/management/mailboxes_controller.rb +171 -0
- data/app/controllers/mailbox_kit/management/styles_controller.rb +11 -0
- data/app/views/layouts/mailbox_kit/management.html.erb +29 -0
- data/app/views/mailbox_kit/management/mailboxes/index.html.erb +65 -0
- data/app/views/mailbox_kit/management/mailboxes/message.html.erb +34 -0
- data/app/views/mailbox_kit/management/mailboxes/show.html.erb +86 -0
- data/docs/integration.md +283 -0
- data/docs/upgrading.md +60 -0
- data/lib/generators/mailbox_kit/install/install_generator.rb +24 -0
- data/lib/generators/mailbox_kit/install/templates/create_mailbox_kit_mailboxes.rb +51 -0
- data/lib/generators/mailbox_kit/install/templates/create_mailbox_kit_receiving_domains.rb +16 -0
- data/lib/generators/mailbox_kit/upgrade/templates/allow_provider_neutral_receiving_domains.rb +9 -0
- data/lib/generators/mailbox_kit/upgrade/templates/index_mailbox_kit_inbound_messages.rb +12 -0
- data/lib/generators/mailbox_kit/upgrade/upgrade_generator.rb +23 -0
- data/lib/mailbox-kit.rb +11 -0
- data/lib/mailbox_kit/active_record/base.rb +58 -0
- data/lib/mailbox_kit/address_syntax.rb +19 -0
- data/lib/mailbox_kit/error.rb +14 -0
- data/lib/mailbox_kit/inbound_email.rb +56 -0
- data/lib/mailbox_kit/mailboxes/configuration.rb +19 -0
- data/lib/mailbox_kit/mailboxes/inbound_retention.rb +16 -0
- data/lib/mailbox_kit/mailboxes/models.rb +152 -0
- data/lib/mailbox_kit/mailboxes/service.rb +312 -0
- data/lib/mailbox_kit/mailboxes.rb +9 -0
- data/lib/mailbox_kit/management/adapter.rb +29 -0
- data/lib/mailbox_kit/management/configuration.rb +15 -0
- data/lib/mailbox_kit/management/engine.rb +13 -0
- data/lib/mailbox_kit/management/management.css +68 -0
- data/lib/mailbox_kit/management/routes.rb +14 -0
- data/lib/mailbox_kit/management.rb +6 -0
- data/lib/mailbox_kit/railtie.rb +21 -0
- data/lib/mailbox_kit/tenancy.rb +76 -0
- data/lib/mailbox_kit/tenant_job_context.rb +88 -0
- data/lib/mailbox_kit/version.rb +3 -0
- 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,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,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
|