cloudflare-email 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +149 -1
- data/README.md +287 -385
- data/SECURITY.md +51 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +33 -21
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
- data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
- data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
- data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
- data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
- data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
- data/docs/activerecord-tenanted.md +181 -0
- data/docs/architecture.md +105 -0
- data/docs/custom-ingress.md +207 -0
- data/docs/delivery-events.md +186 -0
- data/docs/features.md +138 -0
- data/docs/getting-started.md +308 -0
- data/docs/mailboxes.md +363 -0
- data/docs/management-engine.md +190 -0
- data/docs/outbox.md +175 -0
- data/docs/routing-deliveries.md +94 -0
- data/docs/routing-diagnostics.md +68 -0
- data/docs/tenant-mailboxes-plan.md +149 -0
- data/docs/thread-correlation.md +27 -0
- data/docs/troubleshooting.md +104 -0
- data/docs/upgrading-0.2.md +101 -0
- data/docs/upgrading-0.3.md +55 -0
- data/docs/verification/2026-09-10-followup.md +49 -0
- data/docs/verification/2026-09-10-install-upgrade.md +86 -0
- data/docs/verification/2026-09-10-live.md +102 -0
- data/docs/verification/2026-09-10.md +175 -0
- data/docs/verification/2026-09-11-custom-ingress.md +49 -0
- data/docs/verification/2026-09-11-extraction.md +32 -0
- data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
- data/docs/verification/2026-09-11-security.md +76 -0
- data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
- data/docs/verification/2026-09-12-destination-context.md +46 -0
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
- data/examples/plain_ruby.rb +24 -0
- data/lib/cloudflare/email/active_record/base.rb +60 -0
- data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
- data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
- data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
- data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
- data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
- data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
- data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
- data/lib/cloudflare/email/active_record/outbox.rb +219 -0
- data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
- data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
- data/lib/cloudflare/email/active_record.rb +5 -0
- data/lib/cloudflare/email/check_route_task.rb +21 -0
- data/lib/cloudflare/email/client.rb +93 -18
- data/lib/cloudflare/email/consume_events_task.rb +22 -0
- data/lib/cloudflare/email/delivery_event.rb +70 -0
- data/lib/cloudflare/email/delivery_method.rb +5 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +6 -3
- data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
- data/lib/cloudflare/email/dev_tunnel.rb +31 -5
- data/lib/cloudflare/email/doctor.rb +16 -38
- data/lib/cloudflare/email/endpoint.rb +24 -0
- data/lib/cloudflare/email/engine.rb +33 -0
- data/lib/cloudflare/email/envelope.rb +58 -0
- data/lib/cloudflare/email/event_consumer.rb +95 -0
- data/lib/cloudflare/email/ingress.rb +115 -0
- data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
- data/lib/cloudflare/email/mailboxes/events.rb +112 -0
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
- data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
- data/lib/cloudflare/email/mailboxes/models.rb +165 -0
- data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
- data/lib/cloudflare/email/mailboxes/service.rb +372 -0
- data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
- data/lib/cloudflare/email/mailboxes.rb +11 -0
- data/lib/cloudflare/email/management/adapter.rb +31 -0
- data/lib/cloudflare/email/management/configuration.rb +17 -0
- data/lib/cloudflare/email/management/engine.rb +15 -0
- data/lib/cloudflare/email/management/management.css +68 -0
- data/lib/cloudflare/email/management/routes.rb +14 -0
- data/lib/cloudflare/email/management.rb +7 -0
- data/lib/cloudflare/email/message_id.rb +12 -0
- data/lib/cloudflare/email/provider_metadata.rb +77 -0
- data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
- data/lib/cloudflare/email/provision_route_task.rb +2 -1
- data/lib/cloudflare/email/replay_events_job.rb +18 -0
- data/lib/cloudflare/email/response.rb +21 -3
- data/lib/cloudflare/email/routing_analytics.rb +196 -0
- data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
- data/lib/cloudflare/email/routing_provisioner.rb +71 -45
- data/lib/cloudflare/email/send_job.rb +41 -0
- data/lib/cloudflare/email/send_test.rb +5 -29
- data/lib/cloudflare/email/signing.rb +2 -2
- data/lib/cloudflare/email/tenancy.rb +78 -0
- data/lib/cloudflare/email/tenant_job_context.rb +90 -0
- data/lib/cloudflare/email/verification.rb +38 -8
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +41 -8
- data/lib/cloudflare-email.rb +4 -1
- data/lib/generators/cloudflare/email/install_generator.rb +53 -37
- data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
- data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
- data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +58 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
- data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
- data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
- data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
- data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
- data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
- data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
- data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
- data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
- data/lib/tasks/cloudflare_email.rake +42 -4
- data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
- data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
- data/templates/deploy-to-cloudflare/.gitignore +6 -0
- data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
- data/templates/deploy-to-cloudflare/README.md +84 -0
- data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
- data/templates/deploy-to-cloudflare/package.json +36 -0
- data/templates/deploy-to-cloudflare/src/index.js +445 -0
- data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
- data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
- data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
- data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
- data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
- data/templates/worker/README.md +231 -16
- data/templates/worker/docs/durable-inbound.md +149 -0
- data/templates/worker/package-lock.json +2627 -0
- data/templates/worker/package.json +8 -5
- data/templates/worker/scripts/wrangler.mjs +16 -0
- data/templates/worker/src/index.js +397 -25
- data/templates/worker/test/durable.test.ts +193 -0
- data/templates/worker/test/index.test.ts +183 -13
- data/templates/worker/test/relay.test.ts +226 -0
- data/templates/worker/wrangler.toml +63 -4
- metadata +154 -7
- data/lib/cloudflare/email/secure_message_id.rb +0 -89
data/docs/mailboxes.md
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# Create and manage mailboxes from Rails
|
|
2
|
+
|
|
3
|
+
The optional mailbox module lets your app create mailboxes and aliases, receive
|
|
4
|
+
mail into them, track read/archive state, and send through the durable outbox.
|
|
5
|
+
It works in one SQLite database or with a separate database per organization.
|
|
6
|
+
It supplies models and services; your app supplies permissions and the UI.
|
|
7
|
+
|
|
8
|
+
This module is available since version 0.2.0. Install `gem "cloudflare-email", "~> 0.3.0"` for the latest features.
|
|
9
|
+
|
|
10
|
+
**Database multi-tenancy is off by default.** The mailbox generator works with
|
|
11
|
+
one ordinary database. Calling `for_tenant` groups and scopes mailbox records;
|
|
12
|
+
it does not create databases or install a tenant adapter. Separate databases
|
|
13
|
+
require explicit `Tenancy.configure(...)` before models load. Neither Rails nor
|
|
14
|
+
`activerecord-tenanted` is added to plain Ruby applications by this module.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
First configure [sending and receiving](getting-started.md). Then run:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bin/rails generate cloudflare:email:mailboxes
|
|
22
|
+
bin/rails db:migrate
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The generator includes the outbox and tracking generators. Do not generate those
|
|
26
|
+
migrations a second time if you already installed them; inspect existing
|
|
27
|
+
generator conflicts and retain your installed migrations. Restart Rails after
|
|
28
|
+
configuration changes. The generated initializer explicitly loads:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require "cloudflare/email/mailboxes"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Loading this module enables mailbox lookup on the gem's Cloudflare ingress.
|
|
35
|
+
Existing receiving addresses must be registered and activated before switching
|
|
36
|
+
an existing application over. Unregistered or suspended destinations return
|
|
37
|
+
HTTP 422 and the Worker rejects the delivery by default. You can explicitly
|
|
38
|
+
configure a domain catch-all below when your application needs that behavior.
|
|
39
|
+
|
|
40
|
+
For separate organization databases, follow the
|
|
41
|
+
[activerecord-tenanted setup](activerecord-tenanted.md) **before loading the
|
|
42
|
+
models**. It covers shared versus tenant migrations, tenant-specific framework
|
|
43
|
+
storage, early initialization and background jobs. Rails and ActiveStorage must
|
|
44
|
+
use the same tenant connection as the mailbox tables for atomic incoming storage.
|
|
45
|
+
|
|
46
|
+
## Register an organization's domain
|
|
47
|
+
|
|
48
|
+
Run directory management from your authorized administration/provisioning code.
|
|
49
|
+
Do not expose arbitrary domain claims to customers without ownership checks.
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
inboxes = Cloudflare::Email::Mailboxes
|
|
53
|
+
domain = inboxes.register_domain(
|
|
54
|
+
domain: "acme.example.com",
|
|
55
|
+
tenant_key: "organization-123",
|
|
56
|
+
account_id: Cloudflare::Email::Credentials.account_id,
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
This creates a **pending directory entry**, not DNS records. Set up the receiving
|
|
61
|
+
subdomain in Cloudflare and deploy the Worker. Then record your verification:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
inboxes.activate_domain!(domain.id,
|
|
65
|
+
evidence: "Receiving DNS and the development Worker verified in setup ticket 42",
|
|
66
|
+
sending_enabled: false,
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Activation records an operator assertion; the gem does not independently verify
|
|
71
|
+
domain ownership from this string. Set `sending_enabled: true` only after
|
|
72
|
+
Cloudflare has also verified this sending domain. Incoming routing and outgoing
|
|
73
|
+
domain verification are separate setup steps. An address's route is activated
|
|
74
|
+
separately below.
|
|
75
|
+
|
|
76
|
+
Each exact domain belongs to one tenant. Domain ownership, tenant key and
|
|
77
|
+
Cloudflare account identity cannot be reassigned through normal model updates.
|
|
78
|
+
Keep tenant keys stable and resolve them to provisioned databases through your
|
|
79
|
+
trusted tenant adapter. Use customer/organization IDs rather than deriving a
|
|
80
|
+
database filename directly from an email address.
|
|
81
|
+
|
|
82
|
+
## Create a mailbox and activate its route
|
|
83
|
+
|
|
84
|
+
Even in a single database, use an explicit tenant key and the scoped session:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
inboxes.for_tenant("organization-123") do |account|
|
|
88
|
+
mailbox = account.create(
|
|
89
|
+
name: "Customer support",
|
|
90
|
+
address: "support@acme.example.com",
|
|
91
|
+
owner_ref: "team:42",
|
|
92
|
+
)
|
|
93
|
+
address = account.addresses(mailbox.id).first
|
|
94
|
+
|
|
95
|
+
provisioner = Cloudflare::Email::RoutingProvisioner.new(
|
|
96
|
+
api_token: Cloudflare::Email::Credentials.management_token,
|
|
97
|
+
)
|
|
98
|
+
account.provision_address!(address.id,
|
|
99
|
+
provisioner: provisioner,
|
|
100
|
+
worker_name: "cloudflare-email-ingress-development",
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`owner_ref` is your application's optional stable reference to a user, site,
|
|
106
|
+
team or customer. The gem stores it; it does not load that record or authorize
|
|
107
|
+
the person using it. Authorize the organization and mailbox in your app first.
|
|
108
|
+
|
|
109
|
+
The address stays pending if provisioning fails. The provisioner upserts an
|
|
110
|
+
individual address rule, so you can retry setup. Run provisioning outside a
|
|
111
|
+
database transaction. DNS propagation and an actual inbound test are still
|
|
112
|
+
needed after the API succeeds.
|
|
113
|
+
|
|
114
|
+
If you already configured a route separately, record that explicitly:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
account.activate_address!(address.id, evidence: "Existing route verified in ticket 43")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
This example assumes the same `for_tenant` block and local variables as above.
|
|
121
|
+
The gem does not change a zone-wide catch-all while creating a mailbox.
|
|
122
|
+
|
|
123
|
+
## Aliases, suspension and ownership
|
|
124
|
+
|
|
125
|
+
An alias is another address attached to the same mailbox:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
inboxes.for_tenant("organization-123") do |account|
|
|
129
|
+
mailbox = account.mailboxes.find_by!(owner_ref: "team:42")
|
|
130
|
+
alias_address = account.add_address(mailbox.id, address: "help@acme.example.com")
|
|
131
|
+
# Provision alias_address exactly as you provisioned the first address.
|
|
132
|
+
|
|
133
|
+
account.suspend(mailbox.id) # Stops new ingress and queued mailbox sends.
|
|
134
|
+
account.resume(mailbox.id)
|
|
135
|
+
# To suspend only one address:
|
|
136
|
+
account.suspend_address(mailbox.id, alias_address.id)
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Suspension is enforced by Rails; it does not delete the Cloudflare route. Mail
|
|
141
|
+
already accepted and stored remains available for processing and inspection.
|
|
142
|
+
Use `activate_address!` with new evidence to reactivate an address. Domain-wide
|
|
143
|
+
suspension is an administrative update of `ReceivingDomain#state` to `suspended`.
|
|
144
|
+
|
|
145
|
+
Addresses use lowercase ASCII dot-atom local parts and domains. Alias addresses
|
|
146
|
+
are explicit: the module does not automatically strip `+tags` or invent address
|
|
147
|
+
fallbacks. Reserve application-specific names such as Rebulk's `tracking` in
|
|
148
|
+
your mailbox-management policy before creation.
|
|
149
|
+
|
|
150
|
+
## Receive unregistered local parts with an optional catch-all
|
|
151
|
+
|
|
152
|
+
Catch-all receiving is **off by default** and is available in the 0.3
|
|
153
|
+
version. It lets one existing address receive otherwise unregistered addresses
|
|
154
|
+
on its exact domain, without creating an alias row for each incoming local part.
|
|
155
|
+
For example, `anything@acme.example.com` can arrive in an existing support mailbox.
|
|
156
|
+
It does not cover subdomains such as `anything@other.acme.example.com`.
|
|
157
|
+
|
|
158
|
+
Fresh installations include the schema. For an existing installation, generate
|
|
159
|
+
the optional upgrade and apply it to the database containing your mailbox tables:
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
bin/rails generate cloudflare:email:mailboxes:catch_all
|
|
163
|
+
bin/rails db:migrate
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
With separate tenant databases, pass
|
|
167
|
+
`--tenant-migrations-path=db/tenant_migrate` and run your application's tenant
|
|
168
|
+
migration command for every affected tenant. Updating the gem alone keeps older
|
|
169
|
+
schemas working with exact-address routing. Enabling a catch-all without its
|
|
170
|
+
migration raises a configuration error.
|
|
171
|
+
|
|
172
|
+
First configure and verify the domain's catch-all Email Routing rule in Cloudflare
|
|
173
|
+
to send mail to your Worker. Then use your authorized provisioning code:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
Cloudflare::Email::Mailboxes.for_tenant("organization-123") do |account|
|
|
177
|
+
mailbox = account.mailboxes.find_by!(owner_ref: "team:42")
|
|
178
|
+
address = account.addresses(mailbox.id).find_by!(address: "support@acme.example.com")
|
|
179
|
+
account.enable_catch_all(address.id,
|
|
180
|
+
evidence: "Domain catch-all Worker route verified in setup ticket 57")
|
|
181
|
+
|
|
182
|
+
# Later, stop accepting unregistered local parts:
|
|
183
|
+
# account.disable_catch_all(address.id)
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The address, mailbox and receiving domain must be active before enabling it.
|
|
188
|
+
The evidence is an operator assertion, as with ordinary address activation;
|
|
189
|
+
this call does not change Cloudflare rules. Only one active address per domain
|
|
190
|
+
can serve as a catch-all. A database constraint also protects concurrent changes.
|
|
191
|
+
Suspending that address pauses fallback. If another catch-all is enabled while
|
|
192
|
+
it is suspended, disable the old flag before reactivating the old address.
|
|
193
|
+
|
|
194
|
+
Exact registered addresses always take precedence. An exact address that is
|
|
195
|
+
pending, suspended, or belongs to a suspended mailbox is rejected; it does not
|
|
196
|
+
fall through to another mailbox. The catch-all also stops receiving when its
|
|
197
|
+
mailbox or domain is suspended.
|
|
198
|
+
|
|
199
|
+
Both `with_recipient` and `receive` yield `destination.catch_all == true` only
|
|
200
|
+
when fallback was used. `destination.recipient` remains the actual envelope
|
|
201
|
+
recipient, while `address_id` identifies the existing backing address. Stored
|
|
202
|
+
mailbox membership retains that actual recipient too. Apps can use this flag to
|
|
203
|
+
enforce additional rules, such as allowing fallback only while an organization
|
|
204
|
+
has one site, before accepting/persisting the message.
|
|
205
|
+
|
|
206
|
+
Catch-all receiving grants **no additional sending addresses**. Outbound From,
|
|
207
|
+
Sender and envelope sender must still match an exact active registered address.
|
|
208
|
+
Disable catch-all receiving through the API; the management UI displays its
|
|
209
|
+
configuration but does not enable or disable it.
|
|
210
|
+
|
|
211
|
+
## Read incoming mail
|
|
212
|
+
|
|
213
|
+
The Worker signs the SMTP recipient. Rails verifies the whole request, resolves
|
|
214
|
+
the domain, enters its tenant context and stores raw mail plus a mailbox
|
|
215
|
+
membership in one database transaction. MIME To/Cc headers do not select the
|
|
216
|
+
tenant. Retries for the same raw message and exact SMTP recipient are deduplicated.
|
|
217
|
+
|
|
218
|
+
Inside your authenticated application's mailbox view/service:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
inboxes.for_tenant("organization-123") do |account|
|
|
222
|
+
mailbox = account.mailboxes.find_by!(owner_ref: "team:42")
|
|
223
|
+
account.messages(mailbox.id).inbox.unread.order(id: :desc).limit(25).each do |entry|
|
|
224
|
+
inbound = account.inbound_email(mailbox.id, entry.id)
|
|
225
|
+
subject = inbound.mail.subject
|
|
226
|
+
raw_mime = inbound.raw_email.download
|
|
227
|
+
# Build your authorized UI response here; sanitize rendered mail content.
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Perform tenant record access inside the block, rather than retaining lazy
|
|
233
|
+
relations or framework records for use under another tenant. Numeric record IDs
|
|
234
|
+
can overlap in separate tenant databases.
|
|
235
|
+
|
|
236
|
+
For a message entry you have already authorized:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
account.mark_read(mailbox.id, entry.id)
|
|
240
|
+
account.mark_read(mailbox.id, entry.id, read: false)
|
|
241
|
+
account.archive(mailbox.id, entry.id)
|
|
242
|
+
account.archive(mailbox.id, entry.id, archived: false)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
These calls also belong inside the tenant block. Raw mail with a mailbox
|
|
246
|
+
membership is protected from ActionMailbox's normal automatic incineration.
|
|
247
|
+
Archive hides it from the inbox scope but retains the content. For deliberate
|
|
248
|
+
permanent deletion, use `account.purge_message(mailbox.id, entry.id)`; it deletes
|
|
249
|
+
the raw inbound record only when no other mailbox membership references it.
|
|
250
|
+
ActiveStorage schedules its attachment cleanup jobs. Apply your own retention,
|
|
251
|
+
encryption and backup policies; archive is not a retention policy.
|
|
252
|
+
|
|
253
|
+
## Send from the mailbox
|
|
254
|
+
|
|
255
|
+
Use a rendered ActionMailer message whose From and SMTP envelope sender are an
|
|
256
|
+
active address belonging to the mailbox. The domain must be enabled for sending.
|
|
257
|
+
`ReplyMailer` below is your application's mailer, not a generated gem class.
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
inboxes.for_tenant("organization-123") do |account|
|
|
261
|
+
mailbox = account.mailboxes.find_by!(owner_ref: "team:42")
|
|
262
|
+
operation = account.prepare(mailbox.id,
|
|
263
|
+
operation_key: "draft:789:revision:1",
|
|
264
|
+
mail: ReplyMailer.reply(draft).message,
|
|
265
|
+
)
|
|
266
|
+
account.enqueue(mailbox.id, operation_key: operation.operation_key)
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Authorize the send before preparation. If preparation is part of an application
|
|
271
|
+
transaction, enqueue only after its **outer commit**. The job queue must process
|
|
272
|
+
`mailers`. Do not also call `deliver_later` on the mailer.
|
|
273
|
+
|
|
274
|
+
The caller's operation key is namespaced by tenant and mailbox. Save the returned
|
|
275
|
+
operation key and reuse it when enqueueing a retry. Re-rendering an already
|
|
276
|
+
prepared operation may change MIME headers and raises a snapshot conflict.
|
|
277
|
+
The job serializes tenant, mailbox ID and operation key—not MIME or credentials.
|
|
278
|
+
It checks the current mailbox/address/domain state again before sending.
|
|
279
|
+
|
|
280
|
+
Inspect `account.outbound_messages(mailbox.id)` and each link's
|
|
281
|
+
`outbound_delivery` for the saved operation, provider ID and recipient outcomes.
|
|
282
|
+
Accepted or partial attempts are never automatically resent. Uncertain attempts
|
|
283
|
+
need [audited reconciliation](outbox.md#reconcile-uncertainty), available through
|
|
284
|
+
`account.reconcile(mailbox.id, operation_key: saved_key, **evidence)`.
|
|
285
|
+
|
|
286
|
+
For several Cloudflare accounts, configure a runtime client resolver **before
|
|
287
|
+
loading mailbox models**:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
Cloudflare::Email::Mailboxes.configure(
|
|
291
|
+
directory_base: DirectoryRecord,
|
|
292
|
+
client_resolver: ->(tenant_key, account_id) {
|
|
293
|
+
Cloudflare::Email::Client.new(account_id: account_id,
|
|
294
|
+
api_token: YourSecretStore.email_token(tenant_key, account_id),
|
|
295
|
+
retry_ambiguous: false)
|
|
296
|
+
},
|
|
297
|
+
)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
`DirectoryRecord` and `YourSecretStore` are application examples. For one account,
|
|
301
|
+
the default resolver uses the existing gem credentials and verifies the account
|
|
302
|
+
matches. Cloudflare account IDs and your tenant keys are separate identities.
|
|
303
|
+
|
|
304
|
+
## Delivery events and recovery
|
|
305
|
+
|
|
306
|
+
Configure the [Cloudflare Queue subscription and token](delivery-events.md).
|
|
307
|
+
Use shared intake for tenant mailboxes, rather than selecting a tenant from the
|
|
308
|
+
event's recipient or Cloudflare account:
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
Rails.application.config.x.cloudflare_email.event_domains = ["acme.example.com"]
|
|
312
|
+
Rails.application.config.x.cloudflare_email.event_handler = ->(event) {
|
|
313
|
+
Cloudflare::Email::Mailboxes::Events.record(event)
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Schedule `cloudflare:email:consume_events` to poll batches. Also schedule these
|
|
318
|
+
jobs regularly, starting at the beginning of each scan:
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
Cloudflare::Email::Mailboxes::ReplayEventsJob.perform_later
|
|
322
|
+
Cloudflare::Email::Mailboxes::RecoverJob.perform_later("organization-123")
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Schedule recovery for each provisioned organization in your own directory. Each
|
|
326
|
+
job processes a bounded page and enqueues its continuation. Recovery dispatches
|
|
327
|
+
prepared operations, repairs accepted-result callbacks and registers provider
|
|
328
|
+
correlation; it does not clear uncertain send claims. Domain suspension leaves
|
|
329
|
+
events retained for later projection.
|
|
330
|
+
|
|
331
|
+
Shared event intake commits before queue acknowledgement. After an accepted
|
|
332
|
+
send, the gem registers account/message/recipient correlation to the tenant's
|
|
333
|
+
outbox record. Events arriving first remain unmatched until replay. Tenant
|
|
334
|
+
projection commits before shared completion; a crash between those commits
|
|
335
|
+
causes safe redelivery to the tenant's deduplicating receipt ledger. Ambiguous
|
|
336
|
+
correlations remain unprojected.
|
|
337
|
+
|
|
338
|
+
The existing `config.x.cloudflare_email.outbox_delivery_handler` and
|
|
339
|
+
`outbox_recipient_handler` callbacks work here too. Keep them idempotent and on
|
|
340
|
+
the same tenant connection when atomicity is required. External effects cannot
|
|
341
|
+
be rolled back. Provider acceptance, shared storage and tenant storage are not
|
|
342
|
+
one transaction; this is not exactly-once delivery.
|
|
343
|
+
|
|
344
|
+
## Isolation and rollout
|
|
345
|
+
|
|
346
|
+
The scoped session API checks tenant/mailbox ownership and sender addresses.
|
|
347
|
+
It does not authenticate Rails users. Protect admin directory APIs, choose the
|
|
348
|
+
tenant through your access resolver, and authorize every mailbox operation.
|
|
349
|
+
Direct/unscoped ActiveRecord access is an application-level privileged interface,
|
|
350
|
+
particularly in shared-database mode; it is not a row-level authorization system.
|
|
351
|
+
|
|
352
|
+
For database tenancy, configure all framework storage on the tenant connection
|
|
353
|
+
and exclude ingress from hostname/session tenant selection. New framework jobs
|
|
354
|
+
capture tenant context before serialization and restore it before GlobalID
|
|
355
|
+
lookup. Drain existing ActionMailbox/ActiveStorage queues before enabling this
|
|
356
|
+
mode: previously serialized jobs without the new metadata are rejected.
|
|
357
|
+
|
|
358
|
+
The gem does not create organizations/users, verify customer domain ownership,
|
|
359
|
+
provide IMAP/POP, or supply a full compose/conversation inbox. The optional
|
|
360
|
+
[management engine](management-engine.md) supplies mailbox administration and
|
|
361
|
+
plain-text previews using your host's access policy. The original Rebulk document-processing
|
|
362
|
+
Worker contract and sender-review policy still need a separate application
|
|
363
|
+
migration; installing this module does not replace that live pipeline.
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Optional mailbox management engine
|
|
2
|
+
|
|
3
|
+
The management engine is a small, server-rendered Rails interface for the
|
|
4
|
+
[managed mailbox APIs](mailboxes.md). It works with ordinary Rails apps and with
|
|
5
|
+
apps using Inertia, React, Turbo or another frontend. No JavaScript, Node build,
|
|
6
|
+
asset pipeline or frontend package is required for the engine.
|
|
7
|
+
|
|
8
|
+
This feature is available in RubyGems 0.3.0.
|
|
9
|
+
|
|
10
|
+
## What you get
|
|
11
|
+
|
|
12
|
+
- Mailbox lists, names, creation and aliases on allowed receiving domains.
|
|
13
|
+
- Address setup status and mailbox suspension/resumption.
|
|
14
|
+
- A paginated list of received messages and an escaped plain-text preview.
|
|
15
|
+
- Optional read/unread and archive/unarchive controls.
|
|
16
|
+
- Ordinary CSRF-protected forms, mobile layouts and bundled CSS.
|
|
17
|
+
|
|
18
|
+
Your app supplies login, permissions, domain entitlements and any organization
|
|
19
|
+
mapping. The engine does not create users, claim domains, edit DNS, provision
|
|
20
|
+
Cloudflare routes, send mail, or permanently delete records. The default creation
|
|
21
|
+
hooks create pending addresses. Your administrator or existing provisioning code
|
|
22
|
+
completes routing and activation through the same Ruby APIs.
|
|
23
|
+
|
|
24
|
+
The viewer never renders sender HTML, loads remote images, or provides raw MIME
|
|
25
|
+
or attachment downloads. It shows attachment names and up to 100,000 plain-text
|
|
26
|
+
characters. Compose, conversations, AI workflows and product search belong in
|
|
27
|
+
your application.
|
|
28
|
+
|
|
29
|
+
## Install and mount
|
|
30
|
+
|
|
31
|
+
First install the managed mailbox tables and enable the module using
|
|
32
|
+
[the mailbox setup guide](mailboxes.md). SQLite works; database tenancy remains
|
|
33
|
+
off unless your app configures it explicitly.
|
|
34
|
+
|
|
35
|
+
Require the engine **before Rails initializes**, after Bundler loads the gem:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# config/application.rb, after Bundler.require(*Rails.groups)
|
|
39
|
+
require "cloudflare/email/management"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Configure a host adapter in an initializer:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# config/initializers/cloudflare_email_management.rb
|
|
46
|
+
Cloudflare::Email::Management.configure do |config|
|
|
47
|
+
# Resolve the class when handling the request, so Rails reloading works.
|
|
48
|
+
config.adapter = ->(controller) { EmailManagementAccess.new(controller) }
|
|
49
|
+
config.back_path = ->(controller) { controller.main_app.root_path }
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Mount it wherever it fits your application:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
# config/routes.rb
|
|
57
|
+
mount Cloudflare::Email::Management::Engine => "/email-management",
|
|
58
|
+
as: :email_management
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Installing the gem does not expose this interface automatically. Without a
|
|
62
|
+
configured adapter and enabled mailbox module the mounted interface returns 503.
|
|
63
|
+
The base adapter grants no access. Its public stylesheet endpoint serves only
|
|
64
|
+
the bundled CSS.
|
|
65
|
+
|
|
66
|
+
## Connect your authentication and ownership
|
|
67
|
+
|
|
68
|
+
Here is a single-database example using Rails' generated authentication convention
|
|
69
|
+
(`Session`, `Current.session`, and a signed `session_id` cookie). Match the cookie
|
|
70
|
+
and session lookup to your app; the agentic inbox example uses `session_token`.
|
|
71
|
+
Use the host authentication system's expiry, revocation and access policy.
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
# app/services/email_management_access.rb
|
|
75
|
+
class EmailManagementAccess < Cloudflare::Email::Management::Adapter
|
|
76
|
+
ACTIONS = %i[index show create add_address suspend resume
|
|
77
|
+
show_message mark_read archive].freeze
|
|
78
|
+
|
|
79
|
+
def authenticate!
|
|
80
|
+
Current.session = Session.find_by(
|
|
81
|
+
id: controller.request.cookie_jar.signed[:session_id]
|
|
82
|
+
)
|
|
83
|
+
@user = Current.user
|
|
84
|
+
return true if @user
|
|
85
|
+
|
|
86
|
+
controller.redirect_to controller.main_app.new_session_path
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def tenant_key = "application"
|
|
91
|
+
def owner_ref = "User:#{@user.id}"
|
|
92
|
+
|
|
93
|
+
def mailboxes(session)
|
|
94
|
+
session.mailboxes.where(owner_ref: owner_ref)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def allowed?(action, mailbox = nil)
|
|
98
|
+
@user.present? && ACTIONS.include?(action) &&
|
|
99
|
+
(mailbox.nil? || mailbox.owner_ref == owner_ref)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def domains(_session)
|
|
103
|
+
["in.example.com"] # Only domains this user is entitled to use.
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def create_mailbox(session, name:, address:)
|
|
107
|
+
session.create(name: name, address: address, owner_ref: owner_ref)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
For Devise, the authentication hook can use your configured Warden scope instead
|
|
113
|
+
of the cookie lookup, for example
|
|
114
|
+
`@user = controller.request.env["warden"]&.authenticate(scope: :user)`.
|
|
115
|
+
The engine inherits from `ActionController::Base`; host `ApplicationController`
|
|
116
|
+
callbacks and methods are not inherited. Make authentication explicit in the
|
|
117
|
+
adapter and return exactly `true` on success, or redirect to the host's login.
|
|
118
|
+
|
|
119
|
+
The directory must already contain the active receiving domain assigned to the
|
|
120
|
+
same registry key. The engine intersects `domains` with that directory and
|
|
121
|
+
intersects the host mailbox relation with the current gem tenant. Parameters
|
|
122
|
+
cannot select an owner or tenant.
|
|
123
|
+
|
|
124
|
+
Do not return every domain merely because it exists in the database. A customer
|
|
125
|
+
app should apply organization entitlement, reserved-name and signup policies.
|
|
126
|
+
For separate tenant databases, return a trusted host-resolved key from
|
|
127
|
+
`tenant_key` and configure the existing gem tenancy adapter and framework
|
|
128
|
+
connections before mailbox models load.
|
|
129
|
+
|
|
130
|
+
## Customize behavior without forking the pages
|
|
131
|
+
|
|
132
|
+
The adapter has these hooks:
|
|
133
|
+
|
|
134
|
+
| Hook | Contract |
|
|
135
|
+
| --- | --- |
|
|
136
|
+
| `authenticate!` | Authenticate the request; return exactly true or redirect/deny. |
|
|
137
|
+
| `tenant_key` | Trusted registry key; never use an unchecked request parameter. |
|
|
138
|
+
| `mailboxes(session)` | ActiveRecord relation containing mailboxes accessible to this principal. |
|
|
139
|
+
| `allowed?(action, mailbox = nil)` | Return exactly true to permit an action. |
|
|
140
|
+
| `domains(session)` | Array of domain strings permitted for this principal. |
|
|
141
|
+
| `create_mailbox(session, name:, address:)` | Return the created gem mailbox, visible in the host's scope. |
|
|
142
|
+
| `add_address(session, mailbox, address:)` | Add an address through your existing app provisioning service if needed. |
|
|
143
|
+
|
|
144
|
+
The adapter factory, authentication and tenant-key resolution run before the
|
|
145
|
+
mailbox context is entered; the host must establish any context its authentication
|
|
146
|
+
needs. Mailbox/domain scopes, permissions and mutation hooks run inside the
|
|
147
|
+
resolved mailbox context. Creation also runs in a
|
|
148
|
+
database transaction; a returned mailbox outside the host scope rolls back.
|
|
149
|
+
Keep network work out of creation hooks and enqueue provisioning after commit.
|
|
150
|
+
The default alias hook calls `session.add_address`.
|
|
151
|
+
|
|
152
|
+
Return false for `:create` or lifecycle actions to provide a restricted dashboard.
|
|
153
|
+
Use `:show_message` to control message access independently of metadata. Deny
|
|
154
|
+
`:mark_read` and `:archive` when the host product already owns a different
|
|
155
|
+
conversation-level read/folder model. Hidden controls also reject direct requests.
|
|
156
|
+
|
|
157
|
+
An application with its own mailbox record can override creation and alias hooks
|
|
158
|
+
to call that application's service and return the linked gem model. The agentic
|
|
159
|
+
inbox example does this: both UIs and automatic account provisioning share one
|
|
160
|
+
service, so engine-created mailboxes also appear in its full inbox.
|
|
161
|
+
|
|
162
|
+
## Inertia, React and Turbo applications
|
|
163
|
+
|
|
164
|
+
Use a normal HTML link or full-page navigation to the mount. For example:
|
|
165
|
+
|
|
166
|
+
```jsx
|
|
167
|
+
<a href="/email-management">Manage mailboxes</a>
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The engine returns HTML, not Inertia responses. Its separate layout keeps host
|
|
171
|
+
frontend dependencies out of the gem. The optional `back_path` callback returns
|
|
172
|
+
a local absolute path for a Back to app link. Programmatic provisioning continues
|
|
173
|
+
to work whether this engine is mounted or not.
|
|
174
|
+
|
|
175
|
+
## Operational behavior
|
|
176
|
+
|
|
177
|
+
Suspension preserves mailbox records and retained mail while disabling mailbox
|
|
178
|
+
sending/receiving. Resuming does not activate pending addresses or repair DNS.
|
|
179
|
+
Read/archive state belongs to the gem mailbox memberships and does not implicitly
|
|
180
|
+
rewrite host conversation records.
|
|
181
|
+
|
|
182
|
+
Private pages use no-store caching, a restrictive CSP and a same-origin referrer
|
|
183
|
+
policy. Forms check both Rails CSRF tokens and origins. Serve the host over HTTPS,
|
|
184
|
+
configure trusted proxy headers correctly, and keep its session cookies secure.
|
|
185
|
+
Do not disable CSRF to work around a proxy configuration issue.
|
|
186
|
+
|
|
187
|
+
Verification includes real Rails sessions, owner/tenant scoping, all operation
|
|
188
|
+
permissions, CSRF/origin checks, rollback, escaped previews, retained raw source,
|
|
189
|
+
and eager-loading a core-only app without exposing the engine. The example also
|
|
190
|
+
tests desktop/mobile Chromium and JavaScript-disabled forms with synthetic mail.
|