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.
Files changed (139) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +287 -385
  4. data/SECURITY.md +51 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +33 -21
  6. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
  7. data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
  8. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
  9. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
  10. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
  11. data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
  12. data/docs/activerecord-tenanted.md +181 -0
  13. data/docs/architecture.md +105 -0
  14. data/docs/custom-ingress.md +207 -0
  15. data/docs/delivery-events.md +186 -0
  16. data/docs/features.md +138 -0
  17. data/docs/getting-started.md +308 -0
  18. data/docs/mailboxes.md +363 -0
  19. data/docs/management-engine.md +190 -0
  20. data/docs/outbox.md +175 -0
  21. data/docs/routing-deliveries.md +94 -0
  22. data/docs/routing-diagnostics.md +68 -0
  23. data/docs/tenant-mailboxes-plan.md +149 -0
  24. data/docs/thread-correlation.md +27 -0
  25. data/docs/troubleshooting.md +104 -0
  26. data/docs/upgrading-0.2.md +101 -0
  27. data/docs/upgrading-0.3.md +55 -0
  28. data/docs/verification/2026-09-10-followup.md +49 -0
  29. data/docs/verification/2026-09-10-install-upgrade.md +86 -0
  30. data/docs/verification/2026-09-10-live.md +102 -0
  31. data/docs/verification/2026-09-10.md +175 -0
  32. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  33. data/docs/verification/2026-09-11-extraction.md +32 -0
  34. data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
  35. data/docs/verification/2026-09-11-security.md +76 -0
  36. data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
  37. data/docs/verification/2026-09-12-destination-context.md +46 -0
  38. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  39. data/examples/plain_ruby.rb +24 -0
  40. data/lib/cloudflare/email/active_record/base.rb +60 -0
  41. data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
  42. data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
  43. data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
  44. data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
  45. data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
  46. data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
  47. data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
  48. data/lib/cloudflare/email/active_record/outbox.rb +219 -0
  49. data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
  50. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  51. data/lib/cloudflare/email/active_record.rb +5 -0
  52. data/lib/cloudflare/email/check_route_task.rb +21 -0
  53. data/lib/cloudflare/email/client.rb +93 -18
  54. data/lib/cloudflare/email/consume_events_task.rb +22 -0
  55. data/lib/cloudflare/email/delivery_event.rb +70 -0
  56. data/lib/cloudflare/email/delivery_method.rb +5 -0
  57. data/lib/cloudflare/email/deploy_worker_task.rb +6 -3
  58. data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
  59. data/lib/cloudflare/email/dev_tunnel.rb +31 -5
  60. data/lib/cloudflare/email/doctor.rb +16 -38
  61. data/lib/cloudflare/email/endpoint.rb +24 -0
  62. data/lib/cloudflare/email/engine.rb +33 -0
  63. data/lib/cloudflare/email/envelope.rb +58 -0
  64. data/lib/cloudflare/email/event_consumer.rb +95 -0
  65. data/lib/cloudflare/email/ingress.rb +115 -0
  66. data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
  67. data/lib/cloudflare/email/mailboxes/events.rb +112 -0
  68. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
  69. data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
  70. data/lib/cloudflare/email/mailboxes/models.rb +165 -0
  71. data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
  72. data/lib/cloudflare/email/mailboxes/service.rb +372 -0
  73. data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
  74. data/lib/cloudflare/email/mailboxes.rb +11 -0
  75. data/lib/cloudflare/email/management/adapter.rb +31 -0
  76. data/lib/cloudflare/email/management/configuration.rb +17 -0
  77. data/lib/cloudflare/email/management/engine.rb +15 -0
  78. data/lib/cloudflare/email/management/management.css +68 -0
  79. data/lib/cloudflare/email/management/routes.rb +14 -0
  80. data/lib/cloudflare/email/management.rb +7 -0
  81. data/lib/cloudflare/email/message_id.rb +12 -0
  82. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  83. data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
  84. data/lib/cloudflare/email/provision_route_task.rb +2 -1
  85. data/lib/cloudflare/email/replay_events_job.rb +18 -0
  86. data/lib/cloudflare/email/response.rb +21 -3
  87. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  88. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  89. data/lib/cloudflare/email/routing_provisioner.rb +71 -45
  90. data/lib/cloudflare/email/send_job.rb +41 -0
  91. data/lib/cloudflare/email/send_test.rb +5 -29
  92. data/lib/cloudflare/email/signing.rb +2 -2
  93. data/lib/cloudflare/email/tenancy.rb +78 -0
  94. data/lib/cloudflare/email/tenant_job_context.rb +90 -0
  95. data/lib/cloudflare/email/verification.rb +38 -8
  96. data/lib/cloudflare/email/version.rb +1 -1
  97. data/lib/cloudflare/email/worker_deployer.rb +41 -8
  98. data/lib/cloudflare-email.rb +4 -1
  99. data/lib/generators/cloudflare/email/install_generator.rb +53 -37
  100. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  101. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  102. data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
  103. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +58 -0
  104. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
  105. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
  106. data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
  107. data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
  108. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  109. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  110. data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
  111. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
  112. data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
  113. data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
  114. data/lib/tasks/cloudflare_email.rake +42 -4
  115. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  116. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  117. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  118. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  119. data/templates/deploy-to-cloudflare/README.md +84 -0
  120. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  121. data/templates/deploy-to-cloudflare/package.json +36 -0
  122. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  123. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  124. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  125. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  126. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  127. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  128. data/templates/worker/README.md +231 -16
  129. data/templates/worker/docs/durable-inbound.md +149 -0
  130. data/templates/worker/package-lock.json +2627 -0
  131. data/templates/worker/package.json +8 -5
  132. data/templates/worker/scripts/wrangler.mjs +16 -0
  133. data/templates/worker/src/index.js +397 -25
  134. data/templates/worker/test/durable.test.ts +193 -0
  135. data/templates/worker/test/index.test.ts +183 -13
  136. data/templates/worker/test/relay.test.ts +226 -0
  137. data/templates/worker/wrangler.toml +63 -4
  138. metadata +154 -7
  139. 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.