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
data/docs/integration.md
ADDED
|
@@ -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
|
data/lib/mailbox-kit.rb
ADDED
|
@@ -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
|