cloudflare-email 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -1
  3. data/README.md +265 -388
  4. data/SECURITY.md +51 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +62 -6
  6. data/docs/activerecord-tenanted.md +135 -0
  7. data/docs/architecture.md +95 -0
  8. data/docs/delivery-events.md +177 -0
  9. data/docs/features.md +107 -0
  10. data/docs/getting-started.md +298 -0
  11. data/docs/mailboxes.md +299 -0
  12. data/docs/outbox.md +175 -0
  13. data/docs/tenant-mailboxes-plan.md +149 -0
  14. data/docs/thread-correlation.md +27 -0
  15. data/docs/troubleshooting.md +103 -0
  16. data/docs/upgrading-0.2.md +96 -0
  17. data/docs/verification/2026-09-10-followup.md +49 -0
  18. data/docs/verification/2026-09-10-install-upgrade.md +86 -0
  19. data/docs/verification/2026-09-10-live.md +102 -0
  20. data/docs/verification/2026-09-10.md +175 -0
  21. data/docs/verification/2026-09-11-extraction.md +32 -0
  22. data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
  23. data/docs/verification/2026-09-11-security.md +76 -0
  24. data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
  25. data/examples/plain_ruby.rb +24 -0
  26. data/lib/cloudflare/email/active_record/base.rb +48 -0
  27. data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
  28. data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
  29. data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
  30. data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
  31. data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
  32. data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
  33. data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
  34. data/lib/cloudflare/email/active_record/outbox.rb +219 -0
  35. data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
  36. data/lib/cloudflare/email/active_record.rb +5 -0
  37. data/lib/cloudflare/email/client.rb +51 -11
  38. data/lib/cloudflare/email/consume_events_task.rb +22 -0
  39. data/lib/cloudflare/email/delivery_event.rb +70 -0
  40. data/lib/cloudflare/email/delivery_method.rb +3 -0
  41. data/lib/cloudflare/email/deploy_worker_task.rb +3 -0
  42. data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
  43. data/lib/cloudflare/email/dev_tunnel.rb +31 -5
  44. data/lib/cloudflare/email/doctor.rb +16 -38
  45. data/lib/cloudflare/email/endpoint.rb +24 -0
  46. data/lib/cloudflare/email/engine.rb +28 -0
  47. data/lib/cloudflare/email/envelope.rb +58 -0
  48. data/lib/cloudflare/email/event_consumer.rb +86 -0
  49. data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
  50. data/lib/cloudflare/email/mailboxes/events.rb +112 -0
  51. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
  52. data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
  53. data/lib/cloudflare/email/mailboxes/models.rb +148 -0
  54. data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
  55. data/lib/cloudflare/email/mailboxes/service.rb +288 -0
  56. data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
  57. data/lib/cloudflare/email/mailboxes.rb +11 -0
  58. data/lib/cloudflare/email/message_id.rb +12 -0
  59. data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
  60. data/lib/cloudflare/email/provision_route_task.rb +2 -1
  61. data/lib/cloudflare/email/replay_events_job.rb +18 -0
  62. data/lib/cloudflare/email/response.rb +21 -3
  63. data/lib/cloudflare/email/routing_provisioner.rb +71 -45
  64. data/lib/cloudflare/email/send_job.rb +41 -0
  65. data/lib/cloudflare/email/send_test.rb +5 -29
  66. data/lib/cloudflare/email/signing.rb +2 -2
  67. data/lib/cloudflare/email/tenancy.rb +78 -0
  68. data/lib/cloudflare/email/tenant_job_context.rb +90 -0
  69. data/lib/cloudflare/email/verification.rb +30 -8
  70. data/lib/cloudflare/email/version.rb +1 -1
  71. data/lib/cloudflare/email/worker_deployer.rb +5 -3
  72. data/lib/cloudflare-email.rb +4 -1
  73. data/lib/generators/cloudflare/email/install_generator.rb +48 -33
  74. data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
  75. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +54 -0
  76. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
  77. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
  78. data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
  79. data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
  80. data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
  81. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
  82. data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
  83. data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
  84. data/lib/tasks/cloudflare_email.rake +41 -4
  85. data/templates/worker/README.md +61 -11
  86. data/templates/worker/package-lock.json +2627 -0
  87. data/templates/worker/package.json +8 -5
  88. data/templates/worker/scripts/wrangler.mjs +16 -0
  89. data/templates/worker/src/index.js +83 -4
  90. data/templates/worker/test/index.test.ts +117 -5
  91. data/templates/worker/wrangler.toml +13 -4
  92. metadata +107 -6
  93. data/lib/cloudflare/email/secure_message_id.rb +0 -89
data/README.md CHANGED
@@ -1,93 +1,74 @@
1
1
  # cloudflare-email
2
2
 
3
- A Ruby gem for [Cloudflare's Email Service](https://blog.cloudflare.com/email-for-agents/)
4
- (public beta, April 2026). Send mail from Rails via an `ActionMailer` delivery
5
- method; receive mail via an `ActionMailbox` ingress backed by a shipped
6
- Cloudflare Email Worker. Works as a plain Ruby client too.
3
+ Ruby client for [Cloudflare Email Service](https://developers.cloudflare.com/email-service/), with ActionMailer, authenticated ActionMailbox ingress, a forwarding Worker, and optional durable Rails sending and delivery-event tracking.
7
4
 
8
- ```ruby
9
- # Gemfile
10
- gem "cloudflare-email"
11
- ```
5
+ Version **0.2.0**. Ruby 3.2+, Rails 7.2–8.1; Ruby 4.0 is tested with Rails 8.1. Supported test floors are Rails 7.2.3.2, 8.0.5.1, and 8.1.3.1. Prefer a maintained Ruby/Rails release for new applications. The plain Ruby client uses Ruby's standard libraries plus the Base64 gem. Node is optional: Worker deployment also works through the included Ruby deployer. See [security guidance](SECURITY.md) for deployment responsibilities.
12
6
 
13
- ---
7
+ ## Start here
14
8
 
15
- ## Two independent paths
9
+ **Sending email?** Start with the [step-by-step Rails guide](docs/getting-started.md).
10
+ **Building a mailbox?** Follow the same guide through receiving, the SQLite-compatible outbox, and delivery tracking.
11
+ **Using plain Ruby?** Jump to [Plain Ruby](#plain-ruby); Rails and a database are optional.
16
12
 
17
- - **Send only** skip to [Sending mail](#sending-mail). No Node, no Workers.
18
- - **Send + Receive** → [Receiving mail](#receiving-mail). Ships a pure-Ruby
19
- Worker deployer. **No wrangler. No npm. No dashboard clicking.**
20
- - Want to cryptographically verify replies belong to the right thread?
21
- [Signed replies](#signed-replies).
13
+ | Guide | What you will learn |
14
+ | --- | --- |
15
+ | [Features at a glance](docs/features.md) | Everything the gem handles, and what your app supplies |
16
+ | [Getting started](docs/getting-started.md) | Install, send your first email, receive replies, and save reliable send operations |
17
+ | [Troubleshooting](docs/troubleshooting.md) | What to check when mail or delivery updates do not arrive |
18
+ | [Managed mailboxes](docs/mailboxes.md) | Create inboxes and aliases, read/archive mail, and send from a mailbox |
19
+ | [SQLite tenant databases](docs/activerecord-tenanted.md) | Give each organization its own SQLite database with `activerecord-tenanted` |
20
+ | [Durable outbox](docs/outbox.md) | Detailed setup, callbacks, retries, and recovery |
21
+ | [Delivery events](docs/delivery-events.md) | Cloudflare Queue setup and recipient status tracking |
22
+ | [Upgrading to 0.2](docs/upgrading-0.2.md) | Changes needed for an existing installation |
22
23
 
23
- ---
24
+ The sections below are the configuration and API reference.
24
25
 
25
- # Sending mail
26
+ ## Install and send from Rails
26
27
 
27
- ## Setup (3 minutes)
28
+ Add version 0.2 to your Gemfile. Existing 0.1 users should follow the [upgrade guide](docs/upgrading-0.2.md), including the coordinated Rails/Worker update:
28
29
 
29
- ```sh
30
- bundle add cloudflare-email
31
- bin/rails generate cloudflare:email:install --no-inbound
32
- bin/rails cloudflare:email:doctor # verify wiring
33
- TO=you@example.com bin/rails cloudflare:email:send_test
30
+ ```ruby
31
+ gem "cloudflare-email", "~> 0.2.0"
34
32
  ```
35
33
 
36
- ## Credentials — two options
37
-
38
- The gem reads config from **Rails credentials first, then env vars**. Pick
39
- whichever fits your workflow:
40
-
41
- **Option A: Rails credentials** (recommended — encrypted, per-env)
42
-
43
34
  ```sh
44
- bin/rails credentials:edit --environment production
35
+ bundle install
36
+ bin/rails generate cloudflare:email:install --no-inbound
45
37
  ```
46
38
 
39
+ Add Rails credentials (encrypted, per environment) or environment variables:
40
+
47
41
  ```yaml
48
42
  cloudflare:
49
- account_id: <your-cloudflare-account-id>
50
- api_token: <email-send-scoped-api-token>
43
+ account_id: your-account-id
44
+ api_token: your-email-sending-token
51
45
  ```
52
46
 
53
- **Option B: `.env` / environment variables**
54
-
55
- ```env
56
- CLOUDFLARE_ACCOUNT_ID=your-account-id
57
- CLOUDFLARE_API_TOKEN=your-send-scoped-token
47
+ ```sh
48
+ export CLOUDFLARE_ACCOUNT_ID=your-account-id
49
+ export CLOUDFLARE_API_TOKEN=your-email-sending-token
58
50
  ```
59
51
 
60
- Use `dotenv-rails`, `foreman`, your platform's secret store (Fly, Render,
61
- Heroku, Kamal) — anything that puts them into `ENV`.
52
+ The generated initializer uses `Cloudflare::Email::Credentials`: nonempty Rails credentials take precedence, then `CLOUDFLARE_*` environment variables. For an existing 0.1.0 installation, update the initializer manually; see [upgrading](docs/upgrading-0.2.md).
62
53
 
63
- ## Dashboard setup (one-time)
54
+ Onboard a sending domain under **Compute → Email Service → Email Sending**. Use a dedicated sending subdomain if the apex already uses another mail provider. Cloudflare's onboarding adds the required bounce MX, SPF, DKIM, and DMARC records for that sending domain. Follow the current [domain configuration](https://developers.cloudflare.com/email-service/configuration/domains/) instructions; do not replace an existing apex SPF record or publish a second one.
64
55
 
65
- 1. **API token**: `dash.cloudflare.com/profile/api-tokens` **Create Token**
66
- **Custom Token**. Permission: **Account → Email Sending → Send**. Scope to
67
- your specific account.
68
- 2. **Sending Domain**: your zone → **Email** → **Email Sending** → **Sending
69
- Domains** → **Add Sending Domain**. Use a subdomain (e.g.
70
- `mail.yourdomain.com`), not the apex if you already have Google Workspace
71
- there.
72
- 3. **SPF + DMARC** on the apex (DKIM is auto-published by Cloudflare):
73
- ```
74
- TXT @ v=spf1 include:_spf.mx.cloudflare.net ~all
75
- TXT _dmarc v=DMARC1; p=quarantine; rua=mailto:postmaster@yourdomain.com
76
- ```
77
- 4. Wait until the dashboard shows "Verified" before sending (otherwise a vague
78
- `email.sending.error.internal_server` 500 comes back).
56
+ Create a token with permission to send for your account, and verify the domain in the dashboard before testing:
79
57
 
80
- ## Send mail
58
+ ```sh
59
+ bin/rails cloudflare:email:doctor
60
+ FROM=hello@mail.example.com TO=you@example.net bin/rails cloudflare:email:send_test
61
+ ```
62
+
63
+ `doctor` checks configuration and available read access. It does not send mail or prove deliverability. `send_test` requires both `FROM` and `TO`.
81
64
 
82
- Standard ActionMailer — the `:cloudflare` delivery method is registered
83
- automatically:
65
+ Standard ActionMailer works:
84
66
 
85
67
  ```ruby
86
68
  class WelcomeMailer < ApplicationMailer
87
69
  def welcome(user)
88
- mail(to: user.email, from: "hello@mail.yourdomain.com", subject: "Welcome") do |format|
89
- format.text { render plain: "Hi #{user.name}" }
90
- format.html { render "welcome_html" }
70
+ mail(from: "hello@mail.example.com", to: user.email, subject: "Welcome") do |format|
71
+ format.text { render plain: "Hello!" }
91
72
  end
92
73
  end
93
74
  end
@@ -95,427 +76,323 @@ end
95
76
  WelcomeMailer.welcome(user).deliver_later
96
77
  ```
97
78
 
98
- Attachments, multipart, threading headers, cc/bcc all round-trip through the
99
- underlying `send_raw` API.
100
-
101
- ## Plain Ruby (no Rails)
102
-
103
- ```ruby
104
- require "cloudflare-email"
105
-
106
- client = Cloudflare::Email::Client.new(
107
- account_id: ENV["CLOUDFLARE_ACCOUNT_ID"],
108
- api_token: ENV["CLOUDFLARE_API_TOKEN"],
109
- )
110
-
111
- response = client.send(
112
- from: { address: "agent@mail.acme.com", name: "Acme Agent" },
113
- to: "user@example.com",
114
- subject: "Hello",
115
- text: "Plain body",
116
- html: "<p>HTML body</p>",
117
- reply_to: "thread+abc@mail.acme.com",
118
- headers: { "In-Reply-To" => "<msg-123@acme.com>" },
119
- attachments: [{
120
- content: Base64.strict_encode64(File.read("report.pdf")),
121
- filename: "report.pdf",
122
- type: "application/pdf",
123
- }],
124
- )
125
-
126
- response.success? # => true
127
- response.delivered # => ["user@example.com"]
128
- response.message_id # => nil (Cloudflare does not return a message ID)
129
- ```
130
-
131
- For full MIME control: `client.send_raw(from:, recipients:, mime_message:)`.
79
+ Multipart, attachments, cc/bcc, and threading headers are serialized through `send_raw`. Cloudflare still controls final delivery and header acceptance.
132
80
 
133
- ---
81
+ ## Managed mailboxes and optional tenancy
134
82
 
135
- # Receiving mail
83
+ **Multi-tenancy is off by default.** Installing the gem does not create tenant
84
+ databases, install a tenancy library, or change your application's database routing.
136
85
 
137
- Cloudflare Email Routing delivers inbound mail to an **Email Worker**, not an
138
- HTTPS webhook you can't just point it at a URL. This gem ships a Worker
139
- that signs each message with HMAC-SHA256 and POSTs it to a Rails `ActionMailbox`
140
- ingress it sets up for you.
86
+ | Setup | What you explicitly enable |
87
+ | --- | --- |
88
+ | Plain Ruby sending | Nothing extra; no Rails/database required |
89
+ | Managed mailboxes in one database | Run the mailbox generator and load the optional module |
90
+ | Separate tenant databases | Configure `Tenancy.configure(...)` with your application's base class and switching adapter before loading models |
141
91
 
142
- **No wrangler, npm, or Node required.** The Worker is plain JavaScript; the
143
- gem ships a pure-Ruby deployer that talks directly to Cloudflare's Workers
144
- API. `wrangler` is supported as an alternative if you prefer the Cloudflare
145
- CLI.
92
+ The mailbox API uses a tenant key to group and scope records even in one database.
93
+ That key alone does not enable database switching. `activerecord-tenanted` is an
94
+ optional application dependency, not a runtime dependency of this gem.
146
95
 
147
- ## Setup
96
+ Create mailboxes in code with the optional mailbox module. It includes named
97
+ mailboxes, aliases, ownership references, read/archive state, retained raw mail,
98
+ mailbox-authorized sends, and tenant-aware delivery-event recovery:
148
99
 
149
100
  ```sh
150
- bundle add cloudflare-email
151
- bin/rails generate cloudflare:email:install # interactive; scaffolds everything
152
- bin/rails credentials:edit # fill in the 4 secrets (below)
153
- bin/rails cloudflare:email:doctor # verify
154
- bin/rails cloudflare:email:deploy_worker URL=https://yourapp.com/rails/action_mailbox/cloudflare/inbound_emails
155
- bin/rails cloudflare:email:provision_route ADDRESS=cole@in.yourdomain.com
101
+ bin/rails generate cloudflare:email:mailboxes
102
+ bin/rails db:migrate
156
103
  ```
157
104
 
158
- That's it. Zero dashboard clicks once your tokens are created.
105
+ After registering and verifying a receiving domain:
159
106
 
160
- The interactive installer:
107
+ ```ruby
108
+ Cloudflare::Email::Mailboxes.for_tenant("organization-123") do |inboxes|
109
+ mailbox = inboxes.create(name: "Support", address: "support@acme.example.com",
110
+ owner_ref: "team:42")
111
+ # Provision and activate its address route before receiving or sending mail.
112
+ end
113
+ ```
161
114
 
162
- 1. Copies the Worker template into `cloudflare-worker/` + writes the
163
- `config/initializers/cloudflare_email.rb` initializer.
164
- 2. Scaffolds a default `MainMailbox` + catch-all route (prompt) so inbound
165
- mail has somewhere to land on day one.
166
- 3. Runs `bin/rails action_mailbox:install` (prompt) if ActionMailbox is
167
- missing in the app.
115
+ Use one database or an application-provided tenant base class. The optional
116
+ `activerecord-tenanted` integration is tested with separate SQLite databases,
117
+ including jobs and overlapping record IDs. Your app still authorizes users and
118
+ provides the UI. Start with [the mailbox guide](docs/mailboxes.md), and configure
119
+ [tenant connections](docs/activerecord-tenanted.md) before loading models when
120
+ using separate databases. Plain Ruby users do not load this module.
168
121
 
169
- ## Credentials
122
+ ## Durable mailbox delivery from Rails
170
123
 
171
- Same two options as sending (credentials OR `.env`). For inbound you need
172
- three values plus an ingress secret:
124
+ For a mailbox application, use the optional outbox instead of recreating send
125
+ claims, attempt records, recipient outcomes, and event correlation:
173
126
 
174
- ```yaml
175
- cloudflare:
176
- account_id: <your-cloudflare-account-id>
177
- api_token: <runtime token — Email Sending: Send>
178
- management_token: <optional; Workers + Email Routing + Zone Read>
179
- ingress_secret: <generated by the installer>
127
+ ```sh
128
+ bin/rails generate cloudflare:email:tracking
129
+ bin/rails generate cloudflare:email:outbox
130
+ bin/rails db:migrate
180
131
  ```
181
132
 
182
- Or via env vars:
183
-
184
- ```env
185
- CLOUDFLARE_ACCOUNT_ID=...
186
- CLOUDFLARE_API_TOKEN=...
187
- CLOUDFLARE_MANAGEMENT_TOKEN=... # optional
188
- CLOUDFLARE_INGRESS_SECRET=...
133
+ ```ruby
134
+ account = Cloudflare::Email::Credentials.account_id
135
+ operation = Cloudflare::Email::ActiveRecord::Outbox.prepare_mail(
136
+ account_id: account,
137
+ operation_key: "reply:#{draft.id}:revision:#{draft.revision}",
138
+ mail: ReplyMailer.reply(draft).message,
139
+ )
140
+ # Enqueue after the transaction that prepared the operation commits.
141
+ Cloudflare::Email::SendJob.perform_later(account, operation.operation_key)
189
142
  ```
190
143
 
191
- ## Tokens why two?
144
+ The job sends the stored MIME once per operation claim. Accepted repeats cannot
145
+ resend; uncertain sends remain blocked for audited reconciliation. Each recipient
146
+ has separate acceptance evidence and lifecycle status. `DeliveryEvents` persists,
147
+ deduplicates, correlates, orders, and replays queue events, with transactional
148
+ callbacks for your product records. The reference Rails inbox uses these same
149
+ tables and APIs for compose, replies, approved AI drafts, and recovery.
192
150
 
193
- For best security, split your tokens into runtime and management:
151
+ `ReplyMailer`, `draft`, and its revision are application examples. Authorize the
152
+ send before preparation and use a durable job backend. See the [complete outbox
153
+ setup and recovery guide](docs/outbox.md) and [gem/inbox architecture](docs/architecture.md).
154
+ The adapter is opt-in; the plain Ruby client does not load Rails or ActiveRecord.
194
155
 
195
- - **Runtime** (`api_token`): `Email Sending → Send` only. Lives in the app
196
- process at runtime. If leaked, attacker can send spam — that's it.
197
- - **Management** (`management_token`): `Workers Scripts: Edit`, `Zone: Read`,
198
- `Email Routing: Edit`. Used by `deploy_worker`, `provision_route`, and
199
- `dev` tasks. **Never loaded by the running Rails app** — set it in your
200
- deploy environment only, or as a local `.env` for your laptop.
156
+ ## Plain Ruby
201
157
 
202
- If only `api_token` is set, management tasks fall back to it. Single-token
203
- setups are fine for solo devs / small projects; split tokens are strongly
204
- recommended for production.
205
-
206
- ## Dashboard setup — one step
207
-
208
- Only one dashboard visit needed: create the token(s) at
209
- `dash.cloudflare.com/profile/api-tokens`. Choose the scopes from the
210
- [Tokens](#tokens--why-two) table.
211
-
212
- Everything else — sending domain, Email Routing enablement, MX records, route
213
- rules — can be done in the dashboard OR automated from Ruby via the gem's
214
- rake tasks. See the [rake task reference](#rake-tasks) below.
215
-
216
- ## Write your mailbox
158
+ ```ruby
159
+ require "cloudflare-email"
217
160
 
218
- The installer creates `MainMailbox` with a stub. Replace `#process`:
161
+ client = Cloudflare::Email::Client.new(
162
+ account_id: ENV.fetch("CLOUDFLARE_ACCOUNT_ID"),
163
+ api_token: ENV.fetch("CLOUDFLARE_API_TOKEN"),
164
+ )
219
165
 
220
- ```ruby
221
- # app/mailboxes/main_mailbox.rb
222
- class MainMailbox < ApplicationMailbox
223
- def process
224
- YourAgentJob.perform_later(
225
- from: mail.from.first,
226
- subject: mail.subject,
227
- body: mail.body.decoded,
228
- )
229
- end
230
- end
166
+ response = client.send(
167
+ from: { address: "hello@mail.example.com", name: "Example" },
168
+ to: "you@example.net",
169
+ subject: "Hello",
170
+ text: "Plain body",
171
+ html: "<p>HTML body</p>",
172
+ reply_to: "support@in.example.com",
173
+ )
174
+ response.message_id # Provider ID when returned; nil on older responses
175
+ response.delivered # Immediately delivered recipients
176
+ response.queued # Recipients queued for later delivery
177
+ response.permanent_bounces # Recipients that permanently bounced
178
+ response.suppressed_recipients # Recipients dropped by suppression policy
231
179
  ```
232
180
 
233
- Route by address or content in `ApplicationMailbox`:
181
+ `to` may be omitted for cc-only or bcc-only mail. Addresses accept strings or `{ address:, name: }` hashes. Attachments use Cloudflare's `content` (base64), `filename`, `type`, `disposition`, and optional `content_id` fields. For full MIME control, use `client.send_raw(from:, recipients:, mime_message:)`.
234
182
 
235
- ```ruby
236
- class ApplicationMailbox < ActionMailbox::Base
237
- routing /^support@/i => :support
238
- routing :all => :main
239
- end
240
- ```
183
+ An HTTP success does not mean every recipient received the message. Inspect the recipient outcome arrays and use [delivery events](docs/delivery-events.md) for later results. The current API reference includes message IDs and suppressed recipients; older responses remain supported.
184
+
185
+ Cloudflare currently limits ordinary sends to 50 recipients and 5 MiB including attachments; verified destination addresses have a 25 MiB allowance. These limits are enforced by Cloudflare. See [limits](https://developers.cloudflare.com/email-service/platform/limits/) and the [header allowlist](https://developers.cloudflare.com/email-service/reference/headers/).
241
186
 
242
- ## Local development
187
+ ## Receive through ActionMailbox
243
188
 
244
- You need a public HTTPS URL for Cloudflare to POST to. In dev that means
245
- tunneling. Run `bin/rails server` in one terminal, then:
189
+ Cloudflare Email Routing invokes an Email Worker. The bundled Worker forwards unchanged raw MIME to Rails with a versioned HMAC-SHA256 signature covering the timestamp, SMTP envelope, and body. Rails verifies the signature and a five-minute timestamp window before storing the message in ActionMailbox.
246
190
 
247
191
  ```sh
248
- bin/rails cloudflare:email:dev
192
+ bin/rails generate cloudflare:email:install
249
193
  ```
250
194
 
251
- That task starts a `cloudflared` tunnel, updates your deployed Worker's
252
- `RAILS_INGRESS_URL` secret to point at it, and ties up the terminal until
253
- Ctrl-C. Send mail to your routed address; it flows Cloudflare → Worker →
254
- tunnel → local Rails → your mailbox.
195
+ The interactive installer offers ActionMailbox installation/migrations and a default `MainMailbox`, copies the Worker, and configures ingress in development and production. Add `--all-envs` to include test; configure custom staging environments explicitly. Send-only apps do not need ActionMailbox.
255
196
 
256
- Only `cloudflared` required no wrangler, no Node.
197
+ Add the generated ingress secret and an optional management token:
257
198
 
258
- ## Per-environment Worker isolation
199
+ ```yaml
200
+ cloudflare:
201
+ account_id: your-account-id
202
+ api_token: your-runtime-send-token
203
+ management_token: your-deployment-token
204
+ ingress_secret: the-generated-random-secret
205
+ ```
259
206
 
260
- The gem names the Worker `cloudflare-email-ingress-#{Rails.env}` by default.
261
- Dev, staging, and prod deploy as **separate scripts** with separate secrets.
262
- `bin/rails cloudflare:email:dev` only ever touches `-development`, so spinning
263
- up a dev tunnel can never break production's inbound.
207
+ Equivalent environment names are `CLOUDFLARE_MANAGEMENT_TOKEN` and `CLOUDFLARE_INGRESS_SECRET`. Keep management credentials in the deployment environment rather than the running application. Credentials stored in the application's Rails credentials are accessible to that application; this gem is not a secret-isolation boundary.
264
208
 
265
- Deploy per environment:
209
+ 1. Add the receiving subdomain, for example `in.example.com`, in **Email Routing → apex domain → Settings → Subdomains**. This is a separate onboarding step from sending.
210
+ 2. Deploy the environment's Worker and create its route:
266
211
 
267
212
  ```sh
268
- RAILS_ENV=production bin/rails cloudflare:email:deploy_worker URL=https://app.example.com/rails/action_mailbox/cloudflare/inbound_emails
269
- RAILS_ENV=staging bin/rails cloudflare:email:deploy_worker URL=https://staging.example.com/rails/action_mailbox/cloudflare/inbound_emails
213
+ RAILS_ENV=production bin/rails cloudflare:email:deploy_worker URL=https://app.example.com/rails/action_mailbox/cloudflare/inbound_emails
214
+ RAILS_ENV=production bin/rails cloudflare:email:provision_route ADDRESS=support@in.example.com
270
215
  ```
271
216
 
272
- Route different addresses to different Workers:
217
+ Subdomain provisioning checks configured DNS before creating a rule. It never enables the parent apex on behalf of a subdomain. A missing setup or permission fails with instructions. DNS records alone do not prove propagation or live routing; send a real test afterward.
273
218
 
274
- ```sh
275
- RAILS_ENV=production bin/rails cloudflare:email:provision_route ADDRESS=cole@in.yourdomain.com
276
- RAILS_ENV=staging bin/rails cloudflare:email:provision_route ADDRESS=cole@staging.in.yourdomain.com
277
- ```
219
+ For a zone apex, provisioning may enable Email Routing and its DNS records. Only use this when Cloudflare should handle mail for that apex. `provision_catchall DOMAIN=example.com` changes the **zone-wide** catch-all; a subdomain that resolves to a parent zone is rejected. See [subdomain onboarding](https://developers.cloudflare.com/email-service/configuration/subdomains/).
278
220
 
279
- ## ⚠️ Apex vs subdomain
221
+ Replace the scaffolded mailbox's `process` with your application logic. For tenant or mailbox selection, use the authenticated SMTP recipient rather than the sender-controlled MIME `To`/`Cc` headers:
280
222
 
281
- **Don't enable Email Routing on the apex** of a domain where colleagues run
282
- email on Google Workspace or Outlook — MX records are domain-level, so
283
- that'd route everyone's mail through Cloudflare first. Use a subdomain
284
- (`in.yourdomain.com`).
223
+ ```ruby
224
+ class ApplicationMailbox < ActionMailbox::Base
225
+ routing ->(inbound) { Cloudflare::Email::Envelope.for(inbound)&.fetch("to")&.match?(/\Asupport@/i) } => :support
226
+ end
227
+ ```
285
228
 
286
- If you want your own `cole@yourdomain.com` to also reach the agent, set up a
287
- Google Workspace routing rule that BCCs incoming mail to
288
- `cole@in.yourdomain.com`. You read mail in Gmail normally AND the agent
289
- gets a copy.
229
+ `Cloudflare::Email::Envelope.for(inbound_email)` returns a string-keyed `{"from" => "sender@example.com", "to" => "support@example.com"}` hash, or `nil` when the record has no authenticated envelope (for example, another ingress). The metadata is stored on the raw-email blob before routing jobs enqueue. It does not modify the MIME source. Envelope sender information records the SMTP reverse path; it does not authenticate the human sender. An empty `from` is valid for bounces.
290
230
 
291
- ## Rotating the ingress secret
231
+ Version 0.2 requires the bundled v2 Worker; missing or v1 signatures are rejected. Coordinate Rails and Worker deployment while ingress is paused. The Worker requires ASCII dot-atom addresses, at most 254 bytes with a 64-byte local part. Quoted local parts, address literals, and internationalized addresses are not supported by this envelope format.
292
232
 
293
- Rotate Worker and Rails together (no overlap window):
233
+ Successful ingress storage returns HTTP 200; duplicate storage returns 200 too. The timestamp window limits request age, but is not a one-time replay ledger. Version 2 deduplication includes the exact SMTP recipient, so identical MIME delivered to separate To/Cc/Bcc recipients creates separate inbound records while a retry for the same recipient creates none.
294
234
 
295
- 1. `bin/rails credentials:edit` update `cloudflare.ingress_secret`.
296
- 2. Re-run `bin/rails cloudflare:email:deploy_worker URL=...` to push the new
297
- secret to the Worker + redeploy.
235
+ The Worker has a 15-second Rails request timeout and rejects redirects. Non-2xx responses, timeouts, and network failures call `message.setReject`. There is no durable buffering of inbound email: an application outage can reject mail. Storage acceptance does not guarantee later mailbox-job success. Monitor Rails jobs and Cloudflare Worker logs.
298
236
 
299
- If they disagree, inbound mail bounces with 401 and the sender gets a
300
- delivery failure (no silent drop).
237
+ ### Local development and deployment
301
238
 
302
- ## How inbound flows
239
+ Start Rails, then:
303
240
 
304
- ```
305
- Sender's MTA
306
- │ MX lookup resolves to Cloudflare
307
-
308
- Cloudflare Email Routing
309
- │ (rule matched, action = "Send to Worker")
310
-
311
- cloudflare-email-ingress-{env} Worker (reads message.raw, HMAC-signs)
312
- │ POST with Content-Type: message/rfc822
313
- │ + X-CF-Email-Timestamp + X-CF-Email-Signature
314
-
315
- Your Rails app — IngressController
316
- │ (verifies HMAC, 5-min replay window)
317
-
318
- ActionMailbox::InboundEmail.create_and_extract_message_id!
319
-
320
-
321
- ApplicationMailbox → YourMailbox#process
241
+ ```sh
242
+ bin/rails cloudflare:email:deploy_worker
243
+ bin/rails cloudflare:email:dev
322
244
  ```
323
245
 
324
- If Rails responds non-2xx, the Worker calls `message.setReject` so the sender
325
- gets a bounce. No silent drops.
246
+ The dev task requires `cloudflared`, refuses environments other than development, and updates the existing development Worker's URL to a temporary tunnel. It forces a dedicated origin Host; development middleware permits only POSTs to the email ingress on that Host. Restart Rails after upgrading: the task checks that the guard is running before opening the tunnel. Configure a separate development receiving address and route. Stopping the tunnel leaves that URL in the development Worker until the next update. A Worker deployed without a URL rejects mail until the tunnel sets it.
326
247
 
327
- ---
248
+ For a custom installer `--worker-dir`, pass `SCRIPT=custom-directory/src/index.js` to the Ruby `deploy_worker` task. The installer prints the corresponding command.
328
249
 
329
- # Signed replies
250
+ Ruby deployment and Wrangler use matching names: `cloudflare-email-ingress-development`, `-staging`, and `-production`. The optional Wrangler path requires Node 22.12+ (or a supported newer version):
330
251
 
331
- Optional but **highly recommended** for agent email flows: cryptographically
332
- bind replies to the original thread so the inbound side can prove a reply
333
- is legitimate and hasn't been forged. Inspired by Cloudflare's
334
- [`createSecureReplyEmailResolver`](https://developers.cloudflare.com/agents/api-reference/email/)
335
- from the JS Agents SDK, but stateless — no Durable Object storage needed.
252
+ ```sh
253
+ cd cloudflare-worker
254
+ npm ci
255
+ npx wrangler secret put INGRESS_SECRET --env production
256
+ npx wrangler secret put RAILS_INGRESS_URL --env production
257
+ npm run deploy -- --env production
258
+ ```
336
259
 
337
- **How it works**: sign the outbound `Message-ID:` with HMAC-SHA256. When a
338
- user replies, their mail client naturally carries the original id into the
339
- `In-Reply-To:` header. Your mailbox reads + verifies it there, recovers the
340
- payload (thread id, user id, whatever you encoded), and routes accordingly.
260
+ See the [Worker README](templates/worker/README.md). Existing deployments/templates are not automatically migrated; verify routing before switching names.
341
261
 
342
- - HMAC-SHA256, 30-day default max-age (configurable)
343
- - Stateless — no DB row to look up, no Durable Object
344
- - No catch-all route required — replies come to your normal inbound address
345
- - User-visible reply-to address stays clean (`agent@in.yourdomain.com`)
346
- - No size constraint on payloads — Message-IDs can be ~900 chars
262
+ Rotate the shared ingress secret in Rails and the corresponding Worker during a coordinated deployment. This version has no overlapping-key rotation window; requests can fail while secrets differ.
347
263
 
348
- ## Outbound
264
+ ## Outbound delivery events
349
265
 
350
- ```ruby
351
- class AgentMailer < ApplicationMailer
352
- def ping(thread)
353
- signed_id = Cloudflare::Email::SecureMessageId.encode(
354
- payload: {
355
- thread_id: thread.id,
356
- user_id: thread.user_id,
357
- kind: "ping",
358
- },
359
- domain: "mail.yourdomain.com",
360
- secret: Rails.application.credentials.dig(:cloudflare, :reply_secret),
361
- )
362
-
363
- mail(
364
- to: thread.user.email,
365
- from: "agent@mail.yourdomain.com",
366
- reply_to: "agent@in.yourdomain.com", # clean, routable address
367
- subject: "Re: #{thread.title}",
368
- message_id: signed_id, # sign the Message-ID
369
- ) { |f| f.text { render plain: "..." } }
370
- end
371
- end
372
- ```
373
-
374
- ## Inbound
266
+ The new `DeliveryEvent` and `EventConsumer` APIs consume Cloudflare Email Sending lifecycle events through an HTTP pull queue: delivered, deferred, bounced, failed, rejected, and complained.
375
267
 
376
268
  ```ruby
377
- class AgentMailbox < ApplicationMailbox
378
- def process
379
- ref = mail.in_reply_to || Array(mail.references).first
380
- if ref && Cloudflare::Email::SecureMessageId.match?(ref)
381
- payload = Cloudflare::Email::SecureMessageId.decode(
382
- ref,
383
- secret: Rails.application.credentials.dig(:cloudflare, :reply_secret),
384
- )
385
- Thread.find(payload["thread_id"]).ingest(mail)
386
- end
387
- rescue Cloudflare::Email::SecureMessageId::InvalidToken => e
388
- Rails.logger.warn("Invalid signed reply: #{e.message}")
389
- end
269
+ consumer = Cloudflare::Email::EventConsumer.new(
270
+ account_id: ENV.fetch("CLOUDFLARE_ACCOUNT_ID"),
271
+ api_token: ENV.fetch("CLOUDFLARE_QUEUES_TOKEN"),
272
+ queue_id: ENV.fetch("CLOUDFLARE_EVENT_QUEUE_ID"),
273
+ domains: ["mail.example.com"],
274
+ )
275
+ consumer.poll do |event|
276
+ DeliveryEventProcessor.call(event) # Your durable, idempotent application handler
390
277
  end
391
278
  ```
392
279
 
393
- Route replies to `agent@in.yourdomain.com` normally (`provision_route`).
394
- The signed state rides in the Message-ID, not the recipient address.
395
-
396
- ## Setup
397
-
398
- Add a reply secret to credentials:
399
-
400
- ```yaml
401
- cloudflare:
402
- reply_secret: <openssl rand -hex 32>
403
- ```
404
-
405
- (Or `CLOUDFLARE_REPLY_SECRET` in your env.)
280
+ Each event is acknowledged only after the handler returns normally. Configure a dedicated queue, subscription, retry policy, and dead-letter queue first. See the complete [Rails and Ruby delivery-event setup](docs/delivery-events.md).
406
281
 
407
- That's it. Use the helpers in your mailer + mailbox as shown above.
282
+ Rails applications can run `bin/rails generate cloudflare:email:tracking` and
283
+ `bin/rails db:migrate` to install optional durable event receipts. The adapter
284
+ commits before ACK, deduplicates account/event IDs, retains unmatched events,
285
+ and replays them with transactional application handlers. It requires explicit
286
+ opt-in; the plain Ruby client stays independent of ActiveRecord.
408
287
 
409
- ## Compared to Cloudflare's JS SDK
288
+ Shared helpers include `Response#accepted?` (any provider acceptance, not final
289
+ delivery) and `DeliveryEvent#supersedes?(occurred_at:, terminal:)` for ordering
290
+ matched recipient events. See the [architecture guide](docs/architecture.md) for
291
+ what the gem provides and what belongs in the inbox product.
410
292
 
411
- | | Our `SecureMessageId` | CF `createSecureReplyEmailResolver` |
412
- |---|---|---|
413
- | Signing | HMAC-SHA256 (full 64-char hex) | HMAC-SHA256 (full) |
414
- | Carrier | `Message-ID:` `In-Reply-To:` | Headers + Durable Object lookup |
415
- | Statefulness | Stateless | Stateful (DO storage) |
416
- | Works in plain Rails | Yes | Requires Workers + DO |
293
+ Applications using the outbox can use `DeliveryEvents.record(event)` and
294
+ `DeliveryEvents.replay(account_id:, message_id:)` for built-in correlation and
295
+ recipient-state projection. Applications with their own delivery schema can
296
+ continue to use the lower-level `EventInbox` with an explicit handler.
417
297
 
418
- Same security properties (HMAC-SHA256, time-boxed with max-age), different
419
- mechanism. `SecureMessageId` is the idiomatic Rails choice — stateless,
420
- size-unconstrained, and matches email threading natively.
298
+ ## Thread correlation
421
299
 
422
- ---
300
+ Prefer storing the provider's returned `message_id` with your conversation and correlating inbound `In-Reply-To` / `References` against that record. Correlation does not authenticate the sender or authorize an action.
423
301
 
424
- # Rake tasks + token scopes
302
+ Use `Cloudflare::Email::MessageId.normalize(value)` to trim whitespace and one enclosing angle-bracket pair without changing the ID's case. The old `SecureMessageId` helper has been removed.
425
303
 
426
- | Task | What it does | Token scopes |
427
- |---|---|---|
428
- | `doctor` | Verifies credentials, API token validity, ingress secret, `ActionMailbox.ingress`, delivery method. Exit 1 on failure. | `Email Sending: Send` |
429
- | `send_test TO=addr [FROM=addr]` | One-shot test send. FROM auto-detected from verified sending domains. | `Email Sending: Send` |
430
- | `deploy_worker URL=https://...` | Uploads the Worker + sets `INGRESS_SECRET` + `RAILS_INGRESS_URL`. Pure Ruby, no wrangler. Targets `cloudflare-email-ingress-#{Rails.env}`. | `Workers Scripts: Edit` |
431
- | `provision_route ADDRESS=addr@domain` | Creates/updates an Email Routing rule binding the address to the env-scoped Worker. Idempotent. | `Zone: Read`, `Email Routing: Edit` |
432
- | `provision_catchall DOMAIN=sub.domain` | Points the zone's catch-all rule at the env-scoped Worker. | `Zone: Read`, `Email Routing: Edit` |
433
- | `dev` | Starts a `cloudflared` tunnel, auto-updates the `-development` Worker's `RAILS_INGRESS_URL` to point at it. | `Workers Scripts: Edit` |
304
+ Cloudflare's current [header documentation](https://developers.cloudflare.com/email-service/reference/headers/) describes Message-ID as platform-controlled. **The September 10 live test confirmed that Cloudflare replaced custom signed IDs**, including raw-MIME and ActionMailer sends. Store the provider ID for Cloudflare reply correlation. See [thread correlation](docs/thread-correlation.md) and the [live evidence](docs/verification/2026-09-10-live.md).
434
305
 
435
- Create tokens at `dash.cloudflare.com/profile/api-tokens` → **Custom Token**.
436
- Scope to a single account. For production, use two tokens: a runtime
437
- (`Email Sending: Send` only) and a management (everything else).
306
+ ## Retry and configuration
438
307
 
439
- ---
308
+ `Client.new` options are also accepted by `config.action_mailer.cloudflare_settings`:
440
309
 
441
- # Reference
442
-
443
- ## Configuration
444
-
445
- | Setting | Default | Notes |
446
- |---|---|---|
447
- | `account_id` | | Required. |
448
- | `api_token` | | Required. `Email Sending: Send` permission. |
449
- | `base_url` | `https://api.cloudflare.com/client/v4` | Override for testing. |
450
- | `retries` | `3` | On 429 / 5xx / network errors. |
451
- | `initial_backoff` | `0.5` | Seconds. Doubles each retry. |
452
- | `max_retry_after` | `60` | Upper bound on `Retry-After` sleep. |
453
- | `timeout` | `30` | Seconds. Open + read. |
454
- | `logger` | `nil` | Responds to `#warn`. Logs retries. |
310
+ | Option | Default |
311
+ |---|---|
312
+ | `account_id`, `api_token` | Required |
313
+ | `base_url` | `https://api.cloudflare.com/client/v4` |
314
+ | `timeout` | 30 seconds for open/read/write |
315
+ | `retries` | 3 additional attempts |
316
+ | `initial_backoff` | 0.5 seconds, doubling |
317
+ | `max_retry_after` | 60 seconds |
318
+ | `retry_ambiguous` | `false` |
319
+ | `logger` | `nil`, optional `warn` logger |
455
320
 
456
- In Rails: `config.action_mailer.cloudflare_settings = { ... }`.
321
+ By default, only 429 responses and pre-send connection failures retry. Numeric and HTTP-date `Retry-After` values are honored up to the cap. Read/write timeouts, connection resets, and 5xx responses may occur after acceptance; they raise without automatically resending. Setting `retry_ambiguous: true` restores retries for those failures and can send duplicates.
457
322
 
458
- ## Retry, rate limit, idempotency
323
+ No idempotency key is sent. Reusing Message-ID does not guarantee deduplication or exactly-once delivery. Account for ActiveJob's retry policy too: retrying the whole mailer job can resend even when this client's retries are disabled.
459
324
 
460
- - Retries on 429, 5xx, and network errors with exponential backoff.
461
- - `Retry-After` on 429 is honored, capped at `max_retry_after`.
462
- - **Cloudflare does not accept an idempotency key** and does not return a
463
- `message_id` in send responses. Dedupe on your side via the outbound
464
- `Message-ID` header if you care about exactly-once semantics.
325
+ Errors inherit from `Cloudflare::Email::Error`: `ConfigurationError`, `AuthenticationError`, `ValidationError`, `RateLimitError`, `ServerError`, and `NetworkError`. API errors expose `status` and parsed `response`.
465
326
 
466
- ## Errors
327
+ ## Observability and permissions
467
328
 
468
- All descend from `Cloudflare::Email::Error`:
329
+ Notifications: `cloudflare_email.send` / `send_raw` include `account_id`, `path`, `status`, `message_id`, and all four recipient outcome arrays. `cloudflare_email.ingress` includes `bytes`, `result` (`ok`, `duplicate`, `bad_signature`, `stale`, `too_large`), and the stored `message_id` when available. `cloudflare_email.delivery_event` wraps handler execution with `event_id`, `message_id`, and lifecycle `status`; it does not report queue acknowledgement completion. Instrumentation errors include ActiveSupport's exception metadata.
469
330
 
470
- | Class | Trigger |
331
+ | Task | Purpose / credentials |
471
332
  |---|---|
472
- | `ConfigurationError` | Bad init arguments |
473
- | `AuthenticationError` | 401 / 403 |
474
- | `ValidationError` | 400 / 422 or bad input |
475
- | `RateLimitError` | 429 (retried first) |
476
- | `ServerError` | 5xx (retried first) |
477
- | `NetworkError` | Connection failure (retried first) |
478
- | `SecureMessageId::InvalidToken` | Signature mismatch, expired, malformed |
333
+ | `doctor` | Read diagnostics with runtime token; limited read permissions are reported |
334
+ | `send_test FROM=... TO=...` | Send a real message using runtime send permission |
335
+ | `deploy_worker URL=https://...` | Management token: Workers Scripts Edit |
336
+ | `provision_route ADDRESS=...` | Management token: Zone Read, Email Routing Rules Edit; DNS Read for subdomain checks; routing-settings write permission for apex enablement |
337
+ | `provision_catchall DOMAIN=...` | Same routing management permissions; changes the zone-wide catch-all |
338
+ | `dev` | Management token: Workers Scripts Edit; development only |
339
+ | `consume_events` | Separate Queues Read/Write token, queue ID, configured handler |
340
+ | `deliver OPERATION_KEY=...` | Dispatch a saved outbox operation using sending credentials |
341
+ | `replay_events [MESSAGE_ID=...]` | Replay durable receipts for the configured account |
342
+ | `pending_deliveries` | List prepared or uncertain outbox operations for operator review |
479
343
 
480
- Each carries `#status` and `#response` (parsed error body).
344
+ The optional Rails adapter emits `cloudflare_email.outbox_prepare`,
345
+ `cloudflare_email.outbox_send`, and `cloudflare_email.outbox_reconcile` notifications
346
+ with operation identity and resulting state. These do not contain MIME or API
347
+ tokens. A notification is method instrumentation; an enclosing application
348
+ transaction may still roll back. Inspect the durable ledger for authoritative state.
481
349
 
482
- ## Observability
350
+ Management tasks fall back to the runtime token if `management_token` is unset. Restrict scopes and accounts to the operations you need. Event consumers use a separate `queues_token` and do not fall back to a send token.
483
351
 
484
- Subscribe to `ActiveSupport::Notifications`:
352
+ ## SMTP alternative
485
353
 
486
- ```ruby
487
- ActiveSupport::Notifications.subscribe("cloudflare_email.send_raw") do |event|
488
- Rails.logger.info("cf_email status=#{event.payload[:status]} duration=#{event.duration.round(1)}ms")
489
- end
354
+ Cloudflare also supports authenticated SMTP. Existing Rails SMTP applications can use it without this gem's delivery method:
490
355
 
491
- ActiveSupport::Notifications.subscribe("cloudflare_email.ingress") do |event|
492
- StatsD.increment("cf_email.ingress", tags: ["result:#{event.payload[:result]}"])
493
- end
356
+ ```ruby
357
+ config.action_mailer.delivery_method = :smtp
358
+ config.action_mailer.smtp_settings = {
359
+ address: "smtp.mx.cloudflare.net",
360
+ port: 465,
361
+ ssl: true,
362
+ authentication: :plain,
363
+ user_name: "api_token",
364
+ password: ENV.fetch("CLOUDFLARE_SMTP_TOKEN"),
365
+ }
494
366
  ```
495
367
 
496
- | Event | Payload keys |
497
- |---|---|
498
- | `cloudflare_email.send` | `:account_id`, `:path`, `:status`, `:message_id` (nil) |
499
- | `cloudflare_email.send_raw` | `:account_id`, `:path`, `:status`, `:message_id` (nil) |
500
- | `cloudflare_email.ingress` | `:bytes`, `:result` (`:ok` / `:bad_signature` / `:stale`), `:message_id` when `:ok` |
368
+ Cloudflare documents Email Sending Edit permission for SMTP, implicit TLS on port 465, and no outbound STARTTLS on 587. See [SMTP documentation](https://developers.cloudflare.com/email-service/api/send-emails/smtp/). Remove the generated `:cloudflare` initializer override if switching to SMTP.
501
369
 
502
- ## Testing the gem itself
370
+ ## Development and verification
503
371
 
504
372
  ```sh
373
+ bundle install
505
374
  bundle exec rake test
506
- # Against a specific Rails:
507
- BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test
508
- # Worker tests:
509
- cd templates/worker && npm install --legacy-peer-deps && npm test
375
+ bundle exec ruby script/verify_package.rb
376
+ BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle install
377
+ BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle exec rake test
378
+ cd templates/worker
379
+ npm ci
380
+ npm test
381
+ npm run check
382
+ npm audit
383
+ ```
384
+
385
+ Tests include actual Rails boot/installation, ActionMailbox persistence and mailbox processing, task orchestration, HTTP-mocked API behavior, and Worker unit tests. CI also builds and installs the packaged gem and runs a real local workerd-to-Rails check. Run the latter with:
386
+
387
+ ```sh
388
+ BUNDLE_GEMFILE=gemfiles/local_ingress.gemfile bundle install
389
+ BUNDLE_GEMFILE=gemfiles/local_ingress.gemfile bundle exec ruby script/verify_local_ingress.rb
510
390
  ```
511
391
 
512
- ## Status
392
+ Install the Worker tooling first; Node 22+ must be on PATH (or set `NODE_BINARY` to its executable). This uses synthetic mail and temporary loopback services, not a deployed Cloudflare account. The development bundle pins JSON below 3 because current tested Rails versions require its positional-options API.
513
393
 
514
- **v0.1**. Ruby 3.1+, Rails 7.1 / 7.2 / 8.0 / 8.1. Cloudflare Email Service
515
- is itself in public beta. Verified against live Cloudflare end-to-end for
516
- outbound, inbound, Worker deploy, route provisioning, and signed-Message-ID
517
- reply auth. Issues and PRs welcome.
394
+ See the [verification report](docs/verification/2026-09-10.md) for evidence, the historical dogfood inventory, Rebulk integration findings, and remaining live-provider checks.
518
395
 
519
- ## License
396
+ A subsequent [live verification pass](docs/verification/2026-09-10-live.md) exercised isolated sending, deployed ingress, binary attachments, reply threading, real LLM processing, and delivery-event redelivery/acknowledgement under `test.rebulk.com`. The [follow-up report](docs/verification/2026-09-10-followup.md) covers authenticated recipient routing and draft-review improvements after that run. Temporary cloud resources were removed afterward. No DNS changes or RubyGems publication occurred. These dated reports do not constitute live verification of later changes.
520
397
 
521
- MIT.
398
+ MIT license.