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,283 @@
1
+ # Integration guide
2
+
3
+ Persistent inboxes on top of Action Mailbox, independent of your email provider. Rails
4
+ already stores and parses email, routes it to processing handlers, and provides
5
+ configurable retention. Mailbox Kit adds inbox identities, addresses/aliases,
6
+ membership, read/archive state, scoped access and a server-rendered management UI.
7
+ SQLite works; separate tenant databases are opt-in.
8
+
9
+ This package is maintained alongside `cloudflare-email` so changes to the core and
10
+ its first integration can be tested together. It is not yet published. In this
11
+ checkout use `gem "mailbox-kit", path: "mailbox-kit"`. Once released, applications
12
+ can use `gem "mailbox-kit", "~> 0.1"`.
13
+
14
+ ## What belongs where?
15
+
16
+ | Layer | Responsibility |
17
+ | --- | --- |
18
+ | Mailbox Kit | Inbox identities, addresses, recipient lookup, membership, read/archive/purge, selective retention, tenant context, management UI |
19
+ | Rails | InboundEmail records, original MIME/ActiveStorage, parsing, processing callbacks/status, routing jobs, configurable retention, and ActionMailer |
20
+ | Provider integration | Verify incoming requests and envelope recipients, deliver outgoing messages, authenticate delivery feedback, configure DNS/routes |
21
+ | Your application | Users, organizations, sites, permissions, sender acceptance and business workflows |
22
+
23
+ Mailbox Kit does not require Cloudflare, configure DNS, or send messages by itself.
24
+ `cloudflare-email` supplies its existing Worker ingress, sending/outbox, feedback,
25
+ and provisioning integration. Other providers can call the core receiving APIs;
26
+ this release does not claim complete SES, Postmark, or generic outbound adapters.
27
+ The tests exercise Rails' stock Postmark HTTP ingress followed by explicit kit
28
+ attachment in one database; provider setup, dynamic envelope authorization and
29
+ provider outage behavior are separate integration responsibilities.
30
+ Inbound and outbound need not use the same service. A mailbox has no `provider`
31
+ attribute: receiving through one provider does not authorize sending through it.
32
+
33
+ ## Create an inbox
34
+
35
+ Use Rails 7.2–8.1 with Active Record. Install ActionMailbox when you want its raw
36
+ message storage and the message-reading UI:
37
+
38
+ ```sh
39
+ bin/rails action_mailbox:install
40
+ bin/rails generate mailbox_kit:install
41
+ bin/rails db:migrate
42
+ ```
43
+
44
+ The generator adds an initializer requiring `mailbox_kit/mailboxes`. Register a
45
+ domain from trusted setup code after configuring your receiving provider:
46
+
47
+ ```ruby
48
+ boxes = MailboxKit::Mailboxes
49
+ domain = boxes.register_domain(domain: "in.example.com", tenant_key: "workspace")
50
+ boxes.activate_domain!(domain.id, evidence: "Receiving route verified by an end-to-end test")
51
+
52
+ boxes.for_tenant("workspace") do |session|
53
+ inbox = session.create(
54
+ name: "Support", address: "support@in.example.com", owner_ref: "team:stable-uuid"
55
+ )
56
+ session.activate_address!(inbox.addresses.first.id, evidence: "Provider route verified")
57
+ # Aliases belong to the same inbox; each starts pending until activated.
58
+ session.add_address(inbox.id, address: "help@in.example.com")
59
+ end
60
+ ```
61
+
62
+ `workspace` is an explicit scope in a single database; it does not turn on database
63
+ tenancy. Domain/address activation records evidence supplied by your trusted
64
+ application. It does not verify DNS or grant permission to send from that address.
65
+
66
+ `owner_ref` is currently an application-managed reference, not a foreign key or an
67
+ authorization grant. Use stable identities, rather than reusable numeric IDs.
68
+ Automatic `has_mailbox` owner bindings and deletion reconciliation are a separate
69
+ follow-up; this extraction does not introduce a callback that could silently
70
+ reassign an old address to a new owner.
71
+
72
+ ## Reuse Action Mailbox
73
+
74
+ For a receive-and-process application, Action Mailbox alone may be enough. It
75
+ already supports dynamic handlers through regex/callable routes, processing
76
+ callbacks, test helpers, and the development conductor at
77
+ `/rails/conductor/action_mailbox/inbound_emails`. The kit does not replace those.
78
+ Rails' `delivered` status means an inbound handler finished processing; read and
79
+ archive state belong to the kit's inbox membership instead.
80
+
81
+ If an existing Rails ingress has already stored an email, attach that record:
82
+
83
+ ```ruby
84
+ MailboxKit::Mailboxes.for_tenant(trusted_tenant_key) do |session|
85
+ membership = session.attach(
86
+ recipient: verified_envelope_recipient,
87
+ inbound_email_id: inbound_email.id
88
+ )
89
+ end
90
+ ```
91
+
92
+ The caller must authorize the inbound ID and select its tenant before loading
93
+ records. Do not pass an ID from another tenant or accept an unscoped ID from a
94
+ customer. Attachment checks active address ownership, looks up the Rails record
95
+ in the current connection, and rejects existing membership in another tenant.
96
+ It is idempotent for the inbox/email pair and does not enqueue processing again.
97
+ Aliases into the same inbox share a membership, retaining the first recipient.
98
+
99
+ Use Rails' normal `ApplicationMailbox` routing and processing callbacks. One
100
+ handler can dynamically resolve many organizations/sites; no class per inbox is
101
+ needed. Default Rails routes match message headers and choose the first handler;
102
+ they do not establish trusted tenant entitlement or automatically fan out into
103
+ every recipient's inbox. Select a tenant before initial storage if raw mail lives
104
+ in separate tenant databases. Attaching later cannot relocate that original row.
105
+
106
+ ## Receive source through a verified integration
107
+
108
+ Your ingress adapter must authenticate the request and extract the actual SMTP
109
+ envelope recipient before invoking the core. Do not route on an untrusted MIME
110
+ `To` header or a customer-supplied tenant key.
111
+
112
+ ```ruby
113
+ inbound_email = MailboxKit::Mailboxes.receive(
114
+ recipient: verified_envelope_recipient,
115
+ source: raw_mime
116
+ )
117
+ ```
118
+
119
+ The core resolves an active domain and address, selects the tenant, and records
120
+ the mailbox membership in the same database transaction as Rails persistence.
121
+ It calls Rails' creation API and returns the existing record on duplicate source.
122
+ Identical source within one tenant can belong to multiple inboxes without creating
123
+ another raw email or routing job. Default source identity includes the tenant
124
+ scope and uses a stable fallback for missing Message-ID; identical bytes in a
125
+ different tenant do not suppress that tenant's processing. MIME is not rewritten.
126
+ The adapter remains responsible for authentication, authoritative envelope
127
+ recipients, delivery-specific identity where necessary, and retrying failed
128
+ requests. ActionMailbox still owns processing and its `ApplicationMailbox` routes.
129
+
130
+ For application checks before persistence, the existing block form remains:
131
+
132
+ ```ruby
133
+ require "mailbox_kit/inbound_email"
134
+ MailboxKit::Mailboxes.receive(recipient: verified_envelope_recipient) do |destination|
135
+ MyAcceptancePolicy.check!(destination) # Application policy; raise to reject.
136
+ MailboxKit::InboundEmail.persist(source: raw_mime).record
137
+ end
138
+ ```
139
+
140
+ Blocks must return the existing Rails record on duplicates if membership should
141
+ be attached. Rails' bare `create_and_extract_message_id!` returns `nil` for a
142
+ duplicate, so using it directly in that block can omit a second inbox membership.
143
+ Returning `nil` intentionally skips membership, preserving the older block API.
144
+ Neither a database transaction nor source deduplication guarantees exactly-once
145
+ business effects, external blob cleanup on rollback, or recovery of lost jobs.
146
+
147
+ With Cloudflare, the adapter preserves its existing authenticated envelope and
148
+ metadata-based delivery identity, then uses the same Rails persistence bridge:
149
+
150
+ ```ruby
151
+ verified = Cloudflare::Email::Ingress.verify(
152
+ secret: ingress_secret, headers: request.headers, body: request.body
153
+ )
154
+ # Handle non-:ok verification results before accessing the verified message.
155
+ verified.message.receive_into_mailbox! if verified.status == :ok
156
+ ```
157
+
158
+ The shipped Cloudflare ingress controller already does this, including HTTP error
159
+ handling and size limits. Installing the core does not add another HTTP endpoint
160
+ or SMTP server. `persist_action_mailbox!` remains available for applications that
161
+ only need Rails storage; its existing return convention is new record or `nil`
162
+ on duplicate. The new inbox bridge can repair membership on duplicate delivery
163
+ without rerouting the email or replacing stored authentication metadata.
164
+
165
+ For an application with its own persistence, use
166
+ `with_recipient(recipient:) { |destination| ... }`; it performs lookup and tenant
167
+ selection without creating a message membership. It can run without ActionMailbox.
168
+
169
+ An explicit pending/suspended address reserves its name: it never falls through
170
+ to another inbox's catch-all. Unknown addresses are unavailable unless you
171
+ explicitly enable a verified catch-all address with `session.enable_catch_all`.
172
+
173
+ ```ruby
174
+ MailboxKit::Mailboxes.for_tenant("workspace") do |session|
175
+ messages = session.messages(inbox_id).inbox.unread
176
+ session.mark_read(inbox_id, message_id)
177
+ session.archive(inbox_id, message_id)
178
+ session.suspend(inbox_id) # Reversible; reserves existing addresses.
179
+ end
180
+ ```
181
+
182
+ ## Retention is a Rails policy
183
+
184
+ To retain all inbound mail, Rails already provides:
185
+
186
+ ```ruby
187
+ config.action_mailbox.incinerate = false
188
+ ```
189
+
190
+ Or configure its automatic cleanup interval with
191
+ `config.action_mailbox.incinerate_after = 90.days`. Disabling scheduling does not
192
+ cancel incineration jobs already queued. Rails normally schedules processed mail
193
+ for cleanup after 30 days; pending mail is not processed mail.
194
+
195
+ For apps that mix inboxes and transient email handlers, the kit adds a narrow
196
+ membership guard through Rails' `action_mailbox_inbound_email` load hook: mail
197
+ associated with an inbox survives normal incineration, while unassociated mail
198
+ uses Rails' policy. `purge_message` explicitly removes membership and deletes raw
199
+ mail only when no mailbox memberships remain. Attach, purge and this guard lock
200
+ the same inbound row. Direct application deletion and external storage expiration
201
+ remain the application's responsibility. ActionMailbox, ActiveStorage and mailbox
202
+ records must share a connection for the transactional membership operations.
203
+
204
+ ## Management interface
205
+
206
+ Require the optional engine in `config/application.rb`, before Rails initializes:
207
+
208
+ ```ruby
209
+ require "mailbox_kit/management"
210
+ ```
211
+
212
+ Mount it in `config/routes.rb`:
213
+
214
+ ```ruby
215
+ mount MailboxKit::Management::Engine => "/mailboxes"
216
+ ```
217
+
218
+ Configure `MailboxKit::Management.configure { |c| c.adapter = ->(controller) {
219
+ MailboxAccess.new(controller) } }` in an initializer. `MailboxAccess` subclasses
220
+ `MailboxKit::Management::Adapter` and supplies:
221
+
222
+ - `authenticate!`: authenticate using the host application's session.
223
+ - `tenant_key`: select a trusted scope for that principal.
224
+ - `mailboxes(session)`: return only mailboxes the principal can access.
225
+ - `allowed?(action, mailbox = nil)`: authorize each operation.
226
+ - `domains(session)`: return domains the principal may use.
227
+
228
+ All defaults deny access. The engine intersects your relation with its tenant
229
+ scope and checks permissions again for each action. Override `create_mailbox` and
230
+ `add_address` to attach your ownership policy. Ownership alone never grants access.
231
+ The UI needs no React, Inertia, or frontend build. It shows safe text previews;
232
+ remote images and arbitrary email HTML are not rendered.
233
+
234
+ ## Optional database tenancy
235
+
236
+ Before requiring mailbox models, configure your application's connection switch:
237
+
238
+ ```ruby
239
+ require "mailbox_kit/tenancy"
240
+ MailboxKit::Tenancy.configure(
241
+ base_class: TenantRecord,
242
+ switch: ->(key, &block) { TenantRecord.with_tenant(key, &block) },
243
+ current: -> { TenantRecord.current_tenant }
244
+ )
245
+ require "mailbox_kit/mailboxes/configuration"
246
+ MailboxKit::Mailboxes.configure(directory_base: SharedRecord)
247
+ require "mailbox_kit/mailboxes"
248
+ ```
249
+
250
+ Those host methods are examples; adapt them to your tenancy library. Run directory
251
+ migrations on the shared database and mailbox migrations on each tenant database
252
+ using the generator's `--directory-migrations-path` and `--tenant-migrations-path`.
253
+ Never choose the tenant from request parameters. Rails framework jobs preserve the
254
+ explicit context; your own tenant jobs can `prepend MailboxKit::TenantJobContext`
255
+ after requiring `mailbox_kit/tenant_job_context`.
256
+
257
+ ## Existing Cloudflare Email applications
258
+
259
+ Keep your existing requires, initializer, migrations, Worker and mounted engine.
260
+ Updating the Cloudflare gem brings in this core as a dependency. Public Cloudflare
261
+ constants resolve to the same core models; tables, IDs and tenant job payload keys
262
+ are retained. The `cloudflare_email_` table prefix is intentionally unchanged.
263
+ Do **not** run `mailbox_kit:install` over an existing Cloudflare mailbox schema.
264
+
265
+ For a fresh Cloudflare application, continue using the Cloudflare mailbox generator,
266
+ which also installs its outbox and event tables. A core-only installation should
267
+ not run both installers; adding Cloudflare outbound later needs its additional
268
+ tables and explicit sending-account/domain configuration. The receiving-only core
269
+ does not automatically become a Cloudflare sending account when the adapter loads.
270
+
271
+ ## Sending from customer subdomains
272
+
273
+ Cloudflare onboards each sending domain/subdomain separately. Parent-domain
274
+ verification and wildcard receiving do not authorize arbitrary From subdomains.
275
+ Use an explicitly verified sending domain and, where useful, a customer-specific
276
+ inbound Reply-To. See [Cloudflare's subdomain rules](https://developers.cloudflare.com/email-service/configuration/subdomains/).
277
+
278
+ ## Development and release
279
+
280
+ From the repository root, `bundle exec rake test` exercises both the compatibility
281
+ API and standalone core. `bundle exec ruby script/verify_package.rb` builds both
282
+ archives, installs a Rails-free consumer, and tests the packaged Rails integrations.
283
+ Publish `mailbox-kit` before releasing a Cloudflare version that depends on it.
data/docs/upgrading.md ADDED
@@ -0,0 +1,60 @@
1
+ # Upgrading existing mailbox installations
2
+
3
+ The extracted core keeps existing `cloudflare_email_` tables, record IDs, public
4
+ Cloudflare constants and tenant job payloads. Keep your initializer, mounted
5
+ engine, Worker and application processing code. Do not run `mailbox_kit:install`
6
+ or the Cloudflare mailbox installer over existing mailbox tables.
7
+
8
+ After updating the gems, generate the index and receiving-domain migrations:
9
+
10
+ ```sh
11
+ bin/rails generate mailbox_kit:upgrade
12
+ bin/rails db:migrate
13
+ ```
14
+
15
+ For separate tenant databases:
16
+
17
+ ```sh
18
+ bin/rails generate mailbox_kit:upgrade --tenant-migrations-path=db/tenant_migrate --directory-migrations-path=db/migrate
19
+ ```
20
+
21
+ Apply the index migration to every database containing mailbox messages using your
22
+ application's tenant migration runner. It does not belong in the shared domain
23
+ directory database. The migration preserves messages and skips an existing
24
+ single-column inbound index. New installers already include this index.
25
+
26
+ Apply `AllowProviderNeutralReceivingDomains` to the shared directory database.
27
+ It allows a null receiving-domain account ID, preserving existing account values.
28
+ Receiving-only registration requires no Cloudflare account; sending still requires
29
+ an explicit account and verified sending domain. On SQLite this schema change can
30
+ rebuild the directory table, so use your normal migration window.
31
+
32
+ Index creation can block writes on large tables. Plan its application according
33
+ to your database's normal migration procedure; PostgreSQL installations needing
34
+ online builds can adapt it to `algorithm: :concurrently` with
35
+ `disable_ddl_transaction!`.
36
+
37
+ ## Verify the application upgrade
38
+
39
+ 1. Receive a message through the existing authenticated provider integration.
40
+ 2. Confirm it appears in the right inbox and runs the existing business handler.
41
+ 3. Replay the delivery and confirm no duplicate business effects.
42
+ 4. Check authorized and unauthorized UI access, read/archive actions, and retention.
43
+ 5. For separate databases, exercise tenant routing and processing jobs in two tenants.
44
+
45
+ Keep your application's sender acceptance, ownership reconciliation and processing
46
+ claims. Rails processing status does not replace these business policies.
47
+
48
+ ## Adding Cloudflare to a core-only application
49
+
50
+ The core's mailbox schema alone does not supply Cloudflare's outbox and event
51
+ tables. Add those provider tables and sending configuration explicitly using the
52
+ Cloudflare integration guide; do not run both complete installers against the
53
+ same database. Arbitrary subdomain receiving does not authorize sending from those
54
+ subdomains: verify each Cloudflare sending domain separately.
55
+
56
+ ## Versions
57
+
58
+ Cloudflare Email 0.4.0 depends on Mailbox Kit 0.1.x and installs it automatically.
59
+ Core-only applications can install `gem "mailbox-kit", "~> 0.1.0"` directly.
60
+ Upgrade and verify your application's email flows before a wider rollout.
@@ -0,0 +1,24 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+ module MailboxKit
4
+ module Generators
5
+ class InstallGenerator < ::Rails::Generators::Base
6
+ include ::ActiveRecord::Generators::Migration
7
+ namespace "mailbox_kit:install"
8
+ source_root File.expand_path("templates", __dir__)
9
+ class_option :tenant_migrations_path, type: :string, default: "db/migrate"
10
+ class_option :directory_migrations_path, type: :string, default: "db/migrate"
11
+ def copy_migrations
12
+ migration_template "create_mailbox_kit_receiving_domains.rb", File.join(options[:directory_migrations_path], "create_mailbox_kit_receiving_domains.rb")
13
+ migration_template "create_mailbox_kit_mailboxes.rb", File.join(options[:tenant_migrations_path], "create_mailbox_kit_mailboxes.rb")
14
+ end
15
+ def create_initializer
16
+ create_file "config/initializers/mailbox_kit.rb", <<~RUBY
17
+ # Single database by default. Configure MailboxKit::Tenancy before
18
+ # requiring mailbox models when using separate tenant databases.
19
+ require "mailbox_kit/mailboxes"
20
+ RUBY
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,51 @@
1
+ class CreateMailboxKitMailboxes < ActiveRecord::Migration[7.1]
2
+ def up
3
+ create_table :cloudflare_email_mailboxes do |t|
4
+ t.string :tenant_key, null: false
5
+ t.string :name, null: false
6
+ t.string :owner_ref
7
+ t.string :state, null: false, default: "active"
8
+ t.timestamps
9
+ end
10
+ add_index :cloudflare_email_mailboxes, [:tenant_key, :owner_ref], name: "idx_cf_email_mailbox_owner"
11
+
12
+ create_table :cloudflare_email_addresses do |t|
13
+ t.string :tenant_key, null: false
14
+ t.references :mailbox, null: false, index: false, foreign_key: { to_table: :cloudflare_email_mailboxes }
15
+ # The domain directory can live in another database: no cross-database FK.
16
+ t.bigint :receiving_domain_id, null: false
17
+ t.string :local_part, null: false
18
+ t.string :domain, null: false
19
+ t.string :address, null: false
20
+ t.string :state, null: false, default: "pending"
21
+ t.text :provisioning_evidence
22
+ t.boolean :catch_all, null: false, default: false
23
+ t.text :catch_all_evidence
24
+ t.timestamps
25
+ end
26
+ add_index :cloudflare_email_addresses, :address, unique: true, name: "idx_cf_email_mailbox_address"
27
+ add_index :cloudflare_email_addresses, :mailbox_id, name: "idx_cf_email_address_mailbox"
28
+ add_index :cloudflare_email_addresses, :receiving_domain_id, unique: true,
29
+ where: "catch_all = TRUE AND state = 'active'", name: "idx_cf_email_domain_catch_all"
30
+
31
+ create_table :cloudflare_email_mailbox_messages do |t|
32
+ t.string :tenant_key, null: false
33
+ t.references :mailbox, null: false, index: false, foreign_key: { to_table: :cloudflare_email_mailboxes }
34
+ # No database FK to the optional Rails table; the ingress service requires
35
+ # the same connection and manages membership+raw source atomically.
36
+ t.bigint :inbound_email_id, null: false
37
+ t.string :recipient, null: false
38
+ t.datetime :read_at
39
+ t.datetime :archived_at
40
+ t.timestamps
41
+ end
42
+ add_index :cloudflare_email_mailbox_messages, [:mailbox_id, :inbound_email_id], unique: true, name: "idx_cf_email_mailbox_inbound"
43
+ add_index :cloudflare_email_mailbox_messages, :inbound_email_id, name: "idx_cf_email_message_inbound"
44
+ add_index :cloudflare_email_mailbox_messages, [:tenant_key, :mailbox_id, :archived_at, :id], name: "idx_cf_email_mailbox_inbox"
45
+
46
+ end
47
+
48
+ def down
49
+ raise ActiveRecord::IrreversibleMigration, "Preserve mailbox ownership and retained messages; use a forward fix"
50
+ end
51
+ end
@@ -0,0 +1,16 @@
1
+ class CreateMailboxKitReceivingDomains < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :cloudflare_email_receiving_domains do |t|
4
+ t.string :domain, null: false
5
+ t.string :tenant_key, null: false
6
+ t.string :account_id # Optional adapter compatibility; not a mailbox provider identity.
7
+ t.string :state, null: false, default: "pending"
8
+ t.boolean :sending_enabled, null: false, default: false
9
+ t.text :provisioning_evidence
10
+ t.datetime :verified_at
11
+ t.timestamps
12
+ end
13
+ add_index :cloudflare_email_receiving_domains, :domain, unique: true, name: "idx_cf_email_directory_domain"
14
+ add_index :cloudflare_email_receiving_domains, [:tenant_key, :state], name: "idx_cf_email_directory_tenant"
15
+ end
16
+ end
@@ -0,0 +1,9 @@
1
+ class AllowProviderNeutralReceivingDomains < ActiveRecord::Migration[7.1]
2
+ def up
3
+ change_column_null :cloudflare_email_receiving_domains, :account_id, true
4
+ end
5
+
6
+ def down
7
+ raise ActiveRecord::IrreversibleMigration, "Receiving-only domains need no provider account; use a forward fix"
8
+ end
9
+ end
@@ -0,0 +1,12 @@
1
+ class IndexMailboxKitInboundMessages < ActiveRecord::Migration[7.1]
2
+ def up
3
+ return if index_exists?(:cloudflare_email_mailbox_messages, :inbound_email_id)
4
+
5
+ add_index :cloudflare_email_mailbox_messages, :inbound_email_id,
6
+ name: "idx_cf_email_message_inbound"
7
+ end
8
+
9
+ def down
10
+ raise ActiveRecord::IrreversibleMigration, "Preserve the inbound lookup index; use a forward fix"
11
+ end
12
+ end
@@ -0,0 +1,23 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module MailboxKit
5
+ module Generators
6
+ class UpgradeGenerator < ::Rails::Generators::Base
7
+ include ::ActiveRecord::Generators::Migration
8
+ namespace "mailbox_kit:upgrade"
9
+ source_root File.expand_path("templates", __dir__)
10
+ class_option :tenant_migrations_path, type: :string, default: "db/migrate",
11
+ desc: "Migration directory applied to each database containing mailbox messages"
12
+ class_option :directory_migrations_path, type: :string, default: "db/migrate",
13
+ desc: "Migration directory for the shared receiving-domain directory"
14
+
15
+ def copy_migration
16
+ migration_template "index_mailbox_kit_inbound_messages.rb",
17
+ File.join(options[:tenant_migrations_path], "index_mailbox_kit_inbound_messages.rb")
18
+ migration_template "allow_provider_neutral_receiving_domains.rb",
19
+ File.join(options[:directory_migrations_path], "allow_provider_neutral_receiving_domains.rb")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,11 @@
1
+ require "mailbox_kit/version"
2
+ require "mailbox_kit/error"
3
+
4
+ module MailboxKit
5
+ ROOT = File.expand_path("..", __dir__).freeze
6
+ module Mailboxes
7
+ def self.enabled? = @enabled == true
8
+ end
9
+ end
10
+
11
+ require "mailbox_kit/railtie" if defined?(::Rails::Railtie)
@@ -0,0 +1,58 @@
1
+ require "active_record"
2
+ require "mailbox_kit/tenancy"
3
+
4
+ module MailboxKit
5
+ module ActiveRecord
6
+ # Rails may inspect every model's pool while preloading schema metadata
7
+ # before any request has selected a tenant. Report an unavailable
8
+ # connection using Rails' error hierarchy so boot can recover, while
9
+ # retaining the same fail-closed guard for every caller.
10
+ class TenantConnectionUnavailable < ::ActiveRecord::ConnectionNotEstablished; end
11
+
12
+ # Uses the host's abstract tenant connection owner when explicitly configured.
13
+ class Base < Tenancy.model_base(::ActiveRecord::Base)
14
+ self.abstract_class = true
15
+
16
+ class << self
17
+ def connection_pool
18
+ if Tenancy.enabled?
19
+ begin
20
+ Tenancy.require_context!
21
+ rescue ConfigurationError => error
22
+ raise TenantConnectionUnavailable, error.message
23
+ end
24
+ end
25
+ super
26
+ end
27
+ end
28
+
29
+ before_validation :verify_cloudflare_email_tenant!
30
+ before_save :verify_cloudflare_email_tenant!
31
+ before_destroy :verify_cloudflare_email_tenant!
32
+
33
+ # These methods can bypass callbacks or load a different row with the same ID.
34
+ %i[reload update_columns delete touch increment! decrement! association].each do |method_name|
35
+ define_method(method_name) do |*args, **kwargs, &block|
36
+ verify_cloudflare_email_tenant!
37
+ super(*args, **kwargs, &block)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # Both new construction and persisted-row instantiation call this before
44
+ # assigning inverse associations (which precede after_initialize).
45
+ def init_internals
46
+ super
47
+ @cloudflare_email_tenant_key = Tenancy.require_context! if Tenancy.enabled?
48
+ end
49
+
50
+ def verify_cloudflare_email_tenant!
51
+ return unless Tenancy.enabled?
52
+ unless @cloudflare_email_tenant_key == Tenancy.require_context!
53
+ raise ConfigurationError, "record belongs to a different tenant context"
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,19 @@
1
+ module MailboxKit
2
+ module AddressSyntax
3
+ LOCAL_PART = /\A[A-Za-z0-9.!#$%&'*+\/=\?^_`{|}~-]+\z/
4
+ DOMAIN_LABEL = /\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\z/
5
+
6
+ def self.valid_address?(address, allow_empty: false)
7
+ return false unless address.is_a?(String) && address.ascii_only?
8
+ return true if allow_empty && address.empty?
9
+ return false if address.bytesize > 254
10
+ parts = address.split("@", -1)
11
+ return false unless parts.size == 2
12
+ local, domain = parts
13
+ local.bytesize <= 64 && LOCAL_PART.match?(local) &&
14
+ !local.start_with?(".") && !local.end_with?(".") && !local.include?("..") &&
15
+ domain.split(".", -1).all? { |label| DOMAIN_LABEL.match?(label) }
16
+ end
17
+
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ module MailboxKit
2
+ class Error < StandardError
3
+ attr_reader :response, :status
4
+
5
+ def initialize(message = nil, status: nil, response: nil)
6
+ super(message)
7
+ @status = status
8
+ @response = response
9
+ end
10
+ end
11
+
12
+ class ConfigurationError < Error; end
13
+ class ValidationError < Error; end
14
+ end
@@ -0,0 +1,56 @@
1
+ require "mailbox-kit"
2
+ require "digest"
3
+
4
+ module MailboxKit
5
+ # A small bridge to Rails' existing storage and routing lifecycle. It returns
6
+ # the existing record on duplicate delivery so callers can attach memberships.
7
+ module InboundEmail
8
+ Result = Struct.new(:record, :created, keyword_init: true) do
9
+ def created? = created
10
+ end
11
+
12
+ def self.persist(source:, message_id: nil, message_checksum: nil, metadata: {})
13
+ unless defined?(::ActionMailbox::InboundEmail)
14
+ raise ConfigurationError, "load ActionMailbox before persisting inbound mail"
15
+ end
16
+ raise ArgumentError, "source must be a String" unless source.is_a?(String)
17
+ source = source.b
18
+ scope = Tenancy.require_context! if defined?(Tenancy) && (Tenancy.enabled? || Tenancy.current_key)
19
+ # Two tenant scopes can share a database. Sharing bytes must not suppress
20
+ # the other tenant's Rails routing job. Recipients within one scope can
21
+ # share the same record and have independent inbox memberships.
22
+ checksum = message_checksum || if scope
23
+ Digest::SHA256.new.update("mailbox-kit\0#{scope.bytesize}:#{scope}\0").update(source).hexdigest
24
+ else
25
+ Digest::SHA1.hexdigest(source)
26
+ end
27
+ unless message_id
28
+ message_id = begin
29
+ ::Mail.from_source(source).message_id
30
+ rescue StandardError
31
+ nil
32
+ end
33
+ end
34
+ identity = { message_id: message_id || "#{checksum}@mailbox-kit.invalid",
35
+ message_checksum: checksum }
36
+ model = ::ActionMailbox::InboundEmail
37
+ model.transaction do
38
+ if (existing = model.find_by(identity))
39
+ next Result.new(record: existing, created: false).freeze
40
+ end
41
+ # Rails returns nil on a unique conflict. Roll back that savepoint before
42
+ # looking up the winner, including on PostgreSQL's aborted transactions.
43
+ record = model.transaction(requires_new: true) do
44
+ created = model.create_and_extract_message_id!(source, **identity)
45
+ raise ::ActiveRecord::Rollback unless created
46
+ unless metadata.empty?
47
+ blob = created.raw_email.blob
48
+ blob.update!(metadata: blob.metadata.merge(metadata))
49
+ end
50
+ created
51
+ end
52
+ Result.new(record: record || model.find_by!(identity), created: !record.nil?).freeze
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,19 @@
1
+ require "active_record"
2
+ require "mailbox_kit/error"
3
+ module MailboxKit
4
+ module Mailboxes
5
+ class Unavailable < MailboxKit::Error; end
6
+ class << self
7
+ def configure(directory_base: ::ActiveRecord::Base)
8
+ raise ConfigurationError, "configure mailboxes before loading mailbox models" if const_defined?(:ReceivingDomain, false)
9
+ unless directory_base.is_a?(Class) && directory_base <= ::ActiveRecord::Base
10
+ raise ConfigurationError, "directory_base must be an ActiveRecord base class"
11
+ end
12
+ @directory_base = directory_base
13
+ end
14
+ def directory_base = @directory_base || ::ActiveRecord::Base
15
+ def enabled? = @enabled == true
16
+ def enable! = @enabled = true
17
+ end
18
+ end
19
+ end