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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +87 -0
- data/LICENSE.txt +21 -0
- data/README.md +521 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +67 -0
- data/lib/cloudflare/email/client.rb +216 -0
- data/lib/cloudflare/email/credentials.rb +59 -0
- data/lib/cloudflare/email/delivery_method.rb +57 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +62 -0
- data/lib/cloudflare/email/dev_tunnel.rb +97 -0
- data/lib/cloudflare/email/doctor.rb +244 -0
- data/lib/cloudflare/email/engine.rb +25 -0
- data/lib/cloudflare/email/error.rb +20 -0
- data/lib/cloudflare/email/provision_catchall_task.rb +36 -0
- data/lib/cloudflare/email/provision_route_task.rb +34 -0
- data/lib/cloudflare/email/response.rb +61 -0
- data/lib/cloudflare/email/routing_provisioner.rb +220 -0
- data/lib/cloudflare/email/secure_message_id.rb +89 -0
- data/lib/cloudflare/email/send_test.rb +75 -0
- data/lib/cloudflare/email/signing.rb +37 -0
- data/lib/cloudflare/email/task_base.rb +61 -0
- data/lib/cloudflare/email/verification.rb +44 -0
- data/lib/cloudflare/email/version.rb +5 -0
- data/lib/cloudflare/email/worker_deployer.rb +183 -0
- data/lib/cloudflare-email.rb +9 -0
- data/lib/generators/cloudflare/email/install_generator.rb +227 -0
- data/lib/generators/cloudflare/email/templates/initializer.rb +20 -0
- data/lib/generators/cloudflare/email/templates/main_mailbox.rb +32 -0
- data/lib/tasks/cloudflare_email.rake +45 -0
- data/templates/worker/README.md +37 -0
- data/templates/worker/package.json +15 -0
- data/templates/worker/src/index.js +73 -0
- data/templates/worker/test/index.test.ts +139 -0
- data/templates/worker/vitest.config.ts +8 -0
- data/templates/worker/wrangler.toml +10 -0
- 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.
|