cloudflare-email 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +149 -1
- data/README.md +287 -385
- data/SECURITY.md +51 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +33 -21
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
- data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
- data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
- data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
- data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
- data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
- data/docs/activerecord-tenanted.md +181 -0
- data/docs/architecture.md +105 -0
- data/docs/custom-ingress.md +207 -0
- data/docs/delivery-events.md +186 -0
- data/docs/features.md +138 -0
- data/docs/getting-started.md +308 -0
- data/docs/mailboxes.md +363 -0
- data/docs/management-engine.md +190 -0
- data/docs/outbox.md +175 -0
- data/docs/routing-deliveries.md +94 -0
- data/docs/routing-diagnostics.md +68 -0
- data/docs/tenant-mailboxes-plan.md +149 -0
- data/docs/thread-correlation.md +27 -0
- data/docs/troubleshooting.md +104 -0
- data/docs/upgrading-0.2.md +101 -0
- data/docs/upgrading-0.3.md +55 -0
- data/docs/verification/2026-09-10-followup.md +49 -0
- data/docs/verification/2026-09-10-install-upgrade.md +86 -0
- data/docs/verification/2026-09-10-live.md +102 -0
- data/docs/verification/2026-09-10.md +175 -0
- data/docs/verification/2026-09-11-custom-ingress.md +49 -0
- data/docs/verification/2026-09-11-extraction.md +32 -0
- data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
- data/docs/verification/2026-09-11-security.md +76 -0
- data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
- data/docs/verification/2026-09-12-destination-context.md +46 -0
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
- data/examples/plain_ruby.rb +24 -0
- data/lib/cloudflare/email/active_record/base.rb +60 -0
- data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
- data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
- data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
- data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
- data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
- data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
- data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
- data/lib/cloudflare/email/active_record/outbox.rb +219 -0
- data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
- data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
- data/lib/cloudflare/email/active_record.rb +5 -0
- data/lib/cloudflare/email/check_route_task.rb +21 -0
- data/lib/cloudflare/email/client.rb +93 -18
- data/lib/cloudflare/email/consume_events_task.rb +22 -0
- data/lib/cloudflare/email/delivery_event.rb +70 -0
- data/lib/cloudflare/email/delivery_method.rb +5 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +6 -3
- data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
- data/lib/cloudflare/email/dev_tunnel.rb +31 -5
- data/lib/cloudflare/email/doctor.rb +16 -38
- data/lib/cloudflare/email/endpoint.rb +24 -0
- data/lib/cloudflare/email/engine.rb +33 -0
- data/lib/cloudflare/email/envelope.rb +58 -0
- data/lib/cloudflare/email/event_consumer.rb +95 -0
- data/lib/cloudflare/email/ingress.rb +115 -0
- data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
- data/lib/cloudflare/email/mailboxes/events.rb +112 -0
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
- data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
- data/lib/cloudflare/email/mailboxes/models.rb +165 -0
- data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
- data/lib/cloudflare/email/mailboxes/service.rb +372 -0
- data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
- data/lib/cloudflare/email/mailboxes.rb +11 -0
- data/lib/cloudflare/email/management/adapter.rb +31 -0
- data/lib/cloudflare/email/management/configuration.rb +17 -0
- data/lib/cloudflare/email/management/engine.rb +15 -0
- data/lib/cloudflare/email/management/management.css +68 -0
- data/lib/cloudflare/email/management/routes.rb +14 -0
- data/lib/cloudflare/email/management.rb +7 -0
- data/lib/cloudflare/email/message_id.rb +12 -0
- data/lib/cloudflare/email/provider_metadata.rb +77 -0
- data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
- data/lib/cloudflare/email/provision_route_task.rb +2 -1
- data/lib/cloudflare/email/replay_events_job.rb +18 -0
- data/lib/cloudflare/email/response.rb +21 -3
- data/lib/cloudflare/email/routing_analytics.rb +196 -0
- data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
- data/lib/cloudflare/email/routing_provisioner.rb +71 -45
- data/lib/cloudflare/email/send_job.rb +41 -0
- data/lib/cloudflare/email/send_test.rb +5 -29
- data/lib/cloudflare/email/signing.rb +2 -2
- data/lib/cloudflare/email/tenancy.rb +78 -0
- data/lib/cloudflare/email/tenant_job_context.rb +90 -0
- data/lib/cloudflare/email/verification.rb +38 -8
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +41 -8
- data/lib/cloudflare-email.rb +4 -1
- data/lib/generators/cloudflare/email/install_generator.rb +53 -37
- data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
- data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
- data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +58 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
- data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
- data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
- data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
- data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
- data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
- data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
- data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
- data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
- data/lib/tasks/cloudflare_email.rake +42 -4
- data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
- data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
- data/templates/deploy-to-cloudflare/.gitignore +6 -0
- data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
- data/templates/deploy-to-cloudflare/README.md +84 -0
- data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
- data/templates/deploy-to-cloudflare/package.json +36 -0
- data/templates/deploy-to-cloudflare/src/index.js +445 -0
- data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
- data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
- data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
- data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
- data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
- data/templates/worker/README.md +231 -16
- data/templates/worker/docs/durable-inbound.md +149 -0
- data/templates/worker/package-lock.json +2627 -0
- data/templates/worker/package.json +8 -5
- data/templates/worker/scripts/wrangler.mjs +16 -0
- data/templates/worker/src/index.js +397 -25
- data/templates/worker/test/durable.test.ts +193 -0
- data/templates/worker/test/index.test.ts +183 -13
- data/templates/worker/test/relay.test.ts +226 -0
- data/templates/worker/wrangler.toml +63 -4
- metadata +154 -7
- data/lib/cloudflare/email/secure_message_id.rb +0 -89
data/README.md
CHANGED
|
@@ -1,93 +1,84 @@
|
|
|
1
1
|
# cloudflare-email
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
9
|
-
# Gemfile
|
|
10
|
-
gem "cloudflare-email"
|
|
11
|
-
```
|
|
5
|
+
Version **0.3.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
|
+
[](https://deploy.workers.cloudflare.com/?url=https://github.com/cole-robertson/cloudflare-email/tree/main/templates/deploy-to-cloudflare)
|
|
14
8
|
|
|
15
|
-
|
|
9
|
+
Deploy the inbound Worker with guided R2/Queue provisioning and secret setup. [Prepare Rails and connect your email address](templates/deploy-to-cloudflare/README.md).
|
|
16
10
|
|
|
17
|
-
|
|
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).
|
|
11
|
+
## Start here
|
|
22
12
|
|
|
23
|
-
|
|
13
|
+
**Sending email?** Start with the [step-by-step Rails guide](docs/getting-started.md).
|
|
14
|
+
**Building a mailbox?** Follow the same guide through receiving, the SQLite-compatible outbox, and delivery tracking.
|
|
15
|
+
**Using plain Ruby?** Jump to [Plain Ruby](#plain-ruby); Rails and a database are optional.
|
|
24
16
|
|
|
25
|
-
|
|
17
|
+
| Guide | What you will learn |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| [Features at a glance](docs/features.md) | Everything the gem handles, and what your app supplies |
|
|
20
|
+
| [Getting started](docs/getting-started.md) | Install, send your first email, receive replies, and save reliable send operations |
|
|
21
|
+
| [Troubleshooting](docs/troubleshooting.md) | What to check when mail or delivery updates do not arrive |
|
|
22
|
+
| [Managed mailboxes](docs/mailboxes.md) | Create inboxes and aliases, read/archive mail, and send from a mailbox |
|
|
23
|
+
| [Management engine](docs/management-engine.md) | Mount an optional server-rendered mailbox UI using your app's authentication |
|
|
24
|
+
| [Custom ingress](docs/custom-ingress.md) | Reuse authentication, signed metadata, and tenant routing in your existing ingestion pipeline |
|
|
25
|
+
| [Routing diagnostics](docs/routing-diagnostics.md) | Inspect exact-domain DNS and Worker routes without changing infrastructure |
|
|
26
|
+
| [Reusable Worker pipeline](templates/worker/README.md#reuse-the-transport-in-an-existing-worker) | Keep custom backend and archive policies while sharing bounded email forwarding |
|
|
27
|
+
| [Durable inbound delivery](templates/worker/README.md#durable-inbound-delivery) | Store incoming mail in R2 and recover Rails outages with queued retries and scheduled recovery |
|
|
28
|
+
| [Routing delivery confirmation](docs/routing-deliveries.md) | Confirm qualifying normal-address deliveries using authenticated Routing analytics |
|
|
29
|
+
| [SQLite tenant databases](docs/activerecord-tenanted.md) | Give each organization its own SQLite database with `activerecord-tenanted` |
|
|
30
|
+
| [Durable outbox](docs/outbox.md) | Detailed setup, callbacks, retries, and recovery |
|
|
31
|
+
| [Delivery events](docs/delivery-events.md) | Cloudflare Queue setup and recipient status tracking |
|
|
32
|
+
| [Upgrading to 0.3](docs/upgrading-0.3.md) | Changes needed for an existing installation |
|
|
26
33
|
|
|
27
|
-
|
|
34
|
+
The sections below are the configuration and API reference.
|
|
28
35
|
|
|
29
|
-
|
|
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
|
|
36
|
+
## Install and send from Rails
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
whichever fits your workflow:
|
|
38
|
+
Add version 0.3 to your Gemfile. Existing users should follow the [upgrade guide](docs/upgrading-0.3.md), especially before deploying the durable Worker:
|
|
40
39
|
|
|
41
|
-
|
|
40
|
+
```ruby
|
|
41
|
+
gem "cloudflare-email", "~> 0.3.0"
|
|
42
|
+
```
|
|
42
43
|
|
|
43
44
|
```sh
|
|
44
|
-
|
|
45
|
+
bundle install
|
|
46
|
+
bin/rails generate cloudflare:email:install --no-inbound
|
|
45
47
|
```
|
|
46
48
|
|
|
49
|
+
Add Rails credentials (encrypted, per environment) or environment variables:
|
|
50
|
+
|
|
47
51
|
```yaml
|
|
48
52
|
cloudflare:
|
|
49
|
-
account_id:
|
|
50
|
-
api_token:
|
|
53
|
+
account_id: your-account-id
|
|
54
|
+
api_token: your-email-sending-token
|
|
51
55
|
```
|
|
52
56
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
CLOUDFLARE_ACCOUNT_ID=your-account-id
|
|
57
|
-
CLOUDFLARE_API_TOKEN=your-send-scoped-token
|
|
57
|
+
```sh
|
|
58
|
+
export CLOUDFLARE_ACCOUNT_ID=your-account-id
|
|
59
|
+
export CLOUDFLARE_API_TOKEN=your-email-sending-token
|
|
58
60
|
```
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
Heroku, Kamal) — anything that puts them into `ENV`.
|
|
62
|
+
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
63
|
|
|
63
|
-
|
|
64
|
+
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
65
|
|
|
65
|
-
|
|
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).
|
|
66
|
+
Create a token with permission to send for your account, and verify the domain in the dashboard before testing:
|
|
79
67
|
|
|
80
|
-
|
|
68
|
+
```sh
|
|
69
|
+
bin/rails cloudflare:email:doctor
|
|
70
|
+
FROM=hello@mail.example.com TO=you@example.net bin/rails cloudflare:email:send_test
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`doctor` checks configuration and available read access. It does not send mail or prove deliverability. `send_test` requires both `FROM` and `TO`.
|
|
81
74
|
|
|
82
|
-
Standard ActionMailer
|
|
83
|
-
automatically:
|
|
75
|
+
Standard ActionMailer works:
|
|
84
76
|
|
|
85
77
|
```ruby
|
|
86
78
|
class WelcomeMailer < ApplicationMailer
|
|
87
79
|
def welcome(user)
|
|
88
|
-
mail(
|
|
89
|
-
format.text { render plain: "
|
|
90
|
-
format.html { render "welcome_html" }
|
|
80
|
+
mail(from: "hello@mail.example.com", to: user.email, subject: "Welcome") do |format|
|
|
81
|
+
format.text { render plain: "Hello!" }
|
|
91
82
|
end
|
|
92
83
|
end
|
|
93
84
|
end
|
|
@@ -95,427 +86,338 @@ end
|
|
|
95
86
|
WelcomeMailer.welcome(user).deliver_later
|
|
96
87
|
```
|
|
97
88
|
|
|
98
|
-
|
|
99
|
-
underlying `send_raw` API.
|
|
100
|
-
|
|
101
|
-
## Plain Ruby (no Rails)
|
|
102
|
-
|
|
103
|
-
```ruby
|
|
104
|
-
require "cloudflare-email"
|
|
89
|
+
Multipart, attachments, cc/bcc, and threading headers are serialized through `send_raw`. Cloudflare still controls final delivery and header acceptance.
|
|
105
90
|
|
|
106
|
-
|
|
107
|
-
account_id: ENV["CLOUDFLARE_ACCOUNT_ID"],
|
|
108
|
-
api_token: ENV["CLOUDFLARE_API_TOKEN"],
|
|
109
|
-
)
|
|
91
|
+
## Managed mailboxes and optional tenancy
|
|
110
92
|
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
)
|
|
93
|
+
**Multi-tenancy is off by default.** Installing the gem does not create tenant
|
|
94
|
+
databases, install a tenancy library, or change your application's database routing.
|
|
125
95
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
96
|
+
| Setup | What you explicitly enable |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| Plain Ruby sending | Nothing extra; no Rails/database required |
|
|
99
|
+
| Managed mailboxes in one database | Run the mailbox generator and load the optional module |
|
|
100
|
+
| Separate tenant databases | Configure `Tenancy.configure(...)` with your application's base class and switching adapter before loading models |
|
|
130
101
|
|
|
131
|
-
|
|
102
|
+
The mailbox API uses a tenant key to group and scope records even in one database.
|
|
103
|
+
That key alone does not enable database switching. `activerecord-tenanted` is an
|
|
104
|
+
optional application dependency, not a runtime dependency of this gem.
|
|
132
105
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
106
|
+
Create mailboxes in code with the optional mailbox module. It includes named
|
|
107
|
+
mailboxes, aliases, ownership references, read/archive state, retained raw mail,
|
|
108
|
+
mailbox-authorized sends, and tenant-aware delivery-event recovery:
|
|
148
109
|
|
|
149
110
|
```sh
|
|
150
|
-
|
|
151
|
-
bin/rails
|
|
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
|
|
111
|
+
bin/rails generate cloudflare:email:mailboxes
|
|
112
|
+
bin/rails db:migrate
|
|
156
113
|
```
|
|
157
114
|
|
|
158
|
-
|
|
115
|
+
After registering and verifying a receiving domain:
|
|
159
116
|
|
|
160
|
-
|
|
117
|
+
```ruby
|
|
118
|
+
Cloudflare::Email::Mailboxes.for_tenant("organization-123") do |inboxes|
|
|
119
|
+
mailbox = inboxes.create(name: "Support", address: "support@acme.example.com",
|
|
120
|
+
owner_ref: "team:42")
|
|
121
|
+
# Provision and activate its address route before receiving or sending mail.
|
|
122
|
+
end
|
|
123
|
+
```
|
|
161
124
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
125
|
+
Use one database or an application-provided tenant base class. The optional
|
|
126
|
+
`activerecord-tenanted` integration is tested with separate SQLite databases,
|
|
127
|
+
including jobs and overlapping record IDs. Your app still authorizes users and
|
|
128
|
+
provides the UI. Start with [the mailbox guide](docs/mailboxes.md), and configure
|
|
129
|
+
[tenant connections](docs/activerecord-tenanted.md) before loading models when
|
|
130
|
+
using separate databases. Plain Ruby users do not load this module.
|
|
168
131
|
|
|
169
|
-
##
|
|
132
|
+
## Durable mailbox delivery from Rails
|
|
170
133
|
|
|
171
|
-
|
|
172
|
-
|
|
134
|
+
For a mailbox application, use the optional outbox instead of recreating send
|
|
135
|
+
claims, attempt records, recipient outcomes, and event correlation:
|
|
173
136
|
|
|
174
|
-
```
|
|
175
|
-
cloudflare:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
management_token: <optional; Workers + Email Routing + Zone Read>
|
|
179
|
-
ingress_secret: <generated by the installer>
|
|
137
|
+
```sh
|
|
138
|
+
bin/rails generate cloudflare:email:tracking
|
|
139
|
+
bin/rails generate cloudflare:email:outbox
|
|
140
|
+
bin/rails db:migrate
|
|
180
141
|
```
|
|
181
142
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
143
|
+
```ruby
|
|
144
|
+
account = Cloudflare::Email::Credentials.account_id
|
|
145
|
+
operation = Cloudflare::Email::ActiveRecord::Outbox.prepare_mail(
|
|
146
|
+
account_id: account,
|
|
147
|
+
operation_key: "reply:#{draft.id}:revision:#{draft.revision}",
|
|
148
|
+
mail: ReplyMailer.reply(draft).message,
|
|
149
|
+
)
|
|
150
|
+
# Enqueue after the transaction that prepared the operation commits.
|
|
151
|
+
Cloudflare::Email::SendJob.perform_later(account, operation.operation_key)
|
|
189
152
|
```
|
|
190
153
|
|
|
191
|
-
|
|
154
|
+
The job sends the stored MIME once per operation claim. Accepted repeats cannot
|
|
155
|
+
resend; uncertain sends remain blocked for audited reconciliation. Each recipient
|
|
156
|
+
has separate acceptance evidence and lifecycle status. `DeliveryEvents` persists,
|
|
157
|
+
deduplicates, correlates, orders, and replays queue events, with transactional
|
|
158
|
+
callbacks for your product records. The reference Rails inbox uses these same
|
|
159
|
+
tables and APIs for compose, replies, approved AI drafts, and recovery.
|
|
192
160
|
|
|
193
|
-
|
|
161
|
+
`ReplyMailer`, `draft`, and its revision are application examples. Authorize the
|
|
162
|
+
send before preparation and use a durable job backend. See the [complete outbox
|
|
163
|
+
setup and recovery guide](docs/outbox.md) and [gem/inbox architecture](docs/architecture.md).
|
|
164
|
+
The adapter is opt-in; the plain Ruby client does not load Rails or ActiveRecord.
|
|
194
165
|
|
|
195
|
-
|
|
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.
|
|
166
|
+
## Plain Ruby
|
|
201
167
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
recommended for production.
|
|
168
|
+
```ruby
|
|
169
|
+
require "cloudflare-email"
|
|
205
170
|
|
|
206
|
-
|
|
171
|
+
client = Cloudflare::Email::Client.new(
|
|
172
|
+
account_id: ENV.fetch("CLOUDFLARE_ACCOUNT_ID"),
|
|
173
|
+
api_token: ENV.fetch("CLOUDFLARE_API_TOKEN"),
|
|
174
|
+
)
|
|
207
175
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
176
|
+
response = client.send(
|
|
177
|
+
from: { address: "hello@mail.example.com", name: "Example" },
|
|
178
|
+
to: "you@example.net",
|
|
179
|
+
subject: "Hello",
|
|
180
|
+
text: "Plain body",
|
|
181
|
+
html: "<p>HTML body</p>",
|
|
182
|
+
reply_to: "support@in.example.com",
|
|
183
|
+
)
|
|
184
|
+
response.message_id # Provider ID when returned; nil on older responses
|
|
185
|
+
response.delivered # Immediately delivered recipients
|
|
186
|
+
response.queued # Recipients queued for later delivery
|
|
187
|
+
response.permanent_bounces # Recipients that permanently bounced
|
|
188
|
+
response.suppressed_recipients # Recipients dropped by suppression policy
|
|
189
|
+
```
|
|
211
190
|
|
|
212
|
-
|
|
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.
|
|
191
|
+
`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:)`.
|
|
215
192
|
|
|
216
|
-
|
|
193
|
+
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.
|
|
217
194
|
|
|
218
|
-
|
|
195
|
+
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/).
|
|
219
196
|
|
|
220
|
-
|
|
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
|
-
```
|
|
197
|
+
## Receive through ActionMailbox
|
|
232
198
|
|
|
233
|
-
|
|
199
|
+
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.
|
|
234
200
|
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
routing /^support@/i => :support
|
|
238
|
-
routing :all => :main
|
|
239
|
-
end
|
|
201
|
+
```sh
|
|
202
|
+
bin/rails generate cloudflare:email:install
|
|
240
203
|
```
|
|
241
204
|
|
|
242
|
-
|
|
205
|
+
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.
|
|
243
206
|
|
|
244
|
-
|
|
245
|
-
tunneling. Run `bin/rails server` in one terminal, then:
|
|
207
|
+
Add the generated ingress secret and an optional management token:
|
|
246
208
|
|
|
247
|
-
```
|
|
248
|
-
|
|
209
|
+
```yaml
|
|
210
|
+
cloudflare:
|
|
211
|
+
account_id: your-account-id
|
|
212
|
+
api_token: your-runtime-send-token
|
|
213
|
+
management_token: your-deployment-token
|
|
214
|
+
ingress_secret: the-generated-random-secret
|
|
249
215
|
```
|
|
250
216
|
|
|
251
|
-
|
|
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.
|
|
217
|
+
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.
|
|
255
218
|
|
|
256
|
-
|
|
219
|
+
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.
|
|
220
|
+
2. Deploy the environment's Worker and create its route:
|
|
257
221
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
The
|
|
261
|
-
|
|
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:
|
|
222
|
+
First create the private R2 bucket and Queue, then deploy the generated Wrangler
|
|
223
|
+
configuration using the [durable setup guide](templates/worker/docs/durable-inbound.md).
|
|
224
|
+
The Ruby task below updates that provisioned Worker while preserving its bindings.
|
|
225
|
+
It refuses a missing bucket/queue binding or recovery schedule before changing code.
|
|
266
226
|
|
|
267
227
|
```sh
|
|
268
|
-
RAILS_ENV=production
|
|
269
|
-
RAILS_ENV=
|
|
228
|
+
RAILS_ENV=production bin/rails cloudflare:email:deploy_worker URL=https://app.example.com/rails/action_mailbox/cloudflare/inbound_emails
|
|
229
|
+
RAILS_ENV=production bin/rails cloudflare:email:provision_route ADDRESS=support@in.example.com
|
|
270
230
|
```
|
|
271
231
|
|
|
272
|
-
|
|
232
|
+
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
233
|
|
|
274
|
-
|
|
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
|
-
```
|
|
234
|
+
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
235
|
|
|
279
|
-
|
|
236
|
+
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
237
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
238
|
+
```ruby
|
|
239
|
+
class ApplicationMailbox < ActionMailbox::Base
|
|
240
|
+
routing ->(inbound) { Cloudflare::Email::Envelope.for(inbound)&.fetch("to")&.match?(/\Asupport@/i) } => :support
|
|
241
|
+
end
|
|
242
|
+
```
|
|
285
243
|
|
|
286
|
-
|
|
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.
|
|
244
|
+
`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
245
|
|
|
291
|
-
|
|
246
|
+
The bundled Worker uses v2 signatures by default; missing or v1 signatures are rejected. The custom-ingress API also supports opt-in v3 signatures carrying authenticated Worker metadata. See [custom ingestion](docs/custom-ingress.md) and upgrade the Rails receiver before enabling v3 in a custom Worker. When upgrading from v1, coordinate Rails and Worker deployment while ingress is paused. Both envelope versions require 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
247
|
|
|
293
|
-
|
|
248
|
+
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
249
|
|
|
295
|
-
|
|
296
|
-
2. Re-run `bin/rails cloudflare:email:deploy_worker URL=...` to push the new
|
|
297
|
-
secret to the Worker + redeploy.
|
|
250
|
+
The Worker has a 15-second Rails request timeout and rejects redirects. Its default [durable inbound path](templates/worker/README.md#durable-inbound-delivery) saves messages in R2 before acceptance, then retries Rails handoffs through Queues and scheduled recovery. Provision the resources before deploying. `INBOUND_DELIVERY_MODE=direct` selects the single-attempt fallback, which can reject mail during an outage; existing retained mail keeps recovering. Rails storage acceptance does not guarantee later mailbox-job success. Monitor Rails jobs and Cloudflare Worker logs.
|
|
298
251
|
|
|
299
|
-
|
|
300
|
-
delivery failure (no silent drop).
|
|
252
|
+
### Local development and deployment
|
|
301
253
|
|
|
302
|
-
|
|
254
|
+
For a simple development tunnel, use the explicit direct fallback. Start Rails, then:
|
|
303
255
|
|
|
256
|
+
```sh
|
|
257
|
+
INBOUND_DELIVERY_MODE=direct bin/rails cloudflare:email:deploy_worker
|
|
258
|
+
bin/rails cloudflare:email:dev
|
|
304
259
|
```
|
|
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
260
|
|
|
327
|
-
|
|
261
|
+
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.
|
|
328
262
|
|
|
329
|
-
|
|
263
|
+
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.
|
|
330
264
|
|
|
331
|
-
|
|
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.
|
|
265
|
+
Ruby deployment and Wrangler use matching names: `cloudflare-email-ingress-development`, `-staging`, and `-production`. Initial durable infrastructure deployment uses Wrangler and requires Node 22.12+ (or a supported newer version):
|
|
336
266
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
267
|
+
```sh
|
|
268
|
+
cd cloudflare-worker
|
|
269
|
+
npm ci
|
|
270
|
+
npx wrangler secret put INGRESS_SECRET --env production
|
|
271
|
+
npx wrangler secret put RAILS_INGRESS_URL --env production
|
|
272
|
+
npm run deploy -- --env production
|
|
273
|
+
```
|
|
341
274
|
|
|
342
|
-
|
|
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
|
|
275
|
+
See the [Worker README](templates/worker/README.md). Existing deployments/templates are not automatically migrated; verify routing before switching names.
|
|
347
276
|
|
|
348
|
-
|
|
277
|
+
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.
|
|
349
278
|
|
|
350
|
-
|
|
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
|
-
```
|
|
279
|
+
## Outbound delivery events
|
|
373
280
|
|
|
374
|
-
|
|
281
|
+
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
282
|
|
|
376
283
|
```ruby
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
284
|
+
consumer = Cloudflare::Email::EventConsumer.new(
|
|
285
|
+
account_id: ENV.fetch("CLOUDFLARE_ACCOUNT_ID"),
|
|
286
|
+
api_token: ENV.fetch("CLOUDFLARE_QUEUES_TOKEN"),
|
|
287
|
+
queue_id: ENV.fetch("CLOUDFLARE_EVENT_QUEUE_ID"),
|
|
288
|
+
domains: ["mail.example.com"],
|
|
289
|
+
)
|
|
290
|
+
consumer.poll do |event|
|
|
291
|
+
DeliveryEventProcessor.call(event) # Your durable, idempotent application handler
|
|
390
292
|
end
|
|
391
293
|
```
|
|
392
294
|
|
|
393
|
-
|
|
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.
|
|
295
|
+
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).
|
|
408
296
|
|
|
409
|
-
|
|
297
|
+
Rails applications can run `bin/rails generate cloudflare:email:tracking` and
|
|
298
|
+
`bin/rails db:migrate` to install optional durable event receipts. The adapter
|
|
299
|
+
commits before ACK, deduplicates account/event IDs, retains unmatched events,
|
|
300
|
+
and replays them with transactional application handlers. It requires explicit
|
|
301
|
+
opt-in; the plain Ruby client stays independent of ActiveRecord.
|
|
410
302
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
| Statefulness | Stateless | Stateful (DO storage) |
|
|
416
|
-
| Works in plain Rails | Yes | Requires Workers + DO |
|
|
303
|
+
Shared helpers include `Response#accepted?` (any provider acceptance, not final
|
|
304
|
+
delivery) and `DeliveryEvent#supersedes?(occurred_at:, terminal:)` for ordering
|
|
305
|
+
matched recipient events. See the [architecture guide](docs/architecture.md) for
|
|
306
|
+
what the gem provides and what belongs in the inbox product.
|
|
417
307
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
308
|
+
Applications using the outbox can use `DeliveryEvents.record(event)` and
|
|
309
|
+
`DeliveryEvents.replay(account_id:, message_id:)` for built-in correlation and
|
|
310
|
+
recipient-state projection. Applications with their own delivery schema can
|
|
311
|
+
continue to use the lower-level `EventInbox` with an explicit handler.
|
|
421
312
|
|
|
422
|
-
|
|
313
|
+
## Thread correlation
|
|
423
314
|
|
|
424
|
-
|
|
315
|
+
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.
|
|
425
316
|
|
|
426
|
-
|
|
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` |
|
|
317
|
+
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.
|
|
434
318
|
|
|
435
|
-
|
|
436
|
-
Scope to a single account. For production, use two tokens: a runtime
|
|
437
|
-
(`Email Sending: Send` only) and a management (everything else).
|
|
319
|
+
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).
|
|
438
320
|
|
|
439
|
-
|
|
321
|
+
## Retry and configuration
|
|
440
322
|
|
|
441
|
-
|
|
323
|
+
`Client.new` options are also accepted by `config.action_mailer.cloudflare_settings`:
|
|
442
324
|
|
|
443
|
-
|
|
325
|
+
| Option | Default |
|
|
326
|
+
|---|---|
|
|
327
|
+
| `account_id`, `api_token` | Required |
|
|
328
|
+
| `base_url` | `https://api.cloudflare.com/client/v4` |
|
|
329
|
+
| `timeout` | 30 seconds for open/read/write |
|
|
330
|
+
| `total_timeout` | Defaults to `timeout`; bounds one complete HTTP attempt, including headers and streamed body |
|
|
331
|
+
| `max_response_bytes` | 1 MiB for `Client`; 32 MiB for `EventConsumer` queue batches |
|
|
332
|
+
| `retries` | 3 additional attempts |
|
|
333
|
+
| `initial_backoff` | 0.5 seconds, doubling |
|
|
334
|
+
| `max_retry_after` | 60 seconds |
|
|
335
|
+
| `retry_ambiguous` | `false` |
|
|
336
|
+
| `logger` | `nil`, optional `warn` logger |
|
|
444
337
|
|
|
445
|
-
|
|
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. |
|
|
338
|
+
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.
|
|
455
339
|
|
|
456
|
-
|
|
340
|
+
Responses are streamed with a byte limit, including decompressed content and
|
|
341
|
+
error responses. A total deadline or response-size failure after transmission
|
|
342
|
+
does not prove a send failed: the outbox preserves the ambiguous claim and blocks
|
|
343
|
+
automatic resend. Net::HTTP's implicit retries are disabled. Explicit retries
|
|
344
|
+
and backoff can make a logical call longer than `total_timeout`; queue handlers,
|
|
345
|
+
database transactions and a complete polling run have separate runtime budgets.
|
|
346
|
+
An uncertain queue acknowledgement permits receipt replay, never email resending.
|
|
457
347
|
|
|
458
|
-
|
|
348
|
+
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
349
|
|
|
460
|
-
|
|
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.
|
|
350
|
+
Errors inherit from `Cloudflare::Email::Error`: `ConfigurationError`, `AuthenticationError`, `ValidationError`, `RateLimitError`, `ServerError`, and `NetworkError`. API errors expose `status` and parsed `response`.
|
|
465
351
|
|
|
466
|
-
##
|
|
352
|
+
## Observability and permissions
|
|
467
353
|
|
|
468
|
-
|
|
354
|
+
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
355
|
|
|
470
|
-
|
|
|
356
|
+
| Task | Purpose / credentials |
|
|
471
357
|
|---|---|
|
|
472
|
-
| `
|
|
473
|
-
| `
|
|
474
|
-
| `
|
|
475
|
-
| `
|
|
476
|
-
| `
|
|
477
|
-
| `
|
|
478
|
-
| `
|
|
358
|
+
| `doctor` | Read diagnostics with runtime token; limited read permissions are reported |
|
|
359
|
+
| `send_test FROM=... TO=...` | Send a real message using runtime send permission |
|
|
360
|
+
| `deploy_worker URL=https://...` | Management token: Workers Scripts Edit |
|
|
361
|
+
| `provision_route ADDRESS=...` | Management token: Zone Read, Email Routing Rules Edit; DNS Read for subdomain checks; routing-settings write permission for apex enablement |
|
|
362
|
+
| `provision_catchall DOMAIN=...` | Same routing management permissions; changes the zone-wide catch-all |
|
|
363
|
+
| `dev` | Management token: Workers Scripts Edit; development only |
|
|
364
|
+
| `consume_events` | Separate Queues Read/Write token, queue ID, configured handler |
|
|
365
|
+
| `deliver OPERATION_KEY=...` | Dispatch a saved outbox operation using sending credentials |
|
|
366
|
+
| `replay_events [MESSAGE_ID=...]` | Replay durable receipts for the configured account |
|
|
367
|
+
| `pending_deliveries` | List prepared or uncertain outbox operations for operator review |
|
|
479
368
|
|
|
480
|
-
|
|
369
|
+
The optional Rails adapter emits `cloudflare_email.outbox_prepare`,
|
|
370
|
+
`cloudflare_email.outbox_send`, and `cloudflare_email.outbox_reconcile` notifications
|
|
371
|
+
with operation identity and resulting state. These do not contain MIME or API
|
|
372
|
+
tokens. A notification is method instrumentation; an enclosing application
|
|
373
|
+
transaction may still roll back. Inspect the durable ledger for authoritative state.
|
|
481
374
|
|
|
482
|
-
|
|
375
|
+
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
376
|
|
|
484
|
-
|
|
377
|
+
## SMTP alternative
|
|
485
378
|
|
|
486
|
-
|
|
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
|
|
379
|
+
Cloudflare also supports authenticated SMTP. Existing Rails SMTP applications can use it without this gem's delivery method:
|
|
490
380
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
381
|
+
```ruby
|
|
382
|
+
config.action_mailer.delivery_method = :smtp
|
|
383
|
+
config.action_mailer.smtp_settings = {
|
|
384
|
+
address: "smtp.mx.cloudflare.net",
|
|
385
|
+
port: 465,
|
|
386
|
+
ssl: true,
|
|
387
|
+
authentication: :plain,
|
|
388
|
+
user_name: "api_token",
|
|
389
|
+
password: ENV.fetch("CLOUDFLARE_SMTP_TOKEN"),
|
|
390
|
+
}
|
|
494
391
|
```
|
|
495
392
|
|
|
496
|
-
|
|
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` |
|
|
393
|
+
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
394
|
|
|
502
|
-
##
|
|
395
|
+
## Development and verification
|
|
503
396
|
|
|
504
397
|
```sh
|
|
398
|
+
bundle install
|
|
505
399
|
bundle exec rake test
|
|
506
|
-
|
|
507
|
-
BUNDLE_GEMFILE=gemfiles/
|
|
508
|
-
|
|
509
|
-
cd templates/worker
|
|
400
|
+
bundle exec ruby script/verify_package.rb
|
|
401
|
+
BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle install
|
|
402
|
+
BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle exec rake test
|
|
403
|
+
cd templates/worker
|
|
404
|
+
npm ci
|
|
405
|
+
npm test
|
|
406
|
+
npm run check
|
|
407
|
+
npm audit
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
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:
|
|
411
|
+
|
|
412
|
+
```sh
|
|
413
|
+
BUNDLE_GEMFILE=gemfiles/local_ingress.gemfile bundle install
|
|
414
|
+
BUNDLE_GEMFILE=gemfiles/local_ingress.gemfile bundle exec ruby script/verify_local_ingress.rb
|
|
510
415
|
```
|
|
511
416
|
|
|
512
|
-
|
|
417
|
+
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
418
|
|
|
514
|
-
|
|
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.
|
|
419
|
+
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
420
|
|
|
519
|
-
|
|
421
|
+
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
422
|
|
|
521
|
-
MIT.
|
|
423
|
+
MIT license.
|