cloudflare-email
A Ruby gem for Cloudflare's Email Service
(public beta, April 2026). Send mail from Rails via an ActionMailer delivery
method; receive mail via an ActionMailbox ingress backed by a shipped
Cloudflare Email Worker. Works as a plain Ruby client too.
# Gemfile
gem "cloudflare-email"
Two independent paths
- Send only → skip to Sending mail. No Node, no Workers.
- Send + Receive → Receiving mail. Ships a pure-Ruby Worker deployer. No wrangler. No npm. No dashboard clicking.
- Want to cryptographically verify replies belong to the right thread? → Signed replies.
Sending mail
Setup (3 minutes)
bundle add cloudflare-email
bin/rails generate cloudflare:email:install --no-inbound
bin/rails cloudflare:email:doctor # verify wiring
TO=you@example.com bin/rails cloudflare:email:send_test
Credentials — two options
The gem reads config from Rails credentials first, then env vars. Pick whichever fits your workflow:
Option A: Rails credentials (recommended — encrypted, per-env)
bin/rails credentials:edit --environment production
cloudflare:
account_id: <your-cloudflare-account-id>
api_token: <email-send-scoped-api-token>
Option B: .env / environment variables
CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_API_TOKEN=your-send-scoped-token
Use dotenv-rails, foreman, your platform's secret store (Fly, Render,
Heroku, Kamal) — anything that puts them into ENV.
Dashboard setup (one-time)
- API token:
dash.cloudflare.com/profile/api-tokens→ Create Token → Custom Token. Permission: Account → Email Sending → Send. Scope to your specific account. - Sending Domain: your zone → Email → Email Sending → Sending
Domains → Add Sending Domain. Use a subdomain (e.g.
mail.yourdomain.com), not the apex if you already have Google Workspace there. - SPF + DMARC on the apex (DKIM is auto-published by Cloudflare):
TXT @ v=spf1 include:_spf.mx.cloudflare.net ~all TXT _dmarc v=DMARC1; p=quarantine; rua=mailto:postmaster@yourdomain.com - Wait until the dashboard shows "Verified" before sending (otherwise a vague
email.sending.error.internal_server500 comes back).
Send mail
Standard ActionMailer — the :cloudflare delivery method is registered
automatically:
class WelcomeMailer < ApplicationMailer
def welcome(user)
mail(to: user.email, from: "hello@mail.yourdomain.com", subject: "Welcome") do |format|
format.text { render plain: "Hi #{user.name}" }
format.html { render "welcome_html" }
end
end
end
WelcomeMailer.welcome(user).deliver_later
Attachments, multipart, threading headers, cc/bcc all round-trip through the
underlying send_raw API.
Plain Ruby (no Rails)
require "cloudflare-email"
client = Cloudflare::Email::Client.new(
account_id: ENV["CLOUDFLARE_ACCOUNT_ID"],
api_token: ENV["CLOUDFLARE_API_TOKEN"],
)
response = client.send(
from: { address: "agent@mail.acme.com", name: "Acme Agent" },
to: "user@example.com",
subject: "Hello",
text: "Plain body",
html: "<p>HTML body</p>",
reply_to: "thread+abc@mail.acme.com",
headers: { "In-Reply-To" => "<msg-123@acme.com>" },
attachments: [{
content: Base64.strict_encode64(File.read("report.pdf")),
filename: "report.pdf",
type: "application/pdf",
}],
)
response.success? # => true
response.delivered # => ["user@example.com"]
response. # => nil (Cloudflare does not return a message ID)
For full MIME control: client.send_raw(from:, recipients:, mime_message:).
Receiving mail
Cloudflare Email Routing delivers inbound mail to an Email Worker, not an
HTTPS webhook — you can't just point it at a URL. This gem ships a Worker
that signs each message with HMAC-SHA256 and POSTs it to a Rails ActionMailbox
ingress it sets up for you.
No wrangler, npm, or Node required. The Worker is plain JavaScript; the
gem ships a pure-Ruby deployer that talks directly to Cloudflare's Workers
API. wrangler is supported as an alternative if you prefer the Cloudflare
CLI.
Setup
bundle add cloudflare-email
bin/rails generate cloudflare:email:install # interactive; scaffolds everything
bin/rails credentials:edit # fill in the 4 secrets (below)
bin/rails cloudflare:email:doctor # verify
bin/rails cloudflare:email:deploy_worker URL=https://yourapp.com/rails/action_mailbox/cloudflare/inbound_emails
bin/rails cloudflare:email:provision_route ADDRESS=cole@in.yourdomain.com
That's it. Zero dashboard clicks once your tokens are created.
The interactive installer:
- Copies the Worker template into
cloudflare-worker/+ writes theconfig/initializers/cloudflare_email.rbinitializer. - Scaffolds a default
MainMailbox+ catch-all route (prompt) so inbound mail has somewhere to land on day one. - Runs
bin/rails action_mailbox:install(prompt) if ActionMailbox is missing in the app.
Credentials
Same two options as sending (credentials OR .env). For inbound you need
three values plus an ingress secret:
cloudflare:
account_id: <your-cloudflare-account-id>
api_token: <runtime token — Email Sending: Send>
management_token: <optional; Workers + Email Routing + Zone Read>
ingress_secret: <generated by the installer>
Or via env vars:
CLOUDFLARE_ACCOUNT_ID=...
CLOUDFLARE_API_TOKEN=...
CLOUDFLARE_MANAGEMENT_TOKEN=... # optional
CLOUDFLARE_INGRESS_SECRET=...
Tokens — why two?
For best security, split your tokens into runtime and management:
- Runtime (
api_token):Email Sending → Sendonly. Lives in the app process at runtime. If leaked, attacker can send spam — that's it. - Management (
management_token):Workers Scripts: Edit,Zone: Read,Email Routing: Edit. Used bydeploy_worker,provision_route, anddevtasks. Never loaded by the running Rails app — set it in your deploy environment only, or as a local.envfor your laptop.
If only api_token is set, management tasks fall back to it. Single-token
setups are fine for solo devs / small projects; split tokens are strongly
recommended for production.
Dashboard setup — one step
Only one dashboard visit needed: create the token(s) at
dash.cloudflare.com/profile/api-tokens. Choose the scopes from the
Tokens table.
Everything else — sending domain, Email Routing enablement, MX records, route rules — can be done in the dashboard OR automated from Ruby via the gem's rake tasks. See the rake task reference below.
Write your mailbox
The installer creates MainMailbox with a stub. Replace #process:
# app/mailboxes/main_mailbox.rb
class MainMailbox < ApplicationMailbox
def process
YourAgentJob.perform_later(
from: mail.from.first,
subject: mail.subject,
body: mail.body.decoded,
)
end
end
Route by address or content in ApplicationMailbox:
class ApplicationMailbox < ActionMailbox::Base
routing /^support@/i => :support
routing :all => :main
end
Local development
You need a public HTTPS URL for Cloudflare to POST to. In dev that means
tunneling. Run bin/rails server in one terminal, then:
bin/rails cloudflare:email:dev
That task starts a cloudflared tunnel, updates your deployed Worker's
RAILS_INGRESS_URL secret to point at it, and ties up the terminal until
Ctrl-C. Send mail to your routed address; it flows Cloudflare → Worker →
tunnel → local Rails → your mailbox.
Only cloudflared required — no wrangler, no Node.
Per-environment Worker isolation
The gem names the Worker cloudflare-email-ingress-#{Rails.env} by default.
Dev, staging, and prod deploy as separate scripts with separate secrets.
bin/rails cloudflare:email:dev only ever touches -development, so spinning
up a dev tunnel can never break production's inbound.
Deploy per environment:
RAILS_ENV=production bin/rails cloudflare:email:deploy_worker URL=https://app.example.com/rails/action_mailbox/cloudflare/inbound_emails
RAILS_ENV=staging bin/rails cloudflare:email:deploy_worker URL=https://staging.example.com/rails/action_mailbox/cloudflare/inbound_emails
Route different addresses to different Workers:
RAILS_ENV=production bin/rails cloudflare:email:provision_route ADDRESS=cole@in.yourdomain.com
RAILS_ENV=staging bin/rails cloudflare:email:provision_route ADDRESS=cole@staging.in.yourdomain.com
⚠️ Apex vs subdomain
Don't enable Email Routing on the apex of a domain where colleagues run
email on Google Workspace or Outlook — MX records are domain-level, so
that'd route everyone's mail through Cloudflare first. Use a subdomain
(in.yourdomain.com).
If you want your own cole@yourdomain.com to also reach the agent, set up a
Google Workspace routing rule that BCCs incoming mail to
cole@in.yourdomain.com. You read mail in Gmail normally AND the agent
gets a copy.
Rotating the ingress secret
Rotate Worker and Rails together (no overlap window):
bin/rails credentials:edit— updatecloudflare.ingress_secret.- Re-run
bin/rails cloudflare:email:deploy_worker URL=...to push the new secret to the Worker + redeploy.
If they disagree, inbound mail bounces with 401 and the sender gets a delivery failure (no silent drop).
How inbound flows
Sender's MTA
│ MX lookup resolves to Cloudflare
▼
Cloudflare Email Routing
│ (rule matched, action = "Send to Worker")
▼
cloudflare-email-ingress-{env} Worker (reads message.raw, HMAC-signs)
│ POST with Content-Type: message/rfc822
│ + X-CF-Email-Timestamp + X-CF-Email-Signature
▼
Your Rails app — IngressController
│ (verifies HMAC, 5-min replay window)
▼
ActionMailbox::InboundEmail.create_and_extract_message_id!
│
▼
ApplicationMailbox → YourMailbox#process
If Rails responds non-2xx, the Worker calls message.setReject so the sender
gets a bounce. No silent drops.
Signed replies
Optional but highly recommended for agent email flows: cryptographically
bind replies to the original thread so the inbound side can prove a reply
is legitimate and hasn't been forged. Inspired by Cloudflare's
createSecureReplyEmailResolver
from the JS Agents SDK, but stateless — no Durable Object storage needed.
How it works: sign the outbound Message-ID: with HMAC-SHA256. When a
user replies, their mail client naturally carries the original id into the
In-Reply-To: header. Your mailbox reads + verifies it there, recovers the
payload (thread id, user id, whatever you encoded), and routes accordingly.
- HMAC-SHA256, 30-day default max-age (configurable)
- Stateless — no DB row to look up, no Durable Object
- No catch-all route required — replies come to your normal inbound address
- User-visible reply-to address stays clean (
agent@in.yourdomain.com) - No size constraint on payloads — Message-IDs can be ~900 chars
Outbound
class AgentMailer < ApplicationMailer
def ping(thread)
signed_id = Cloudflare::Email::SecureMessageId.encode(
payload: {
thread_id: thread.id,
user_id: thread.user_id,
kind: "ping",
},
domain: "mail.yourdomain.com",
secret: Rails.application.credentials.dig(:cloudflare, :reply_secret),
)
mail(
to: thread.user.email,
from: "agent@mail.yourdomain.com",
reply_to: "agent@in.yourdomain.com", # clean, routable address
subject: "Re: #{thread.title}",
message_id: signed_id, # sign the Message-ID
) { |f| f.text { render plain: "..." } }
end
end
Inbound
class AgentMailbox < ApplicationMailbox
def process
ref = mail.in_reply_to || Array(mail.references).first
if ref && Cloudflare::Email::SecureMessageId.match?(ref)
payload = Cloudflare::Email::SecureMessageId.decode(
ref,
secret: Rails.application.credentials.dig(:cloudflare, :reply_secret),
)
Thread.find(payload["thread_id"]).ingest(mail)
end
rescue Cloudflare::Email::SecureMessageId::InvalidToken => e
Rails.logger.warn("Invalid signed reply: #{e.}")
end
end
Route replies to agent@in.yourdomain.com normally (provision_route).
The signed state rides in the Message-ID, not the recipient address.
Setup
Add a reply secret to credentials:
cloudflare:
reply_secret: <openssl rand -hex 32>
(Or CLOUDFLARE_REPLY_SECRET in your env.)
That's it. Use the helpers in your mailer + mailbox as shown above.
Compared to Cloudflare's JS SDK
Our SecureMessageId |
CF createSecureReplyEmailResolver |
|
|---|---|---|
| Signing | HMAC-SHA256 (full 64-char hex) | HMAC-SHA256 (full) |
| Carrier | Message-ID: → In-Reply-To: |
Headers + Durable Object lookup |
| Statefulness | Stateless | Stateful (DO storage) |
| Works in plain Rails | Yes | Requires Workers + DO |
Same security properties (HMAC-SHA256, time-boxed with max-age), different
mechanism. SecureMessageId is the idiomatic Rails choice — stateless,
size-unconstrained, and matches email threading natively.
Rake tasks + token scopes
| Task | What it does | Token scopes |
|---|---|---|
doctor |
Verifies credentials, API token validity, ingress secret, ActionMailbox.ingress, delivery method. Exit 1 on failure. |
Email Sending: Send |
send_test TO=addr [FROM=addr] |
One-shot test send. FROM auto-detected from verified sending domains. | Email Sending: Send |
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 |
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 |
provision_catchall DOMAIN=sub.domain |
Points the zone's catch-all rule at the env-scoped Worker. | Zone: Read, Email Routing: Edit |
dev |
Starts a cloudflared tunnel, auto-updates the -development Worker's RAILS_INGRESS_URL to point at it. |
Workers Scripts: Edit |
Create tokens at dash.cloudflare.com/profile/api-tokens → Custom Token.
Scope to a single account. For production, use two tokens: a runtime
(Email Sending: Send only) and a management (everything else).
Reference
Configuration
| Setting | Default | Notes |
|---|---|---|
account_id |
— | Required. |
api_token |
— | Required. Email Sending: Send permission. |
base_url |
https://api.cloudflare.com/client/v4 |
Override for testing. |
retries |
3 |
On 429 / 5xx / network errors. |
initial_backoff |
0.5 |
Seconds. Doubles each retry. |
max_retry_after |
60 |
Upper bound on Retry-After sleep. |
timeout |
30 |
Seconds. Open + read. |
logger |
nil |
Responds to #warn. Logs retries. |
In Rails: config.action_mailer.cloudflare_settings = { ... }.
Retry, rate limit, idempotency
- Retries on 429, 5xx, and network errors with exponential backoff.
Retry-Afteron 429 is honored, capped atmax_retry_after.- Cloudflare does not accept an idempotency key and does not return a
message_idin send responses. Dedupe on your side via the outboundMessage-IDheader if you care about exactly-once semantics.
Errors
All descend from Cloudflare::Email::Error:
| Class | Trigger |
|---|---|
ConfigurationError |
Bad init arguments |
AuthenticationError |
401 / 403 |
ValidationError |
400 / 422 or bad input |
RateLimitError |
429 (retried first) |
ServerError |
5xx (retried first) |
NetworkError |
Connection failure (retried first) |
SecureMessageId::InvalidToken |
Signature mismatch, expired, malformed |
Each carries #status and #response (parsed error body).
Observability
Subscribe to ActiveSupport::Notifications:
ActiveSupport::Notifications.subscribe("cloudflare_email.send_raw") do |event|
Rails.logger.info("cf_email status=#{event.payload[:status]} duration=#{event.duration.round(1)}ms")
end
ActiveSupport::Notifications.subscribe("cloudflare_email.ingress") do |event|
StatsD.increment("cf_email.ingress", tags: ["result:#{event.payload[:result]}"])
end
| Event | Payload keys |
|---|---|
cloudflare_email.send |
:account_id, :path, :status, :message_id (nil) |
cloudflare_email.send_raw |
:account_id, :path, :status, :message_id (nil) |
cloudflare_email.ingress |
:bytes, :result (:ok / :bad_signature / :stale), :message_id when :ok |
Testing the gem itself
bundle exec rake test
# Against a specific Rails:
BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test
# Worker tests:
cd templates/worker && npm install --legacy-peer-deps && npm test
Status
v0.1. Ruby 3.1+, Rails 7.1 / 7.2 / 8.0 / 8.1. Cloudflare Email Service is itself in public beta. Verified against live Cloudflare end-to-end for outbound, inbound, Worker deploy, route provisioning, and signed-Message-ID reply auth. Issues and PRs welcome.
License
MIT.