cloudflare-email 0.1.0 → 0.3.0

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