cloudflare-email 0.1.0 → 0.2.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -1
  3. data/README.md +265 -388
  4. data/SECURITY.md +51 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +62 -6
  6. data/docs/activerecord-tenanted.md +135 -0
  7. data/docs/architecture.md +95 -0
  8. data/docs/delivery-events.md +177 -0
  9. data/docs/features.md +107 -0
  10. data/docs/getting-started.md +298 -0
  11. data/docs/mailboxes.md +299 -0
  12. data/docs/outbox.md +175 -0
  13. data/docs/tenant-mailboxes-plan.md +149 -0
  14. data/docs/thread-correlation.md +27 -0
  15. data/docs/troubleshooting.md +103 -0
  16. data/docs/upgrading-0.2.md +96 -0
  17. data/docs/verification/2026-09-10-followup.md +49 -0
  18. data/docs/verification/2026-09-10-install-upgrade.md +86 -0
  19. data/docs/verification/2026-09-10-live.md +102 -0
  20. data/docs/verification/2026-09-10.md +175 -0
  21. data/docs/verification/2026-09-11-extraction.md +32 -0
  22. data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
  23. data/docs/verification/2026-09-11-security.md +76 -0
  24. data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
  25. data/examples/plain_ruby.rb +24 -0
  26. data/lib/cloudflare/email/active_record/base.rb +48 -0
  27. data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
  28. data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
  29. data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
  30. data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
  31. data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
  32. data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
  33. data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
  34. data/lib/cloudflare/email/active_record/outbox.rb +219 -0
  35. data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
  36. data/lib/cloudflare/email/active_record.rb +5 -0
  37. data/lib/cloudflare/email/client.rb +51 -11
  38. data/lib/cloudflare/email/consume_events_task.rb +22 -0
  39. data/lib/cloudflare/email/delivery_event.rb +70 -0
  40. data/lib/cloudflare/email/delivery_method.rb +3 -0
  41. data/lib/cloudflare/email/deploy_worker_task.rb +3 -0
  42. data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
  43. data/lib/cloudflare/email/dev_tunnel.rb +31 -5
  44. data/lib/cloudflare/email/doctor.rb +16 -38
  45. data/lib/cloudflare/email/endpoint.rb +24 -0
  46. data/lib/cloudflare/email/engine.rb +28 -0
  47. data/lib/cloudflare/email/envelope.rb +58 -0
  48. data/lib/cloudflare/email/event_consumer.rb +86 -0
  49. data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
  50. data/lib/cloudflare/email/mailboxes/events.rb +112 -0
  51. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
  52. data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
  53. data/lib/cloudflare/email/mailboxes/models.rb +148 -0
  54. data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
  55. data/lib/cloudflare/email/mailboxes/service.rb +288 -0
  56. data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
  57. data/lib/cloudflare/email/mailboxes.rb +11 -0
  58. data/lib/cloudflare/email/message_id.rb +12 -0
  59. data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
  60. data/lib/cloudflare/email/provision_route_task.rb +2 -1
  61. data/lib/cloudflare/email/replay_events_job.rb +18 -0
  62. data/lib/cloudflare/email/response.rb +21 -3
  63. data/lib/cloudflare/email/routing_provisioner.rb +71 -45
  64. data/lib/cloudflare/email/send_job.rb +41 -0
  65. data/lib/cloudflare/email/send_test.rb +5 -29
  66. data/lib/cloudflare/email/signing.rb +2 -2
  67. data/lib/cloudflare/email/tenancy.rb +78 -0
  68. data/lib/cloudflare/email/tenant_job_context.rb +90 -0
  69. data/lib/cloudflare/email/verification.rb +30 -8
  70. data/lib/cloudflare/email/version.rb +1 -1
  71. data/lib/cloudflare/email/worker_deployer.rb +5 -3
  72. data/lib/cloudflare-email.rb +4 -1
  73. data/lib/generators/cloudflare/email/install_generator.rb +48 -33
  74. data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
  75. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +54 -0
  76. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
  77. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
  78. data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
  79. data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
  80. data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
  81. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
  82. data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
  83. data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
  84. data/lib/tasks/cloudflare_email.rake +41 -4
  85. data/templates/worker/README.md +61 -11
  86. data/templates/worker/package-lock.json +2627 -0
  87. data/templates/worker/package.json +8 -5
  88. data/templates/worker/scripts/wrangler.mjs +16 -0
  89. data/templates/worker/src/index.js +83 -4
  90. data/templates/worker/test/index.test.ts +117 -5
  91. data/templates/worker/wrangler.toml +13 -4
  92. metadata +107 -6
  93. data/lib/cloudflare/email/secure_message_id.rb +0 -89
data/docs/mailboxes.md ADDED
@@ -0,0 +1,299 @@
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 in version 0.2.0. Install `gem "cloudflare-email", "~> 0.2.0"`.
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; there is no default-mailbox fallback.
38
+
39
+ For separate organization databases, follow the
40
+ [activerecord-tenanted setup](activerecord-tenanted.md) **before loading the
41
+ models**. It covers shared versus tenant migrations, tenant-specific framework
42
+ storage, early initialization and background jobs. Rails and ActiveStorage must
43
+ use the same tenant connection as the mailbox tables for atomic incoming storage.
44
+
45
+ ## Register an organization's domain
46
+
47
+ Run directory management from your authorized administration/provisioning code.
48
+ Do not expose arbitrary domain claims to customers without ownership checks.
49
+
50
+ ```ruby
51
+ inboxes = Cloudflare::Email::Mailboxes
52
+ domain = inboxes.register_domain(
53
+ domain: "acme.example.com",
54
+ tenant_key: "organization-123",
55
+ account_id: Cloudflare::Email::Credentials.account_id,
56
+ )
57
+ ```
58
+
59
+ This creates a **pending directory entry**, not DNS records. Set up the receiving
60
+ subdomain in Cloudflare and deploy the Worker. Then record your verification:
61
+
62
+ ```ruby
63
+ inboxes.activate_domain!(domain.id,
64
+ evidence: "Receiving DNS and the development Worker verified in setup ticket 42",
65
+ sending_enabled: false,
66
+ )
67
+ ```
68
+
69
+ Activation records an operator assertion; the gem does not independently verify
70
+ domain ownership from this string. Set `sending_enabled: true` only after
71
+ Cloudflare has also verified this sending domain. Incoming routing and outgoing
72
+ domain verification are separate setup steps. An address's route is activated
73
+ separately below.
74
+
75
+ Each exact domain belongs to one tenant. Domain ownership, tenant key and
76
+ Cloudflare account identity cannot be reassigned through normal model updates.
77
+ Keep tenant keys stable and resolve them to provisioned databases through your
78
+ trusted tenant adapter. Use customer/organization IDs rather than deriving a
79
+ database filename directly from an email address.
80
+
81
+ ## Create a mailbox and activate its route
82
+
83
+ Even in a single database, use an explicit tenant key and the scoped session:
84
+
85
+ ```ruby
86
+ inboxes.for_tenant("organization-123") do |account|
87
+ mailbox = account.create(
88
+ name: "Customer support",
89
+ address: "support@acme.example.com",
90
+ owner_ref: "team:42",
91
+ )
92
+ address = account.addresses(mailbox.id).first
93
+
94
+ provisioner = Cloudflare::Email::RoutingProvisioner.new(
95
+ api_token: Cloudflare::Email::Credentials.management_token,
96
+ )
97
+ account.provision_address!(address.id,
98
+ provisioner: provisioner,
99
+ worker_name: "cloudflare-email-ingress-development",
100
+ )
101
+ end
102
+ ```
103
+
104
+ `owner_ref` is your application's optional stable reference to a user, site,
105
+ team or customer. The gem stores it; it does not load that record or authorize
106
+ the person using it. Authorize the organization and mailbox in your app first.
107
+
108
+ The address stays pending if provisioning fails. The provisioner upserts an
109
+ individual address rule, so you can retry setup. Run provisioning outside a
110
+ database transaction. DNS propagation and an actual inbound test are still
111
+ needed after the API succeeds.
112
+
113
+ If you already configured a route separately, record that explicitly:
114
+
115
+ ```ruby
116
+ account.activate_address!(address.id, evidence: "Existing route verified in ticket 43")
117
+ ```
118
+
119
+ This example assumes the same `for_tenant` block and local variables as above.
120
+ The gem does not change a zone-wide catch-all while creating a mailbox.
121
+
122
+ ## Aliases, suspension and ownership
123
+
124
+ An alias is another address attached to the same mailbox:
125
+
126
+ ```ruby
127
+ inboxes.for_tenant("organization-123") do |account|
128
+ mailbox = account.mailboxes.find_by!(owner_ref: "team:42")
129
+ alias_address = account.add_address(mailbox.id, address: "help@acme.example.com")
130
+ # Provision alias_address exactly as you provisioned the first address.
131
+
132
+ account.suspend(mailbox.id) # Stops new ingress and queued mailbox sends.
133
+ account.resume(mailbox.id)
134
+ # To suspend only one address:
135
+ account.suspend_address(mailbox.id, alias_address.id)
136
+ end
137
+ ```
138
+
139
+ Suspension is enforced by Rails; it does not delete the Cloudflare route. Mail
140
+ already accepted and stored remains available for processing and inspection.
141
+ Use `activate_address!` with new evidence to reactivate an address. Domain-wide
142
+ suspension is an administrative update of `ReceivingDomain#state` to `suspended`.
143
+
144
+ Addresses use lowercase ASCII dot-atom local parts and domains. Alias addresses
145
+ are explicit: the module does not automatically strip `+tags` or invent address
146
+ fallbacks. Reserve application-specific names such as Rebulk's `tracking` in
147
+ your mailbox-management policy before creation.
148
+
149
+ ## Read incoming mail
150
+
151
+ The Worker signs the SMTP recipient. Rails verifies the whole request, resolves
152
+ the domain, enters its tenant context and stores raw mail plus a mailbox
153
+ membership in one database transaction. MIME To/Cc headers do not select the
154
+ tenant. Retries for the same raw message and exact SMTP recipient are deduplicated.
155
+
156
+ Inside your authenticated application's mailbox view/service:
157
+
158
+ ```ruby
159
+ inboxes.for_tenant("organization-123") do |account|
160
+ mailbox = account.mailboxes.find_by!(owner_ref: "team:42")
161
+ account.messages(mailbox.id).inbox.unread.order(id: :desc).limit(25).each do |entry|
162
+ inbound = account.inbound_email(mailbox.id, entry.id)
163
+ subject = inbound.mail.subject
164
+ raw_mime = inbound.raw_email.download
165
+ # Build your authorized UI response here; sanitize rendered mail content.
166
+ end
167
+ end
168
+ ```
169
+
170
+ Perform tenant record access inside the block, rather than retaining lazy
171
+ relations or framework records for use under another tenant. Numeric record IDs
172
+ can overlap in separate tenant databases.
173
+
174
+ For a message entry you have already authorized:
175
+
176
+ ```ruby
177
+ account.mark_read(mailbox.id, entry.id)
178
+ account.mark_read(mailbox.id, entry.id, read: false)
179
+ account.archive(mailbox.id, entry.id)
180
+ account.archive(mailbox.id, entry.id, archived: false)
181
+ ```
182
+
183
+ These calls also belong inside the tenant block. Raw mail with a mailbox
184
+ membership is protected from ActionMailbox's normal automatic incineration.
185
+ Archive hides it from the inbox scope but retains the content. For deliberate
186
+ permanent deletion, use `account.purge_message(mailbox.id, entry.id)`; it deletes
187
+ the raw inbound record only when no other mailbox membership references it.
188
+ ActiveStorage schedules its attachment cleanup jobs. Apply your own retention,
189
+ encryption and backup policies; archive is not a retention policy.
190
+
191
+ ## Send from the mailbox
192
+
193
+ Use a rendered ActionMailer message whose From and SMTP envelope sender are an
194
+ active address belonging to the mailbox. The domain must be enabled for sending.
195
+ `ReplyMailer` below is your application's mailer, not a generated gem class.
196
+
197
+ ```ruby
198
+ inboxes.for_tenant("organization-123") do |account|
199
+ mailbox = account.mailboxes.find_by!(owner_ref: "team:42")
200
+ operation = account.prepare(mailbox.id,
201
+ operation_key: "draft:789:revision:1",
202
+ mail: ReplyMailer.reply(draft).message,
203
+ )
204
+ account.enqueue(mailbox.id, operation_key: operation.operation_key)
205
+ end
206
+ ```
207
+
208
+ Authorize the send before preparation. If preparation is part of an application
209
+ transaction, enqueue only after its **outer commit**. The job queue must process
210
+ `mailers`. Do not also call `deliver_later` on the mailer.
211
+
212
+ The caller's operation key is namespaced by tenant and mailbox. Save the returned
213
+ operation key and reuse it when enqueueing a retry. Re-rendering an already
214
+ prepared operation may change MIME headers and raises a snapshot conflict.
215
+ The job serializes tenant, mailbox ID and operation key—not MIME or credentials.
216
+ It checks the current mailbox/address/domain state again before sending.
217
+
218
+ Inspect `account.outbound_messages(mailbox.id)` and each link's
219
+ `outbound_delivery` for the saved operation, provider ID and recipient outcomes.
220
+ Accepted or partial attempts are never automatically resent. Uncertain attempts
221
+ need [audited reconciliation](outbox.md#reconcile-uncertainty), available through
222
+ `account.reconcile(mailbox.id, operation_key: saved_key, **evidence)`.
223
+
224
+ For several Cloudflare accounts, configure a runtime client resolver **before
225
+ loading mailbox models**:
226
+
227
+ ```ruby
228
+ Cloudflare::Email::Mailboxes.configure(
229
+ directory_base: DirectoryRecord,
230
+ client_resolver: ->(tenant_key, account_id) {
231
+ Cloudflare::Email::Client.new(account_id: account_id,
232
+ api_token: YourSecretStore.email_token(tenant_key, account_id),
233
+ retry_ambiguous: false)
234
+ },
235
+ )
236
+ ```
237
+
238
+ `DirectoryRecord` and `YourSecretStore` are application examples. For one account,
239
+ the default resolver uses the existing gem credentials and verifies the account
240
+ matches. Cloudflare account IDs and your tenant keys are separate identities.
241
+
242
+ ## Delivery events and recovery
243
+
244
+ Configure the [Cloudflare Queue subscription and token](delivery-events.md).
245
+ Use shared intake for tenant mailboxes, rather than selecting a tenant from the
246
+ event's recipient or Cloudflare account:
247
+
248
+ ```ruby
249
+ Rails.application.config.x.cloudflare_email.event_domains = ["acme.example.com"]
250
+ Rails.application.config.x.cloudflare_email.event_handler = ->(event) {
251
+ Cloudflare::Email::Mailboxes::Events.record(event)
252
+ }
253
+ ```
254
+
255
+ Schedule `cloudflare:email:consume_events` to poll batches. Also schedule these
256
+ jobs regularly, starting at the beginning of each scan:
257
+
258
+ ```ruby
259
+ Cloudflare::Email::Mailboxes::ReplayEventsJob.perform_later
260
+ Cloudflare::Email::Mailboxes::RecoverJob.perform_later("organization-123")
261
+ ```
262
+
263
+ Schedule recovery for each provisioned organization in your own directory. Each
264
+ job processes a bounded page and enqueues its continuation. Recovery dispatches
265
+ prepared operations, repairs accepted-result callbacks and registers provider
266
+ correlation; it does not clear uncertain send claims. Domain suspension leaves
267
+ events retained for later projection.
268
+
269
+ Shared event intake commits before queue acknowledgement. After an accepted
270
+ send, the gem registers account/message/recipient correlation to the tenant's
271
+ outbox record. Events arriving first remain unmatched until replay. Tenant
272
+ projection commits before shared completion; a crash between those commits
273
+ causes safe redelivery to the tenant's deduplicating receipt ledger. Ambiguous
274
+ correlations remain unprojected.
275
+
276
+ The existing `config.x.cloudflare_email.outbox_delivery_handler` and
277
+ `outbox_recipient_handler` callbacks work here too. Keep them idempotent and on
278
+ the same tenant connection when atomicity is required. External effects cannot
279
+ be rolled back. Provider acceptance, shared storage and tenant storage are not
280
+ one transaction; this is not exactly-once delivery.
281
+
282
+ ## Isolation and rollout
283
+
284
+ The scoped session API checks tenant/mailbox ownership and sender addresses.
285
+ It does not authenticate Rails users. Protect admin directory APIs, choose the
286
+ tenant through your access resolver, and authorize every mailbox operation.
287
+ Direct/unscoped ActiveRecord access is an application-level privileged interface,
288
+ particularly in shared-database mode; it is not a row-level authorization system.
289
+
290
+ For database tenancy, configure all framework storage on the tenant connection
291
+ and exclude ingress from hostname/session tenant selection. New framework jobs
292
+ capture tenant context before serialization and restore it before GlobalID
293
+ lookup. Drain existing ActionMailbox/ActiveStorage queues before enabling this
294
+ mode: previously serialized jobs without the new metadata are rejected.
295
+
296
+ The gem does not create organizations/users, verify customer domain ownership,
297
+ provide IMAP/POP, or supply an inbox UI. The original Rebulk document-processing
298
+ Worker contract and sender-review policy still need a separate application
299
+ migration; installing this module does not replace that live pipeline.
data/docs/outbox.md ADDED
@@ -0,0 +1,175 @@
1
+ # Durable outbound email for Rails
2
+
3
+ The optional Active Record layer supplies immutable send snapshots, single-attempt delivery claims, recipient outcomes, delivery-event receipts, and audited reconciliation. Use it when background retries, process crashes, or missing provider responses must not silently resend an email. The plain Ruby client and ordinary ActionMailer delivery method remain available without these tables.
4
+
5
+ This is delivery infrastructure. Your application still authorizes sending, decides what belongs in a conversation, reviews drafts, and controls operator access.
6
+
7
+ ## Install
8
+
9
+ After configuring the gem's sending credentials, generate both sets of tables:
10
+
11
+ ```sh
12
+ bin/rails generate cloudflare:email:tracking
13
+ bin/rails generate cloudflare:email:outbox
14
+ bin/rails db:migrate
15
+ ```
16
+
17
+ Skip a generator whose migration is already installed. The outbox initializer requires `cloudflare/email/active_record`, which loads the models, `Outbox`, `prepare_mail`, and `DeliveryEvents`, and also loads both jobs. For manual setup without the generator, require:
18
+
19
+ ```ruby
20
+ require "cloudflare/email/active_record"
21
+ require "cloudflare/email/send_job"
22
+ require "cloudflare/email/replay_events_job"
23
+ ```
24
+
25
+ Use a durable ActiveJob backend and a recurring scheduler suitable for your application. The gem does not install a job backend or Cloudflare Queue subscription. Configure delivery events and the separate queue token as described in [delivery-events.md](delivery-events.md).
26
+
27
+ ## Prepare once, dispatch after commit
28
+
29
+ Render the mail, snapshot it together with your application update, and enqueue only after the outer database transaction commits:
30
+
31
+ ```ruby
32
+ outbox = Cloudflare::Email::ActiveRecord::Outbox
33
+ account_id = Cloudflare::Email::Credentials.account_id
34
+
35
+ # Inside an application service that runs outside an existing transaction:
36
+ delivery = nil
37
+ Draft.transaction do
38
+ draft.lock!
39
+ # Apply the application's owner/approval checks here.
40
+ mail = ReplyMailer.reply(draft).message
41
+ delivery = outbox.prepare_mail(
42
+ account_id: account_id,
43
+ operation_key: "draft:#{draft.id}:revision:#{draft.revision}",
44
+ mail: mail
45
+ )
46
+ draft.update!(outbound_operation_key: delivery.operation_key)
47
+ end
48
+
49
+ Cloudflare::Email::SendJob.perform_later(account_id, delivery.operation_key)
50
+ ```
51
+
52
+ `Draft`, `revision`, and `outbound_operation_key` above are application examples, not generated gem models. For nested application transactions, use an actual after-commit hook; returning from an inner transaction does not necessarily commit the outer one.
53
+
54
+ `prepare_mail` accepts a rendered `Mail::Message` or an ActionMailer message delivery. It reads the SMTP envelope and encodes the MIME once, preserving attachments and To/Cc/Bcc delivery. It rejects `perform_deliveries == false`. Mailer rendering callbacks run during rendering; delivery callbacks are bypassed when sending the saved snapshot. Put required delivery authorization before preparation or in your application service.
55
+
56
+ For an existing MIME source:
57
+
58
+ ```ruby
59
+ delivery = outbox.prepare(
60
+ account_id: account_id,
61
+ operation_key: "invoice:123:notification:1",
62
+ from: "billing@example.com",
63
+ recipients: ["customer@example.net"],
64
+ mime_message: raw_mime
65
+ )
66
+ ```
67
+
68
+ The account plus operation key identifies one immutable attempt. Preparing the same envelope and MIME returns the existing operation. Changing its snapshot raises `Outbox::SnapshotConflict`. Recipient order is preserved, duplicate addresses are removed, domains are lowercased, and local parts retain their case. MIME is stored as binary data. Re-rendering a mail can change generated headers, so job retries use the saved operation identity instead of calling the mailer again.
69
+
70
+ `Outbox.deliver(delivery, client: optional_client)` sends synchronously and refuses an ambient database transaction. Its `prepared → sending` claim commits before network I/O. A supplied client must match the account and expose `retry_ambiguous == false`. The standard `SendJob` uses the configured sending account/token and forces ambiguous retries off; applications managing several Cloudflare accounts can use their own account-aware job and pass the appropriate client.
71
+
72
+ ## States and retries
73
+
74
+ | Operation state | Meaning and permitted action |
75
+ | --- | --- |
76
+ | `prepared` | Snapshot saved; safe to dispatch. |
77
+ | `sending` | A process claimed the attempt. Another job cannot send it. A crashed process can leave this state indefinitely. |
78
+ | `accepted` | All recipients have acceptance evidence. Repeated delivery returns the record without a network request. |
79
+ | `partial` | Some recipients have acceptance evidence, while others were rejected or remain unknown. Repeated delivery never resends the batch. |
80
+ | `rejected` | Available outcomes show rejection. The operation is terminal; correcting and sending again requires a new operation key. |
81
+ | `unknown` | Acceptance cannot be established safely. Investigate and reconcile before deciding on another attempt. |
82
+ | `confirmed_not_sent` | An operator confirmed the whole operation was not sent. Old jobs stay blocked; a deliberate new attempt needs a new key. |
83
+
84
+ Each `outbound_recipients` row stores `acceptance_state` separately from lifecycle `state`. A later complaint or bounce must not erase evidence that the original send was accepted. Lifecycle `occurred_at` stays nil for immediate send results and operator reconciliation: local clock time is not a provider event timestamp.
85
+
86
+ Malformed success responses, conflicting provider message IDs, timeouts, ambiguous transport errors, and failures saving an accepted response remain uncertain. Explicit supported provider rejection responses can produce `rejected`. Errors are re-raised after recording the conservative outcome. A database outage may leave the already committed `sending` claim instead of `unknown`; both block resend.
87
+
88
+ This is not exactly-once delivery. Provider acceptance and a database commit cannot be one transaction. Client pre-send/rate-limit retry behavior remains available, but automatic ambiguous retries are prohibited by the outbox.
89
+
90
+ ## Recover dispatch and application projections
91
+
92
+ An application can commit its snapshot and crash before enqueueing. Schedule a scan of `prepared` operations and enqueue their saved identities. Multiple scanners/jobs are safe because only one can claim an operation. Do not reset `sending` operations merely because they are old.
93
+
94
+ Operational commands:
95
+
96
+ ```sh
97
+ bin/rails cloudflare:email:pending_deliveries
98
+ OPERATION_KEY='invoice:123:notification:1' bin/rails cloudflare:email:deliver
99
+ bin/rails cloudflare:email:replay_events
100
+ ```
101
+
102
+ The listing includes prepared, sending, unknown, and partial operations for the configured account; it does not automatically dispatch them. `deliver` uses `SendJob.perform_now` with the saved snapshot.
103
+
104
+ Optional Rails callbacks reduce application glue:
105
+
106
+ ```ruby
107
+ settings = Rails.application.config.x.cloudflare_email
108
+ settings.outbox_delivery_handler = ->(delivery) {
109
+ # Idempotently project the saved operation into application records.
110
+ }
111
+ settings.outbox_recipient_handler = ->(delivery, recipient) {
112
+ # Project a lifecycle change into application records.
113
+ }
114
+ ```
115
+
116
+ The delivery callback runs under a delivery row lock after the result has been saved. If application projection fails after acceptance, retrying the job repairs the projection without another provider send. The recipient callback runs inside receipt processing's transaction. Keep callbacks idempotent and use the same database connection for atomic application writes. External side effects cannot be rolled back; schedule them through a durable application mechanism.
117
+
118
+ ## Consume and replay events
119
+
120
+ Wire the supplied projector into the event consumer:
121
+
122
+ ```ruby
123
+ settings = Rails.application.config.x.cloudflare_email
124
+ settings.event_handler = ->(event) {
125
+ Cloudflare::Email::ActiveRecord::DeliveryEvents.record(event) do |delivery, recipient|
126
+ settings.outbox_recipient_handler&.call(delivery, recipient)
127
+ end
128
+ }
129
+ ```
130
+
131
+ `DeliveryEvents.record` commits the receipt before projecting it. Callback failures roll back projection and leave the saved receipt available for retry. It matches the account, normalized provider Message-ID, and recipient; ambiguous matches remain unmatched. Known events apply with lifecycle ordering and terminal-state guards. Event identity provides durable deduplication. Unknown future event statuses remain unmatched for later support.
132
+
133
+ An event may arrive before the send result is saved. `SendJob` replays matching receipts after its delivery callback, and a recurring `ReplayEventsJob.perform_later(account_id)` covers later recovery. Direct callers can use:
134
+
135
+ ```ruby
136
+ Cloudflare::Email::ActiveRecord::DeliveryEvents.replay(
137
+ account_id: account_id, message_id: delivery.provider_message_id
138
+ ) do |operation, recipient|
139
+ # Optional application projection.
140
+ end
141
+ ```
142
+
143
+ Message-ID normalization removes surrounding angle brackets; it is correlation, not authorization. No provider ID means there is no safe automatic provider-ID match.
144
+
145
+ ## Reconcile uncertainty
146
+
147
+ Restrict this operation to an authorized application operator. The gem records the supplied actor string; it does not authenticate that actor or verify external evidence.
148
+
149
+ ```ruby
150
+ outbox.reconcile(
151
+ delivery,
152
+ outcome: :accepted,
153
+ actor: "User:42",
154
+ reason: "Provider support confirmed acceptance",
155
+ evidence: "Support ticket 123 and retained provider response",
156
+ provider_message_id: "provider-message-id",
157
+ recipients: ["customer@example.net"]
158
+ ) do |operation|
159
+ # Optional application update, atomic with the audit and state change.
160
+ end
161
+ ```
162
+
163
+ `outcome: :accepted` requires a valid provider Message-ID. `outcome: :not_sent` requires evidence of nonacceptance. Actor, reason, and evidence must be nonempty strings. Reconciliation can run inside an application transaction; callbacks and audit writes roll back together if it fails. Existing audit records are read-only through the model.
164
+
165
+ For a partial operation, only unresolved recipients can be reconciled. Omit `recipients` to select the unresolved subset. Known outcomes and lifecycle state are preserved; later decisions can resolve another unknown subset. A conflicting provider ID is refused. A batch with all recipients confirmed not sent becomes `confirmed_not_sent`; a mixed batch remains partial or otherwise reflects the remaining acceptance evidence. Never retry a complete partial batch to reach one unresolved recipient.
166
+
167
+ For a `sending` operation, first stop the original sender and verify it cannot resume. Reconciliation additionally requires `confirm_sender_stopped: true` and a claim at least 15 minutes old. Elapsed time alone is insufficient. There is no automatic lease expiry or timeout-based resend.
168
+
169
+ ## Data ownership and migration
170
+
171
+ The gem owns its operation, recipient, receipt, and reconciliation infrastructure. The inbox owns mailbox permissions, conversations, approval policy, AI drafts, and UI. Link product records to operations by a stable key or application foreign key instead of copying the ledger algorithm.
172
+
173
+ The MIME snapshot contains email bodies, addresses, and attachments. Apply your application's database access, encryption, backup, and retention policies. Removing ledgers or replaying old backups can discard uncertainty and deduplication evidence; quiesce sending and reconcile provider activity during recovery.
174
+
175
+ Importing historical attempts is application-specific. If the old application did not save exact MIME, a reconstructed snapshot is historical metadata, not proof of the original bytes. Preserve its known/unknown state and do not turn imported accepted or uncertain attempts into dispatchable `prepared` operations. The generated outbox and receipt migrations refuse rollback to preserve delivery and deduplication evidence; use a forward fix or a reconciled backup.
@@ -0,0 +1,149 @@
1
+ # Tenant-aware mailbox layer — implementation proposal
2
+
3
+ Status: original design record. The reusable mailbox module and tenant adapter
4
+ are implemented on the feature branch; see [mailboxes](mailboxes.md) and
5
+ [tenant setup](activerecord-tenanted.md) for the actual API. Rebulk application
6
+ migration and live customer rollout remain separate work. Based on the gem's merged 0.2
7
+ code and Rebulk/rebulk-system main at
8
+ `17ffafccb69968dae022acdbb4620b84c65bedfb`.
9
+
10
+ ## Product goal
11
+
12
+ Let a Rails application create, suspend and manage named mailboxes for its
13
+ organizations, sites or users. Support organization subdomains such as
14
+ `houston@venezia.rebulk.com`. Reuse the gem's transport, outbox and receipts;
15
+ do not create another delivery ledger. SQLite remains the default integration
16
+ target, including separate SQLite databases with `activerecord-tenanted`.
17
+
18
+ ## Rebulk already has the foundations
19
+
20
+ - `Organization` is shared and carries `email_subdomain`.
21
+ - `TenantRecord` uses `activerecord-tenanted` and requires a tenant context.
22
+ - Sites have per-organization email local parts. `tracking@<org>.rebulk.com`
23
+ is an existing reserved organization-level destination.
24
+ - `Customer` is a buyer within an organization, not the tenant root. A customer
25
+ mailbox can belong to that record without creating another tenant database.
26
+ - ActiveStorage is tenant-specific. Mail must resolve its tenant before blob
27
+ access, persistence or job deserialization that needs tenant records.
28
+ - `InboundEmail` records document-processing outcomes inside the tenant.
29
+ `CommunicationMessage` is a shared, encrypted cross-channel operational view.
30
+ Preserve these product roles instead of replacing them with duplicate models.
31
+ - Existing ingest has its own Worker contract, sender authentication and
32
+ authorization, raw archive and document processing. It is not currently the
33
+ gem's ActionMailbox pipeline. Its policy cannot be discarded during migration.
34
+ - `OrganizationEmailDomain` supports sign-in auto-join. Receiving-domain
35
+ ownership must be separate; creating a receiving domain must never grant users
36
+ membership or change login policy.
37
+
38
+ ## Database and routing boundary
39
+
40
+ Keep a minimal shared receiving-domain directory: exact canonical domain,
41
+ immutable tenant identity, tenant connection key, lifecycle state and provisioning
42
+ evidence. Enforce domain ownership with a database uniqueness constraint. Never
43
+ construct a SQLite path from an arbitrary recipient string or request parameter.
44
+
45
+ Keep mailbox definitions, address aliases, message memberships, outbox records
46
+ and tenant receipts in the tenant database. Support an application-selected
47
+ ActiveRecord base class so every related gem model uses the intended connection.
48
+ Merely wrapping today's hardcoded `ActiveRecord::Base` models in `with_tenant`
49
+ does not establish that connection contract.
50
+
51
+ The ingress sequence must be:
52
+
53
+ 1. Bound the body and verify the complete Worker signature and envelope.
54
+ 2. Resolve the exact signed recipient domain in the shared active directory.
55
+ 3. Enter the mapped tenant context, without creating a tenant implicitly.
56
+ 4. Resolve the registered active mailbox/alias inside that tenant.
57
+ 5. Persist raw mail and mailbox membership idempotently, then schedule processing.
58
+ 6. Restore tenant context even when processing raises.
59
+
60
+ No MIME To/Cc, browser session fallback, sender domain or guessed organization
61
+ slug may select the receiving tenant. Unknown/suspended domains and addresses
62
+ must have explicit rejection or restricted quarantine outcomes, with no fallback
63
+ to an arbitrary tenant/mailbox. Directory changes and mailbox suspension must
64
+ also be checked when deferred work resumes; never silently reassign old mail.
65
+
66
+ ## Optional mailbox module
67
+
68
+ Add explicit opt-in loading and a generator. The initial contract should cover:
69
+
70
+ - Tenant/domain registration and verified provisioning state.
71
+ - Mailbox creation, aliases, suspension and lookup by registered address.
72
+ - Application ownership links for user/site/customer mailboxes, checked within
73
+ the resolved tenant; authenticated actor authorization stays in the host app.
74
+ - Incoming message membership and deduplication without another copy of MIME.
75
+ - Outgoing preparation through an active mailbox with an allowed From address,
76
+ linked to the existing outbox. A customer cannot select another tenant's
77
+ sender merely because both share the same Cloudflare account token.
78
+ - An explicit tenant context adapter and connection-class configuration;
79
+ `activerecord-tenanted` is an optional integration, not a plain-Ruby dependency.
80
+
81
+ Do not claim a finished inbox UI, IMAP/POP service, thread model, AI workflow,
82
+ search engine or membership system. Rebulk supplies those product decisions.
83
+ Document local-part case policy, reserved names and alias behavior before schema
84
+ implementation; normalization must agree across directory, mailbox and send APIs.
85
+
86
+ ## Jobs, events and cross-database commits
87
+
88
+ Jobs carry stable tenant and operation/mailbox identities. Establish tenant
89
+ context before loading records. Never rely on a previous request's `Current`
90
+ state, a naked numeric record ID or a Cloudflare account ID as a tenant key.
91
+ Cloudflare account identity and application tenant identity are different.
92
+
93
+ Queue events arrive without a Rails tenant context and may precede a saved send
94
+ response. Use a shared durable receipt intake and a provider correlation
95
+ directory populated by tenant outbox results. Retain unmatched events and retry
96
+ when correlation becomes available; do not infer tenant ownership solely from
97
+ the recipient's external domain. Ambiguous mappings remain unprojected.
98
+
99
+ Delivery from shared intake to tenant projection is at least once. A tenant
100
+ receipt deduplicates it. Mark shared processing complete only after tenant commit;
101
+ crashes between commits cause safe replay. Writes to the shared directory,
102
+ tenant SQLite and job queue are not one transaction. Recovery must cover each
103
+ gap without allowing another send or prematurely acknowledging an event.
104
+
105
+ ## Customer subdomains and Cloudflare
106
+
107
+ An organization may use a Rebulk-owned subdomain or, later, an explicitly verified
108
+ customer-owned domain. Track receive readiness and send-domain verification
109
+ separately. A Rails database record or wildcard DNS entry alone does not enable
110
+ Cloudflare email routing or sending.
111
+
112
+ Prefer explicit address rules for the first supported provisioner. Reconcile
113
+ desired state with Cloudflare and retain failures for retry rather than marking
114
+ a mailbox active before its route is ready. Multiple tenants may share a Worker
115
+ and ingress secret within one application/environment; tenant routing stays in
116
+ Rails. Separate Cloudflare accounts require account-aware credentials/jobs.
117
+
118
+ The current catch-all task operates at zone scope. Do not alter the Rebulk apex
119
+ catch-all or promise automatic subdomain catch-alls as part of mailbox creation.
120
+ Verify provider support and limits for the exact subdomain topology before a
121
+ live rollout. Custom domains need ownership verification and safe offboarding.
122
+
123
+ ## Implementation slices and acceptance checks
124
+
125
+ 1. **Tenant foundation:** configurable model connections, tenant-aware job
126
+ wrappers and migrations in the tenant migration path. Test two real SQLite
127
+ tenant databases with overlapping IDs, missing context, retries and cleanup.
128
+ 2. **Mailbox registry:** shared domain mapping plus tenant mailboxes/aliases,
129
+ lifecycle and ownership APIs. Test exact matching, uniqueness races,
130
+ suspension, reserved addresses and isolation for lookup and sending.
131
+ 3. **Tenant ingress:** authenticated envelope resolution before ActionMailbox
132
+ storage. Test Bcc routing, raw bytes, duplicate delivery, unknown destinations,
133
+ tenant-specific blob access and processing jobs after process restart.
134
+ 4. **Outbound/event integration:** reuse saved MIME/claims, enforce mailbox sender
135
+ identity, add durable shared-to-tenant event dispatch and repair scans. Test
136
+ event-before-response, duplicate receipts and crashes at each commit boundary.
137
+ 5. **Rebulk adoption:** map existing org/site addresses, preserve tracking and
138
+ sender-review policies, link existing communication/ingest records, and add
139
+ mailbox management through Rebulk's access resolver. Start behind a feature
140
+ flag on an isolated development address; keep current production routes intact.
141
+ 6. **Provisioning and live verification:** test domain/route setup with bounded
142
+ retries, then a real send/reply/attachment/event flow for two test tenants.
143
+ Verify the deployed revision and preserve a dated report before customer rollout.
144
+
145
+ Required regressions include existing plain-Ruby, single-database SQLite and
146
+ PostgreSQL behavior. Do not call the integration complete merely because a
147
+ single-tenant send works. Existing Rebulk sender-authentication metadata must
148
+ remain authenticated through the new Worker contract before its old ingress
149
+ can be retired; the gem's v2 envelope alone does not supply that policy evidence.
@@ -0,0 +1,27 @@
1
+ # Thread correlation
2
+
3
+ Store the provider's returned `message_id` with the intended recipient and your
4
+ application conversation. The ActionMailer delivery method updates the Mail
5
+ object's message ID when Cloudflare returns one. Plain Ruby callers can read
6
+ `response.message_id`.
7
+
8
+ Normalize stored IDs and parsed `In-Reply-To` / `References` values with
9
+ `Cloudflare::Email::MessageId.normalize(value)`. This removes surrounding whitespace
10
+ and one complete angle-bracket pair; it preserves case. Parse multiple-ID headers
11
+ with your mail parser first. Match `In-Reply-To` first, then `References` from most
12
+ recent to oldest, scoped to the authenticated recipient's mailbox.
13
+
14
+ Outgoing replies should set `In-Reply-To` and `References` using the parent
15
+ message's ID. Keep unmatched replies available for application handling when an
16
+ ID is missing or unknown. Send acceptance and saving the returned ID are not one
17
+ atomic transaction.
18
+
19
+ The old `SecureMessageId` helper was removed before the 0.2 release. Cloudflare
20
+ controls Message-ID and our [live test](verification/2026-09-10-live.md) confirmed
21
+ replacement of custom IDs for both raw MIME and ActionMailer sends. The provider's
22
+ returned IDs matched received headers and supported reply correlation.
23
+
24
+ Email headers locate a conversation; they do not authorize access to it. Scope
25
+ lookups by the trusted SMTP recipient obtained through `Envelope.for(inbound)`.
26
+ The SMTP envelope sender and MIME From header do not authenticate a person.
27
+ Sensitive actions require your application's authentication and approval policy.