cloudflare-email 0.1.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 (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +87 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +521 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +67 -0
  6. data/lib/cloudflare/email/client.rb +216 -0
  7. data/lib/cloudflare/email/credentials.rb +59 -0
  8. data/lib/cloudflare/email/delivery_method.rb +57 -0
  9. data/lib/cloudflare/email/deploy_worker_task.rb +62 -0
  10. data/lib/cloudflare/email/dev_tunnel.rb +97 -0
  11. data/lib/cloudflare/email/doctor.rb +244 -0
  12. data/lib/cloudflare/email/engine.rb +25 -0
  13. data/lib/cloudflare/email/error.rb +20 -0
  14. data/lib/cloudflare/email/provision_catchall_task.rb +36 -0
  15. data/lib/cloudflare/email/provision_route_task.rb +34 -0
  16. data/lib/cloudflare/email/response.rb +61 -0
  17. data/lib/cloudflare/email/routing_provisioner.rb +220 -0
  18. data/lib/cloudflare/email/secure_message_id.rb +89 -0
  19. data/lib/cloudflare/email/send_test.rb +75 -0
  20. data/lib/cloudflare/email/signing.rb +37 -0
  21. data/lib/cloudflare/email/task_base.rb +61 -0
  22. data/lib/cloudflare/email/verification.rb +44 -0
  23. data/lib/cloudflare/email/version.rb +5 -0
  24. data/lib/cloudflare/email/worker_deployer.rb +183 -0
  25. data/lib/cloudflare-email.rb +9 -0
  26. data/lib/generators/cloudflare/email/install_generator.rb +227 -0
  27. data/lib/generators/cloudflare/email/templates/initializer.rb +20 -0
  28. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +32 -0
  29. data/lib/tasks/cloudflare_email.rake +45 -0
  30. data/templates/worker/README.md +37 -0
  31. data/templates/worker/package.json +15 -0
  32. data/templates/worker/src/index.js +73 -0
  33. data/templates/worker/test/index.test.ts +139 -0
  34. data/templates/worker/vitest.config.ts +8 -0
  35. data/templates/worker/wrangler.toml +10 -0
  36. metadata +155 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0f26340bc2629fee65067ec40a1f78ffea475e70a7d102931f3a96ab0f65d621
4
+ data.tar.gz: 291281b738050c94bc80062a977f0b820178b70e4574f49ed449d737c06aa70c
5
+ SHA512:
6
+ metadata.gz: 62b1afce0f1b6afaa33091a1c9540702f7d09e7b1ecb3a3cd71b11a70fbd52162aeb50dba1696fb6a23799096d88be0a1d06ff804b996191018d4641ca5c68a4
7
+ data.tar.gz: 2e2210138612fb118ae196f9c97569e3006e6d6f43657efa5be80e5e7636bd55bb112742aaf08df092d4668d66295fa6a5e35df9e9035946897db99ad1c237bc
data/CHANGELOG.md ADDED
@@ -0,0 +1,87 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - **`Cloudflare::Email::SecureMessageId`** — sign the outbound `Message-ID:`
6
+ with HMAC-SHA256. The recipient's reply naturally carries the signed id
7
+ in `In-Reply-To:`, which the inbound mailbox reads and verifies. Inspired
8
+ by Cloudflare's Agents SDK `createSecureReplyEmailResolver` but stateless
9
+ (no Durable Object storage). Payloads can carry meaningful JSON (thread
10
+ id + user id + action) since Message-IDs don't hit the 64-char local-part
11
+ limit. 30-day default max-age. Verified end-to-end against live Cloudflare
12
+ with a 191-char signed Message-ID carrying a 4-field JSON payload.
13
+ - **`bin/rails cloudflare:email:provision_catchall DOMAIN=...`** — one-shot
14
+ catch-all rule setup for a zone. Useful for bounce handling, dev
15
+ subdomains, and alias routing.
16
+ - **`Cloudflare::Email::Credentials`** — unified credential lookup: Rails
17
+ credentials first, then `CLOUDFLARE_*` env vars. Supports both workflows
18
+ (credentials.yml.enc AND `.env` / platform secret stores).
19
+ - **Two-token split** — tasks that need higher privilege (deploy_worker,
20
+ provision_route, dev) now read `management_token` and fall back to
21
+ `api_token`. Runtime ActionMailer delivery keeps using `api_token`.
22
+ Production: split them. Dev: one token works fine.
23
+ - **Doctor checks token split** and reports a warning for single-token setups.
24
+ - **Per-environment Worker naming**: the default Worker is now
25
+ `cloudflare-email-ingress-#{Rails.env}` instead of a single shared
26
+ `cloudflare-email-ingress`. Critical fix — `cloudflare:email:dev` can no
27
+ longer clobber the production Worker's `RAILS_INGRESS_URL`. Prod and
28
+ dev/staging deploy as separate scripts with separate secrets.
29
+ - **`bin/rails cloudflare:email:provision_route ADDRESS=addr@domain.com`** —
30
+ one-command Email Routing setup via Cloudflare API. Looks up the zone
31
+ owning the domain, enables Email Routing if needed, creates or updates a
32
+ rule binding that address to the env-scoped Worker. Idempotent.
33
+ - **`bin/rails cloudflare:email:deploy_worker`** — pure-Ruby Worker deployer
34
+ that talks to the Cloudflare Workers API directly. No wrangler, Node, or
35
+ npm required. Uploads the Worker script and sets both secrets
36
+ (`INGRESS_SECRET` from Rails credentials, `RAILS_INGRESS_URL` from the
37
+ `URL=` env var) in one shot.
38
+ - **Worker template rewritten in plain JavaScript** (dropped TypeScript from
39
+ the deploy path). Same logic, deployable as an ES module without a build
40
+ step. The `vitest` test suite still covers it.
41
+ - **`cloudflare:email:dev` no longer needs wrangler** — uses the same Ruby
42
+ Worker deployer to update the tunnel URL on the deployed Worker.
43
+ - **Install generator scaffolds a `MainMailbox`** (interactive prompt) with a
44
+ `routing :all => :main` catch-all so inbound emails have somewhere to land
45
+ on a fresh Rails app — avoids `ActionMailbox::Router::RoutingError` on the
46
+ first test message.
47
+ - **Install generator runs `bin/rails action_mailbox:install`** automatically
48
+ if ActionMailbox isn't set up in the app yet (interactive prompt).
49
+ - **`bin/rails cloudflare:email:doctor`** — diagnostic runner that verifies every
50
+ configuration layer (credentials, token validity, account access, sending
51
+ domains, ingress secret, ActionMailbox + delivery method wiring).
52
+ - **`bin/rails cloudflare:email:send_test TO=...`** — one-shot test send using
53
+ current config; auto-infers the FROM address from verified sending domains.
54
+ - **`bin/rails cloudflare:email:dev`** — spins up a `cloudflared` tunnel, updates
55
+ the Worker's `RAILS_INGRESS_URL` secret to point at it, tails Worker logs.
56
+ - **Generator** now detects `wrangler` and offers to deploy the Worker + set
57
+ secrets in one pass (skip via `--deploy-worker=false`).
58
+ - **Generator** post-install message includes dashboard deep-links for API
59
+ tokens, sending domains, and email routing pages.
60
+ - Emit `ActiveSupport::Notifications` events (`cloudflare_email.send`,
61
+ `cloudflare_email.send_raw`, `cloudflare_email.ingress`).
62
+ - Honor `Retry-After` headers on 429 responses (capped at `max_retry_after`,
63
+ default 60s).
64
+ - Generator `--all-envs` flag also configures `development.rb` and `test.rb`.
65
+ - Response handling updated to match the real API shape: `delivered`,
66
+ `queued`, and `permanent_bounces` are arrays of email strings (not hashes);
67
+ `message_id` returns `nil` since Cloudflare does not include one.
68
+ - Ships a vitest test harness for the bundled Cloudflare Worker.
69
+ - Verified against Rails 7.1, 7.2, 8.0, and 8.1 via `gemfiles/*.gemfile`.
70
+
71
+ ## 0.1.0 — 2026-04-16
72
+
73
+ Initial release.
74
+
75
+ - `Cloudflare::Email::Client` — plain-Ruby HTTP client for the Email Sending API.
76
+ Supports `send` (structured) and `send_raw` (RFC822). Retries on 429/5xx/network
77
+ errors with exponential backoff.
78
+ - `Cloudflare::Email::DeliveryMethod` — ActionMailer delivery method registered
79
+ on the `:cloudflare` symbol. Uses `send_raw` so full MIME round-trips.
80
+ - `Cloudflare::Email::IngressController` — ActionMailbox ingress mounted at
81
+ `/rails/action_mailbox/cloudflare/inbound_emails`. Verifies HMAC-SHA256
82
+ signatures in constant time, rejects stale timestamps (5-min replay window).
83
+ - Cloudflare Email Worker template (`templates/worker/`) that signs and forwards
84
+ inbound mail to the Rails ingress.
85
+ - `cloudflare:email:install` generator that writes the initializer, copies the
86
+ Worker template, generates a strong ingress secret, and prints the deploy
87
+ commands.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cole
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,521 @@
1
+ # cloudflare-email
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.
7
+
8
+ ```ruby
9
+ # Gemfile
10
+ gem "cloudflare-email"
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Two independent paths
16
+
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).
22
+
23
+ ---
24
+
25
+ # Sending mail
26
+
27
+ ## Setup (3 minutes)
28
+
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
34
+ ```
35
+
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
+ ```sh
44
+ bin/rails credentials:edit --environment production
45
+ ```
46
+
47
+ ```yaml
48
+ cloudflare:
49
+ account_id: <your-cloudflare-account-id>
50
+ api_token: <email-send-scoped-api-token>
51
+ ```
52
+
53
+ **Option B: `.env` / environment variables**
54
+
55
+ ```env
56
+ CLOUDFLARE_ACCOUNT_ID=your-account-id
57
+ CLOUDFLARE_API_TOKEN=your-send-scoped-token
58
+ ```
59
+
60
+ Use `dotenv-rails`, `foreman`, your platform's secret store (Fly, Render,
61
+ Heroku, Kamal) — anything that puts them into `ENV`.
62
+
63
+ ## Dashboard setup (one-time)
64
+
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).
79
+
80
+ ## Send mail
81
+
82
+ Standard ActionMailer — the `:cloudflare` delivery method is registered
83
+ automatically:
84
+
85
+ ```ruby
86
+ class WelcomeMailer < ApplicationMailer
87
+ 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" }
91
+ end
92
+ end
93
+ end
94
+
95
+ WelcomeMailer.welcome(user).deliver_later
96
+ ```
97
+
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:)`.
132
+
133
+ ---
134
+
135
+ # Receiving mail
136
+
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.
141
+
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.
146
+
147
+ ## Setup
148
+
149
+ ```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
156
+ ```
157
+
158
+ That's it. Zero dashboard clicks once your tokens are created.
159
+
160
+ The interactive installer:
161
+
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.
168
+
169
+ ## Credentials
170
+
171
+ Same two options as sending (credentials OR `.env`). For inbound you need
172
+ three values plus an ingress secret:
173
+
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>
180
+ ```
181
+
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=...
189
+ ```
190
+
191
+ ## Tokens — why two?
192
+
193
+ For best security, split your tokens into runtime and management:
194
+
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.
201
+
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
217
+
218
+ The installer creates `MainMailbox` with a stub. Replace `#process`:
219
+
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
231
+ ```
232
+
233
+ Route by address or content in `ApplicationMailbox`:
234
+
235
+ ```ruby
236
+ class ApplicationMailbox < ActionMailbox::Base
237
+ routing /^support@/i => :support
238
+ routing :all => :main
239
+ end
240
+ ```
241
+
242
+ ## Local development
243
+
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:
246
+
247
+ ```sh
248
+ bin/rails cloudflare:email:dev
249
+ ```
250
+
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.
255
+
256
+ Only `cloudflared` required — no wrangler, no Node.
257
+
258
+ ## Per-environment Worker isolation
259
+
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.
264
+
265
+ Deploy per environment:
266
+
267
+ ```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
270
+ ```
271
+
272
+ Route different addresses to different Workers:
273
+
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
+ ```
278
+
279
+ ## ⚠️ Apex vs subdomain
280
+
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`).
285
+
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.
290
+
291
+ ## Rotating the ingress secret
292
+
293
+ Rotate Worker and Rails together (no overlap window):
294
+
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.
298
+
299
+ If they disagree, inbound mail bounces with 401 and the sender gets a
300
+ delivery failure (no silent drop).
301
+
302
+ ## How inbound flows
303
+
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
322
+ ```
323
+
324
+ If Rails responds non-2xx, the Worker calls `message.setReject` so the sender
325
+ gets a bounce. No silent drops.
326
+
327
+ ---
328
+
329
+ # Signed replies
330
+
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.
336
+
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.
341
+
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
347
+
348
+ ## Outbound
349
+
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
375
+
376
+ ```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
390
+ end
391
+ ```
392
+
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.)
406
+
407
+ That's it. Use the helpers in your mailer + mailbox as shown above.
408
+
409
+ ## Compared to Cloudflare's JS SDK
410
+
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 |
417
+
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.
421
+
422
+ ---
423
+
424
+ # Rake tasks + token scopes
425
+
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` |
434
+
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).
438
+
439
+ ---
440
+
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. |
455
+
456
+ In Rails: `config.action_mailer.cloudflare_settings = { ... }`.
457
+
458
+ ## Retry, rate limit, idempotency
459
+
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.
465
+
466
+ ## Errors
467
+
468
+ All descend from `Cloudflare::Email::Error`:
469
+
470
+ | Class | Trigger |
471
+ |---|---|
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 |
479
+
480
+ Each carries `#status` and `#response` (parsed error body).
481
+
482
+ ## Observability
483
+
484
+ Subscribe to `ActiveSupport::Notifications`:
485
+
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
490
+
491
+ ActiveSupport::Notifications.subscribe("cloudflare_email.ingress") do |event|
492
+ StatsD.increment("cf_email.ingress", tags: ["result:#{event.payload[:result]}"])
493
+ end
494
+ ```
495
+
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` |
501
+
502
+ ## Testing the gem itself
503
+
504
+ ```sh
505
+ 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
510
+ ```
511
+
512
+ ## Status
513
+
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.
518
+
519
+ ## License
520
+
521
+ MIT.