cloudflare-email 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +149 -1
- data/README.md +287 -385
- data/SECURITY.md +51 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +33 -21
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
- data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
- data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
- data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
- data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
- data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
- data/docs/activerecord-tenanted.md +181 -0
- data/docs/architecture.md +105 -0
- data/docs/custom-ingress.md +207 -0
- data/docs/delivery-events.md +186 -0
- data/docs/features.md +138 -0
- data/docs/getting-started.md +308 -0
- data/docs/mailboxes.md +363 -0
- data/docs/management-engine.md +190 -0
- data/docs/outbox.md +175 -0
- data/docs/routing-deliveries.md +94 -0
- data/docs/routing-diagnostics.md +68 -0
- data/docs/tenant-mailboxes-plan.md +149 -0
- data/docs/thread-correlation.md +27 -0
- data/docs/troubleshooting.md +104 -0
- data/docs/upgrading-0.2.md +101 -0
- data/docs/upgrading-0.3.md +55 -0
- data/docs/verification/2026-09-10-followup.md +49 -0
- data/docs/verification/2026-09-10-install-upgrade.md +86 -0
- data/docs/verification/2026-09-10-live.md +102 -0
- data/docs/verification/2026-09-10.md +175 -0
- data/docs/verification/2026-09-11-custom-ingress.md +49 -0
- data/docs/verification/2026-09-11-extraction.md +32 -0
- data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
- data/docs/verification/2026-09-11-security.md +76 -0
- data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
- data/docs/verification/2026-09-12-destination-context.md +46 -0
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
- data/examples/plain_ruby.rb +24 -0
- data/lib/cloudflare/email/active_record/base.rb +60 -0
- data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
- data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
- data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
- data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
- data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
- data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
- data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
- data/lib/cloudflare/email/active_record/outbox.rb +219 -0
- data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
- data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
- data/lib/cloudflare/email/active_record.rb +5 -0
- data/lib/cloudflare/email/check_route_task.rb +21 -0
- data/lib/cloudflare/email/client.rb +93 -18
- data/lib/cloudflare/email/consume_events_task.rb +22 -0
- data/lib/cloudflare/email/delivery_event.rb +70 -0
- data/lib/cloudflare/email/delivery_method.rb +5 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +6 -3
- data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
- data/lib/cloudflare/email/dev_tunnel.rb +31 -5
- data/lib/cloudflare/email/doctor.rb +16 -38
- data/lib/cloudflare/email/endpoint.rb +24 -0
- data/lib/cloudflare/email/engine.rb +33 -0
- data/lib/cloudflare/email/envelope.rb +58 -0
- data/lib/cloudflare/email/event_consumer.rb +95 -0
- data/lib/cloudflare/email/ingress.rb +115 -0
- data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
- data/lib/cloudflare/email/mailboxes/events.rb +112 -0
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
- data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
- data/lib/cloudflare/email/mailboxes/models.rb +165 -0
- data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
- data/lib/cloudflare/email/mailboxes/service.rb +372 -0
- data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
- data/lib/cloudflare/email/mailboxes.rb +11 -0
- data/lib/cloudflare/email/management/adapter.rb +31 -0
- data/lib/cloudflare/email/management/configuration.rb +17 -0
- data/lib/cloudflare/email/management/engine.rb +15 -0
- data/lib/cloudflare/email/management/management.css +68 -0
- data/lib/cloudflare/email/management/routes.rb +14 -0
- data/lib/cloudflare/email/management.rb +7 -0
- data/lib/cloudflare/email/message_id.rb +12 -0
- data/lib/cloudflare/email/provider_metadata.rb +77 -0
- data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
- data/lib/cloudflare/email/provision_route_task.rb +2 -1
- data/lib/cloudflare/email/replay_events_job.rb +18 -0
- data/lib/cloudflare/email/response.rb +21 -3
- data/lib/cloudflare/email/routing_analytics.rb +196 -0
- data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
- data/lib/cloudflare/email/routing_provisioner.rb +71 -45
- data/lib/cloudflare/email/send_job.rb +41 -0
- data/lib/cloudflare/email/send_test.rb +5 -29
- data/lib/cloudflare/email/signing.rb +2 -2
- data/lib/cloudflare/email/tenancy.rb +78 -0
- data/lib/cloudflare/email/tenant_job_context.rb +90 -0
- data/lib/cloudflare/email/verification.rb +38 -8
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +41 -8
- data/lib/cloudflare-email.rb +4 -1
- data/lib/generators/cloudflare/email/install_generator.rb +53 -37
- data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
- data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
- data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +58 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
- data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
- data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
- data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
- data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
- data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
- data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
- data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
- data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
- data/lib/tasks/cloudflare_email.rake +42 -4
- data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
- data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
- data/templates/deploy-to-cloudflare/.gitignore +6 -0
- data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
- data/templates/deploy-to-cloudflare/README.md +84 -0
- data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
- data/templates/deploy-to-cloudflare/package.json +36 -0
- data/templates/deploy-to-cloudflare/src/index.js +445 -0
- data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
- data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
- data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
- data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
- data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
- data/templates/worker/README.md +231 -16
- data/templates/worker/docs/durable-inbound.md +149 -0
- data/templates/worker/package-lock.json +2627 -0
- data/templates/worker/package.json +8 -5
- data/templates/worker/scripts/wrangler.mjs +16 -0
- data/templates/worker/src/index.js +397 -25
- data/templates/worker/test/durable.test.ts +193 -0
- data/templates/worker/test/index.test.ts +183 -13
- data/templates/worker/test/relay.test.ts +226 -0
- data/templates/worker/wrangler.toml +63 -4
- metadata +154 -7
- data/lib/cloudflare/email/secure_message_id.rb +0 -89
data/docs/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,94 @@
|
|
|
1
|
+
# Confirm delivery to verified Routing destinations
|
|
2
|
+
|
|
3
|
+
Cloudflare can deliver mail to a verified Email Routing destination through Routing without producing an Email Sending delivery callback. You can keep using the recipient's normal address. Do not remove its verified destination or rewrite the recipient to obtain a callback.
|
|
4
|
+
|
|
5
|
+
The optional Routing Analytics integration confirms **positive delivery to the receiving mail server** from authenticated Cloudflare analytics. It does not prove inbox placement or that a person read the message. Missing analytics leave the outcome unresolved; they never authorize a resend or provider fallback.
|
|
6
|
+
|
|
7
|
+
## Enable it
|
|
8
|
+
|
|
9
|
+
The plain Ruby client needs no Rails or Active Record dependency:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require "cloudflare/email/routing_analytics"
|
|
13
|
+
|
|
14
|
+
client = Cloudflare::Email::RoutingAnalytics::Client.new(
|
|
15
|
+
account_id: ENV.fetch("CLOUDFLARE_ACCOUNT_ID"),
|
|
16
|
+
zone_id: ENV.fetch("CLOUDFLARE_ANALYTICS_ZONE_ID"),
|
|
17
|
+
api_token: ENV.fetch("CLOUDFLARE_ANALYTICS_TOKEN")
|
|
18
|
+
)
|
|
19
|
+
client.verify_account!
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Use a separate token with **Zone Analytics Read and Zone Read**, restricted to the sending zone. The client verifies that the zone belongs to the supplied account before querying analytics. Keep sending and queue credentials separate. A token is never written into a receipt.
|
|
23
|
+
|
|
24
|
+
For durable Rails tracking, install the outbox first, then generate the additional receipt table:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
bin/rails generate cloudflare:email:outbox # only if not already installed
|
|
28
|
+
bin/rails generate cloudflare:email:routing_tracking
|
|
29
|
+
bin/rails db:migrate
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Receipts encrypt their JSON evidence using Rails Active Record Encryption. Configure the application's `active_record.encryption.primary_key`, `deterministic_key`, and `key_derivation_salt` before recording receipts (Rails' `db:encryption:init` task generates keys). Keep keys in credentials or protected environment configuration and back them up separately. There is no plaintext fallback enabled by this integration. Receipt metadata includes account and operation identifiers; protect the database and backups as usual.
|
|
33
|
+
|
|
34
|
+
The generated initializer explicitly requires `cloudflare/email/active_record/routing_deliveries`. Installing the gem alone enables no scheduler, network request, tracking table, or tenancy behavior.
|
|
35
|
+
|
|
36
|
+
## Collect and apply evidence
|
|
37
|
+
|
|
38
|
+
Run network requests outside database transactions. The durable outbox must have retained the accepted operation's SMTP envelope and provider Message-ID:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
tracking = Cloudflare::Email::ActiveRecord::RoutingDeliveries
|
|
42
|
+
|
|
43
|
+
client.delivery_evidence(
|
|
44
|
+
message_id: delivery.provider_message_id,
|
|
45
|
+
since: delivery.request_started_at,
|
|
46
|
+
until_time: Time.now.utc
|
|
47
|
+
).each do |evidence|
|
|
48
|
+
receipt = tracking.record(delivery: delivery, evidence: evidence)
|
|
49
|
+
tracking.apply(receipt) do |saved_delivery, recipient|
|
|
50
|
+
# Optional business projection using this same database connection.
|
|
51
|
+
# Read recipient.state; do not send mail or call external services here.
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`record` commits the original evidence before projection. Repeating it returns the same receipt. A changed authentic event under the same identity, or association with another operation, raises an error. Query windows and other rows returned alongside that event may differ between polls without creating duplicate receipts. Client payloads retain the complete original response envelope, provider row, authenticated account/zone, source, and query window and are immutable through ordinary model updates.
|
|
57
|
+
|
|
58
|
+
`apply` returns `:applied`, including successful replay. It updates only a nonterminal recipient and marks the receipt applied in the same transaction. It preserves the accepted operation, envelope snapshot, and every existing terminal webhook fact, including a bounce. Thus `:applied` means evidence was processed; inspect the recipient's actual state. A later genuine Sending complaint continues to update the recipient through the existing Sending event API.
|
|
59
|
+
|
|
60
|
+
Callbacks run only when the recipient changes. Same-database callback errors roll back the recipient, business writes, and receipt completion together; the original pending receipt remains available. A callback must not send email or perform external effects.
|
|
61
|
+
|
|
62
|
+
This is not a webhook endpoint: accept evidence only from the authenticated client or your protected durable storage. Constructing an `Evidence` object validates its shape and correlation, not its provenance. Never deserialize arbitrary incoming request bodies into trusted analytics evidence.
|
|
63
|
+
|
|
64
|
+
## Retry projection fairly
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
result = tracking.replay(account_id: account_id, limit: 100, after_id: saved_cursor)
|
|
68
|
+
result.errors.each do |failure|
|
|
69
|
+
ErrorReporter.report(failure[:error], receipt_id: failure[:receipt_id])
|
|
70
|
+
end
|
|
71
|
+
save_cursor(result.finished ? 0 : result.after_id)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The batch is bounded to 1–1,000 receipts. Failed receipts advance the cursor, allowing later receipts to proceed. After a complete pass, reset the cursor and revisit failures. Errors remain visible in the return value; your application must report them. Supply the same optional projection callback when replaying. Give fresh analytics discovery a separate scheduling opportunity so repeated projection failures cannot starve new evidence. No database transaction is interrupted by an HTTP or polling timeout.
|
|
75
|
+
|
|
76
|
+
The client uses a five-second total network deadline, five-second socket timeouts, a 1,000,000-byte streamed response cap and no implicit HTTP retry. A failed request is visible to your scheduler. Queries returning 100 rows are rejected as potentially truncated; use a narrower historical window and retain existing unresolved records. Plan availability, analytics retention and sampling can limit what Cloudflare can prove.
|
|
77
|
+
|
|
78
|
+
## Deliberately conservative matching
|
|
79
|
+
|
|
80
|
+
The initial API requires exactly one accepted saved SMTP-envelope recipient, exactly one matching accepted operation in the account, matching sender and normalized provider Message-ID, and a nonempty provider session ID. The event must be `newEmail`, `delivered`, final, unsampled and not an NDR. Its timestamp must fall inside the requested historical window and at or after the saved request start, rounded down to Cloudflare's second precision.
|
|
81
|
+
|
|
82
|
+
MIME `To` and analytics `to` are never used as envelope identity. Multi-recipient operations, sampled rows, forwarding rows and missing events do not become guessed delivery or failure. Malformed, truncated, conflicting or wrongly scoped qualifying evidence raises. Terminal delivery can override an equal or later nonterminal observation; existing terminal facts stay intact.
|
|
83
|
+
|
|
84
|
+
## Tenant and cross-database applications
|
|
85
|
+
|
|
86
|
+
Multi-tenancy remains off by default. If enabled, use the existing `Tenancy.with` or `Mailboxes.for_tenant` context for every outbox and Routing receipt operation, and install the receipt migration in each tenant database. Record instances cannot be moved between tenant contexts. A separate analytics account does not select or authorize a tenant.
|
|
87
|
+
|
|
88
|
+
The gem checks provider-ID uniqueness inside the selected database. If one Cloudflare account spans several tenant databases, the host must also enforce account-wide provider-ID uniqueness in its shared directory or ledger before calling `record`. Tenant-local lookup alone cannot establish that a matching operation is unique across independent databases.
|
|
89
|
+
|
|
90
|
+
An application may reserve an explicit physical storage key for system mail, such as `_system_outbound`, using its existing tenant provisioning and backup process. That storage key is not an organization or authorization identity. Keep nil tenant context fail-closed; do not silently map it to shared storage. Reserve and validate the key in the host application so a customer cannot claim it.
|
|
91
|
+
|
|
92
|
+
For business records on another database, keep the host's own durable intake/projection record. Commit the gem evidence and projection in the selected tenant database, then finish the host projection. If the second commit fails, replay the same gem evidence and finish the host work independently. The gem's callback will not run again after its receipt was applied. There is no distributed transaction or exactly-once external effect guarantee.
|
|
93
|
+
|
|
94
|
+
Cloudflare references: [Email event subscriptions](https://developers.cloudflare.com/email-service/platform/event-subscriptions/) and [metrics and analytics](https://developers.cloudflare.com/email-service/observability/metrics-analytics/).
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Check whether an address is configured to reach your Worker
|
|
2
|
+
|
|
3
|
+
This diagnostic is available in gem version 0.3.0.
|
|
4
|
+
|
|
5
|
+
A mailbox registered in Rails is not proof that Cloudflare can deliver to it.
|
|
6
|
+
Use this read-only check when onboarding a receiving domain, investigating a
|
|
7
|
+
missing message, or checking an existing catch-all before activating addresses.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bin/rails cloudflare:email:check_route \
|
|
11
|
+
ADDRESS=houston@customer.example.com \
|
|
12
|
+
WORKER_NAME=cloudflare-email-ingress-production \
|
|
13
|
+
ACCOUNT_ID=your-cloudflare-account-id
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Configure your Cloudflare management token in the normal gem credentials. The
|
|
17
|
+
inspection needs Zone Read, DNS Read, and Email Routing Read permissions. No Edit
|
|
18
|
+
permissions are needed. `WORKER_NAME` defaults to the gem's environment-scoped
|
|
19
|
+
Worker name; `ACCOUNT_ID` falls back to the configured account, if present.
|
|
20
|
+
|
|
21
|
+
Every check reports `PASS`, `FAIL`, or `UNKNOWN`. The command exits zero only
|
|
22
|
+
when all configuration checks pass. An unavailable API, insufficient permission,
|
|
23
|
+
an unsupported matcher, or ambiguous rule ordering produces `UNKNOWN`, not a
|
|
24
|
+
successful result. Output excludes raw provider responses and credentials.
|
|
25
|
+
|
|
26
|
+
The check inspects:
|
|
27
|
+
|
|
28
|
+
- The closest containing Cloudflare zone and its account when an expected account is supplied.
|
|
29
|
+
- Whether Email Routing is enabled for the zone.
|
|
30
|
+
- MX and SPF records for the **exact receiving domain**. Parent-domain records
|
|
31
|
+
cannot satisfy a subdomain's requirements, and a parent's unrelated MX provider
|
|
32
|
+
does not conflict with correctly configured subdomain records.
|
|
33
|
+
- Active explicit address rules before falling back to the catch-all. An explicit
|
|
34
|
+
drop, forwarding action, or different Worker takes precedence over a correct
|
|
35
|
+
catch-all. Disabled explicit rules are ignored. Multiple matching rules or
|
|
36
|
+
unsupported matcher forms leave selection unverified rather than guessing
|
|
37
|
+
priority or tie behavior.
|
|
38
|
+
|
|
39
|
+
List reads are paginated and bounded to 50 pages per endpoint, responses to 1 MiB,
|
|
40
|
+
and each request to 20 seconds (with shorter connection/read timeouts). An
|
|
41
|
+
incomplete inspection does not report success. This command only issues GET
|
|
42
|
+
requests. It never enables routing, changes DNS, repairs rules, or activates
|
|
43
|
+
mailbox records.
|
|
44
|
+
|
|
45
|
+
## Use from Ruby
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
require "cloudflare/email/routing_diagnostics"
|
|
49
|
+
|
|
50
|
+
report = Cloudflare::Email::RoutingDiagnostics.new(
|
|
51
|
+
api_token: ENV.fetch("CLOUDFLARE_API_TOKEN")
|
|
52
|
+
).check(
|
|
53
|
+
address: "houston@customer.example.com",
|
|
54
|
+
worker_name: "cloudflare-email-ingress-production",
|
|
55
|
+
account_id: ENV.fetch("CLOUDFLARE_ACCOUNT_ID")
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
report[:status] # "pass", "fail", or "unknown"
|
|
59
|
+
report[:checks] # [{ name: "zone", status: "pass", message: "..." }, ...]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This API does not require Active Record or enable multi-tenancy. You can use it
|
|
63
|
+
with a custom Worker and an existing application ingestion pipeline. It reports
|
|
64
|
+
a provider configuration snapshot; it does **not** prove public DNS propagation,
|
|
65
|
+
full SPF evaluation, successful live delivery, the Worker's application URL or
|
|
66
|
+
behavior, or application acceptance. Keep those as separate checks, including a
|
|
67
|
+
controlled end-to-end delivery when appropriate. A passing report does not
|
|
68
|
+
automatically change mailbox readiness or constitute delivery evidence.
|
|
@@ -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.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# When email does not behave as expected
|
|
2
|
+
|
|
3
|
+
Start by identifying which step failed: sending, incoming storage, mailbox
|
|
4
|
+
processing, or delivery tracking. Those are separate pieces. Use test addresses
|
|
5
|
+
you control, and avoid pasting tokens, raw MIME or private provider responses
|
|
6
|
+
into public logs or issues.
|
|
7
|
+
|
|
8
|
+
## Sending
|
|
9
|
+
|
|
10
|
+
| What you see | What to check next |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| New generator or API is missing | Check `Gemfile.lock`. The new features require the [0.2 commit](getting-started.md#install-the-current-code); published 0.1.0 lacks them. |
|
|
13
|
+
| Credentials appear to be ignored | Nonempty Rails credentials override environment variables. Check the Rails environment and restart the app after changes. |
|
|
14
|
+
| Authentication or domain error | Run `bin/rails cloudflare:email:doctor`; check account ID, token permissions and sending-domain verification. |
|
|
15
|
+
| `doctor` reports limited read access | A send token may lack diagnostic read permissions. Review the specific result; diagnostics alone cannot prove whether sending works. |
|
|
16
|
+
| API accepted the message but nothing arrives | Inspect queued, bounced and suppressed recipient outcomes, then delivery events and the destination's spam folder. Acceptance is not final delivery. |
|
|
17
|
+
| `deliver_later` does nothing | Check your job backend, running workers and failed jobs. For the outbox's `SendJob`, make sure a worker processes `mailers`. |
|
|
18
|
+
| Timeout or server error | Cloudflare might have accepted the message before the error. Investigate before resending. The outbox preserves this uncertainty. |
|
|
19
|
+
| Some recipients received it | Inspect each recipient; do not resend the entire partial batch. See [outbox recovery](outbox.md#reconcile-uncertainty). |
|
|
20
|
+
|
|
21
|
+
To perform a deliberate real-send check:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
FROM=hello@mail.example.com TO=you@example.net bin/rails cloudflare:email:send_test
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Incoming email
|
|
28
|
+
|
|
29
|
+
| What you see | What to check next |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| Address receives nothing | Verify receiving-subdomain onboarding and the address route's Worker. Sending-domain verification is separate. |
|
|
32
|
+
| Route provisioning refuses a subdomain | Complete Email Routing subdomain setup and DNS preflight first. Do not enable or replace apex routing just to bypass the error. |
|
|
33
|
+
| Worker reports 401 from Rails | Check matching ingress secrets and compatible signature versions. The Worker defaults to v2. The opt-in v3 [custom-ingress API](custom-ingress.md) requires upgrading Rails before enabling Worker metadata. |
|
|
34
|
+
| Worker reports 408 | Check clock accuracy and the five-minute signing window. |
|
|
35
|
+
| Worker reports 413 or rejects size | Raw MIME exceeds the configured limit. Match `MAX_EMAIL_BYTES` on Rails and Worker and check upstream request limits. |
|
|
36
|
+
| Worker reports 3xx | Point it directly at the final HTTPS ingress URL; redirects are intentionally rejected. |
|
|
37
|
+
| Worker times out | Check Rails availability and request latency. Direct mode can reject mail; the default [durable inbound path](../templates/worker/README.md#durable-inbound-delivery) retains it in R2 for retry and scheduled recovery. |
|
|
38
|
+
| Rails returns 200 but your product shows no message | Check ActionMailbox records, job workers, failed routing jobs and your mailbox's `process` method. The default mailbox only logs receipt. |
|
|
39
|
+
| Retrying creates no new inbound record | Identical MIME for the same SMTP recipient is intentionally deduplicated. |
|
|
40
|
+
| Bcc message appears routed to the wrong mailbox | Use `Envelope.for(inbound_email)["to"]` after checking the envelope exists; MIME `To` does not identify every SMTP recipient. |
|
|
41
|
+
| Quoted or internationalized addresses are rejected | Both v2 and v3 envelopes support ASCII dot-atom addresses, not every possible email address syntax. |
|
|
42
|
+
|
|
43
|
+
## Local development
|
|
44
|
+
|
|
45
|
+
Restart Rails after upgrading so the ingress-only guard is installed. Start
|
|
46
|
+
Rails on the expected port before `cloudflare:email:dev`, install `cloudflared`,
|
|
47
|
+
and use development credentials and a development receiving route.
|
|
48
|
+
|
|
49
|
+
The tunnel only permits POSTs to the email ingress. Opening its root URL in a
|
|
50
|
+
browser returning 404 is expected. When you stop the tunnel, its URL remains in
|
|
51
|
+
the development Worker until the next update; it no longer reaches your app.
|
|
52
|
+
|
|
53
|
+
## Saved outbox operations
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
bin/rails cloudflare:email:pending_deliveries
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
This lists operations requiring attention for the configured account. It does
|
|
60
|
+
not dispatch or repair them automatically.
|
|
61
|
+
|
|
62
|
+
| State or error | What to do |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `prepared` | The message is saved and can be dispatched using its existing operation key. Check for an enqueue failure or stopped worker. |
|
|
65
|
+
| `sending` | A process claimed the send. Verify its status; age alone does not prove it failed to send. |
|
|
66
|
+
| `unknown` | Collect provider evidence before making a resend decision. |
|
|
67
|
+
| `partial` | Review individual acceptance outcomes. Already accepted recipients must not receive a batch retry. |
|
|
68
|
+
| `accepted` | Repeated delivery uses the existing record without another send. Look at recipient lifecycle for later delivery results. |
|
|
69
|
+
| `rejected` | Correct the cause; a deliberate new attempt needs a new operation key. |
|
|
70
|
+
| `SnapshotConflict` | The same key was used with different MIME/envelope data. Retry the saved identity; do not re-render it. |
|
|
71
|
+
|
|
72
|
+
For a known `prepared` operation, dispatch the saved message with:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
OPERATION_KEY='your-existing-operation-key' bin/rails cloudflare:email:deliver
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
For uncertainty, follow [audited reconciliation](outbox.md#reconcile-uncertainty).
|
|
79
|
+
Never reset a row to `prepared` or delete the ledger to make a retry go through.
|
|
80
|
+
Reconciliation requires an authorized operator and evidence; the gem records
|
|
81
|
+
the actor you supply but does not authenticate that person.
|
|
82
|
+
|
|
83
|
+
## Delivery updates
|
|
84
|
+
|
|
85
|
+
| What you see | What to check next |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| Queue stays empty | Check the Email Sending subscription, sending domain, selected event types and a real send from that domain. |
|
|
88
|
+
| A verified Routing destination received mail but Sending feedback is missing | Use optional [Routing delivery confirmation](routing-deliveries.md) with its separate read credential. Missing evidence is unresolved, not proof of failure; keep the normal recipient address. |
|
|
89
|
+
| Polling fails before processing | Check the queue ID, separate Queues Read/Write token, HTTP pull configuration and configured event handler. |
|
|
90
|
+
| Events process once then stop | `consume_events` is a one-batch task. Schedule it to run repeatedly. |
|
|
91
|
+
| Events stay unmatched | Check account, normalized provider message ID and recipient. Schedule replay; a receipt can arrive before the send result is saved. |
|
|
92
|
+
| Acknowledged event did not update the UI | Durable receipt storage and product projection are separate. Check replay, callback failures and application correlation. |
|
|
93
|
+
| Old delivery status does not replace a newer one | Ordering and terminal-state guards intentionally reject stale updates. |
|
|
94
|
+
| One event repeatedly fails | Inspect validation/handler errors and dead-letter queue policy. Invalid events are not acknowledged by the consumer. |
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
bin/rails cloudflare:email:consume_events
|
|
98
|
+
bin/rails cloudflare:email:replay_events
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
See [delivery events](delivery-events.md) for queue setup and
|
|
102
|
+
[observability](../README.md#observability-and-permissions) for notifications you
|
|
103
|
+
can connect to your monitoring. A delivery notification wraps handler execution;
|
|
104
|
+
it is not proof that queue acknowledgement completed.
|