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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -1
- data/README.md +265 -388
- data/SECURITY.md +51 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +62 -6
- data/docs/activerecord-tenanted.md +135 -0
- data/docs/architecture.md +95 -0
- data/docs/delivery-events.md +177 -0
- data/docs/features.md +107 -0
- data/docs/getting-started.md +298 -0
- data/docs/mailboxes.md +299 -0
- data/docs/outbox.md +175 -0
- data/docs/tenant-mailboxes-plan.md +149 -0
- data/docs/thread-correlation.md +27 -0
- data/docs/troubleshooting.md +103 -0
- data/docs/upgrading-0.2.md +96 -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-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/examples/plain_ruby.rb +24 -0
- data/lib/cloudflare/email/active_record/base.rb +48 -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.rb +5 -0
- data/lib/cloudflare/email/client.rb +51 -11
- 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 +3 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +3 -0
- 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 +28 -0
- data/lib/cloudflare/email/envelope.rb +58 -0
- data/lib/cloudflare/email/event_consumer.rb +86 -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 +148 -0
- data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
- data/lib/cloudflare/email/mailboxes/service.rb +288 -0
- data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
- data/lib/cloudflare/email/mailboxes.rb +11 -0
- data/lib/cloudflare/email/message_id.rb +12 -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_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 +30 -8
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +5 -3
- data/lib/cloudflare-email.rb +4 -1
- data/lib/generators/cloudflare/email/install_generator.rb +48 -33
- data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +54 -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/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 +41 -4
- data/templates/worker/README.md +61 -11
- 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 +83 -4
- data/templates/worker/test/index.test.ts +117 -5
- data/templates/worker/wrangler.toml +13 -4
- metadata +107 -6
- 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.
|