cloudflare-email 0.4.0 → 0.4.2
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 +59 -2
- data/README.md +63 -379
- data/SECURITY.md +1 -9
- data/docs/activerecord-tenanted.md +0 -11
- data/docs/custom-ingress.md +1 -15
- data/docs/delivery-events.md +2 -5
- data/docs/features.md +49 -123
- data/docs/getting-started.md +90 -172
- data/docs/mailboxes.md +24 -60
- data/docs/management-engine.md +1 -3
- data/docs/outbox.md +5 -6
- data/docs/reference.md +190 -0
- data/docs/routing-diagnostics.md +0 -2
- data/docs/thread-correlation.md +2 -4
- data/docs/troubleshooting.md +4 -4
- data/lib/cloudflare/email/client.rb +23 -5
- data/lib/cloudflare/email/ingress.rb +16 -4
- data/lib/cloudflare/email/version.rb +1 -1
- data/templates/deploy-to-cloudflare/README.md +8 -10
- data/templates/deploy-to-cloudflare/docs/domain-setup.md +86 -166
- data/templates/worker/README.md +6 -8
- data/templates/worker/docs/domain-setup.md +86 -166
- data/templates/worker/docs/durable-inbound.md +4 -16
- metadata +4 -19
- data/docs/architecture.md +0 -105
- data/docs/tenant-mailboxes-plan.md +0 -149
- data/docs/upgrading-0.2.md +0 -101
- data/docs/upgrading-0.3.md +0 -55
- data/docs/verification/2026-09-10-followup.md +0 -49
- data/docs/verification/2026-09-10-install-upgrade.md +0 -86
- data/docs/verification/2026-09-10-live.md +0 -102
- data/docs/verification/2026-09-10.md +0 -175
- data/docs/verification/2026-09-11-custom-ingress.md +0 -49
- data/docs/verification/2026-09-11-extraction.md +0 -32
- data/docs/verification/2026-09-11-outbound-ledger.md +0 -60
- data/docs/verification/2026-09-11-security.md +0 -76
- data/docs/verification/2026-09-11-tenant-mailboxes.md +0 -75
- data/docs/verification/2026-09-12-destination-context.md +0 -46
- data/docs/verification/2026-09-13-action-mailbox-core.md +0 -96
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +0 -71
data/docs/reference.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Ruby and Rails reference
|
|
2
|
+
|
|
3
|
+
For a first setup, follow the [Rails quickstart](getting-started.md).
|
|
4
|
+
|
|
5
|
+
## Plain Ruby
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require "cloudflare-email"
|
|
9
|
+
|
|
10
|
+
client = Cloudflare::Email::Client.new(
|
|
11
|
+
account_id: ENV.fetch("CLOUDFLARE_ACCOUNT_ID"),
|
|
12
|
+
api_token: ENV.fetch("CLOUDFLARE_API_TOKEN"),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
response = client.send(
|
|
16
|
+
from: { address: "hello@mail.example.com", name: "Example" },
|
|
17
|
+
to: "you@example.net",
|
|
18
|
+
subject: "Hello",
|
|
19
|
+
text: "Plain body",
|
|
20
|
+
html: "<p>HTML body</p>",
|
|
21
|
+
reply_to: "support@in.example.com",
|
|
22
|
+
)
|
|
23
|
+
response.message_id # Provider ID when returned
|
|
24
|
+
response.delivered # Immediately delivered recipients
|
|
25
|
+
response.queued # Recipients queued for later delivery
|
|
26
|
+
response.permanent_bounces # Recipients that permanently bounced
|
|
27
|
+
response.suppressed_recipients # Recipients dropped by suppression policy
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`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:)`.
|
|
31
|
+
|
|
32
|
+
An HTTP success does not mean every recipient received the message. Inspect the recipient outcome arrays and use [delivery events](delivery-events.md) for later results.
|
|
33
|
+
|
|
34
|
+
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/).
|
|
35
|
+
|
|
36
|
+
## Receive through ActionMailbox
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
bin/rails generate cloudflare:email:install
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
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.
|
|
45
|
+
|
|
46
|
+
Add the generated ingress secret and an optional management token:
|
|
47
|
+
|
|
48
|
+
```yaml
|
|
49
|
+
cloudflare:
|
|
50
|
+
account_id: your-account-id
|
|
51
|
+
api_token: your-runtime-send-token
|
|
52
|
+
management_token: your-deployment-token
|
|
53
|
+
ingress_secret: the-generated-random-secret
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
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.
|
|
59
|
+
2. Deploy the environment's Worker and create its route:
|
|
60
|
+
|
|
61
|
+
First create the private R2 bucket and Queue, then deploy the generated Wrangler
|
|
62
|
+
configuration using the [durable setup guide](../templates/worker/docs/durable-inbound.md).
|
|
63
|
+
The Ruby task below updates that provisioned Worker while preserving its bindings.
|
|
64
|
+
It refuses a missing bucket/queue binding or recovery schedule before changing code.
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
RAILS_ENV=production bin/rails cloudflare:email:deploy_worker URL=https://app.example.com/rails/action_mailbox/cloudflare/inbound_emails
|
|
68
|
+
RAILS_ENV=production bin/rails cloudflare:email:provision_route ADDRESS=support@in.example.com
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
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.
|
|
72
|
+
|
|
73
|
+
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/).
|
|
74
|
+
|
|
75
|
+
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:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
class ApplicationMailbox < ActionMailbox::Base
|
|
79
|
+
routing ->(inbound) { Cloudflare::Email::Envelope.for(inbound)&.fetch("to")&.match?(/\Asupport@/i) } => :support
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`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.
|
|
84
|
+
|
|
85
|
+
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](custom-ingress.md) for signed Worker metadata. 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.
|
|
86
|
+
|
|
87
|
+
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.
|
|
88
|
+
|
|
89
|
+
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.
|
|
90
|
+
|
|
91
|
+
### Local development and deployment
|
|
92
|
+
|
|
93
|
+
For a simple development tunnel, use the explicit direct fallback. Start Rails, then:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
INBOUND_DELIVERY_MODE=direct bin/rails cloudflare:email:deploy_worker
|
|
97
|
+
bin/rails cloudflare:email:dev
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
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. 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.
|
|
101
|
+
|
|
102
|
+
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.
|
|
103
|
+
|
|
104
|
+
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):
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
cd cloudflare-worker
|
|
108
|
+
npm ci
|
|
109
|
+
npx wrangler secret put INGRESS_SECRET --env production
|
|
110
|
+
npx wrangler secret put RAILS_INGRESS_URL --env production
|
|
111
|
+
npm run deploy -- --env production
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
See the [Worker README](../templates/worker/README.md).
|
|
115
|
+
|
|
116
|
+
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.
|
|
117
|
+
|
|
118
|
+
## Retry and configuration
|
|
119
|
+
|
|
120
|
+
`Client.new` options are also accepted by `config.action_mailer.cloudflare_settings`:
|
|
121
|
+
|
|
122
|
+
| Option | Default |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `account_id`, `api_token` | Required |
|
|
125
|
+
| `base_url` | `https://api.cloudflare.com/client/v4` |
|
|
126
|
+
| `timeout` | 30 seconds for open/read/write |
|
|
127
|
+
| `total_timeout` | Defaults to `timeout`; bounds one complete HTTP attempt, including headers and streamed body |
|
|
128
|
+
| `max_response_bytes` | 1 MiB for `Client`; 32 MiB for `EventConsumer` queue batches |
|
|
129
|
+
| `retries` | 3 additional attempts |
|
|
130
|
+
| `initial_backoff` | 0.5 seconds, doubling |
|
|
131
|
+
| `max_retry_after` | 60 seconds |
|
|
132
|
+
| `retry_ambiguous` | `false` |
|
|
133
|
+
| `logger` | `nil`, optional `warn` logger |
|
|
134
|
+
|
|
135
|
+
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.
|
|
136
|
+
|
|
137
|
+
Responses are streamed with a byte limit, including decompressed content and
|
|
138
|
+
error responses. A total deadline or response-size failure after transmission
|
|
139
|
+
does not prove a send failed: the outbox preserves the ambiguous claim and blocks
|
|
140
|
+
automatic resend. Net::HTTP's implicit retries are disabled. Explicit retries
|
|
141
|
+
and backoff can make a logical call longer than `total_timeout`; queue handlers,
|
|
142
|
+
database transactions and a complete polling run have separate runtime budgets.
|
|
143
|
+
An uncertain queue acknowledgement permits receipt replay, never email resending.
|
|
144
|
+
|
|
145
|
+
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.
|
|
146
|
+
|
|
147
|
+
Errors inherit from `Cloudflare::Email::Error`: `ConfigurationError`, `AuthenticationError`, `ValidationError`, `RateLimitError`, `ServerError`, and `NetworkError`. API errors expose `status` and parsed `response`.
|
|
148
|
+
|
|
149
|
+
## Observability and permissions
|
|
150
|
+
|
|
151
|
+
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.
|
|
152
|
+
|
|
153
|
+
| Task | Purpose / credentials |
|
|
154
|
+
|---|---|
|
|
155
|
+
| `doctor` | Read diagnostics with runtime token; limited read permissions are reported |
|
|
156
|
+
| `send_test FROM=... TO=...` | Send a real message using runtime send permission |
|
|
157
|
+
| `deploy_worker URL=https://...` | Management token: Workers Scripts Edit |
|
|
158
|
+
| `provision_route ADDRESS=...` | Management token: Zone Read, Email Routing Rules Edit; DNS Read for subdomain checks; routing-settings write permission for apex enablement |
|
|
159
|
+
| `provision_catchall DOMAIN=...` | Same routing management permissions; changes the zone-wide catch-all |
|
|
160
|
+
| `dev` | Management token: Workers Scripts Edit; development only |
|
|
161
|
+
| `consume_events` | Separate Queues Read/Write token, queue ID, configured handler |
|
|
162
|
+
| `deliver OPERATION_KEY=...` | Dispatch a saved outbox operation using sending credentials |
|
|
163
|
+
| `replay_events [MESSAGE_ID=...]` | Replay durable receipts for the configured account |
|
|
164
|
+
| `pending_deliveries` | List prepared or uncertain outbox operations for operator review |
|
|
165
|
+
|
|
166
|
+
The optional Rails adapter emits `cloudflare_email.outbox_prepare`,
|
|
167
|
+
`cloudflare_email.outbox_send`, and `cloudflare_email.outbox_reconcile` notifications
|
|
168
|
+
with operation identity and resulting state. These do not contain MIME or API
|
|
169
|
+
tokens. A notification is method instrumentation; an enclosing application
|
|
170
|
+
transaction may still roll back. Inspect the durable ledger for authoritative state.
|
|
171
|
+
|
|
172
|
+
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.
|
|
173
|
+
|
|
174
|
+
## SMTP alternative
|
|
175
|
+
|
|
176
|
+
Cloudflare also supports authenticated SMTP. Existing Rails SMTP applications can use it without this gem's delivery method:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
config.action_mailer.delivery_method = :smtp
|
|
180
|
+
config.action_mailer.smtp_settings = {
|
|
181
|
+
address: "smtp.mx.cloudflare.net",
|
|
182
|
+
port: 465,
|
|
183
|
+
ssl: true,
|
|
184
|
+
authentication: :plain,
|
|
185
|
+
user_name: "api_token",
|
|
186
|
+
password: ENV.fetch("CLOUDFLARE_SMTP_TOKEN"),
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
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.
|
data/docs/routing-diagnostics.md
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# Check whether an address is configured to reach your Worker
|
|
2
2
|
|
|
3
|
-
This diagnostic is available in gem version 0.3.0.
|
|
4
|
-
|
|
5
3
|
A mailbox registered in Rails is not proof that Cloudflare can deliver to it.
|
|
6
4
|
Use this read-only check when onboarding a receiving domain, investigating a
|
|
7
5
|
missing message, or checking an existing catch-all before activating addresses.
|
data/docs/thread-correlation.md
CHANGED
|
@@ -16,10 +16,8 @@ message's ID. Keep unmatched replies available for application handling when an
|
|
|
16
16
|
ID is missing or unknown. Send acceptance and saving the returned ID are not one
|
|
17
17
|
atomic transaction.
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
replacement of custom IDs for both raw MIME and ActionMailer sends. The provider's
|
|
22
|
-
returned IDs matched received headers and supported reply correlation.
|
|
19
|
+
Cloudflare controls the outgoing Message-ID. Store the returned provider ID
|
|
20
|
+
instead of relying on a custom ID supplied before sending.
|
|
23
21
|
|
|
24
22
|
Email headers locate a conversation; they do not authorize access to it. Scope
|
|
25
23
|
lookups by the trusted SMTP recipient obtained through `Envelope.for(inbound)`.
|
data/docs/troubleshooting.md
CHANGED
|
@@ -9,7 +9,7 @@ into public logs or issues.
|
|
|
9
9
|
|
|
10
10
|
| What you see | What to check next |
|
|
11
11
|
| --- | --- |
|
|
12
|
-
| New generator or API is missing | Check `Gemfile.lock`. Use cloudflare-email 0.4.x with mailbox-kit 0.1.x; follow the [installation guide](getting-started.md#install
|
|
12
|
+
| New generator or API is missing | Check `Gemfile.lock`. Use cloudflare-email 0.4.x with mailbox-kit 0.1.x; follow the [installation guide](getting-started.md#1-install). |
|
|
13
13
|
| Credentials appear to be ignored | Nonempty Rails credentials override environment variables. Check the Rails environment and restart the app after changes. |
|
|
14
14
|
| Authentication or domain error | Run `bin/rails cloudflare:email:doctor`; check account ID, token permissions and sending-domain verification. |
|
|
15
15
|
| `doctor` reports limited read access | A send token may lack diagnostic read permissions. Review the specific result; diagnostics alone cannot prove whether sending works. |
|
|
@@ -30,7 +30,7 @@ FROM=hello@mail.example.com TO=you@example.net bin/rails cloudflare:email:send_t
|
|
|
30
30
|
| --- | --- |
|
|
31
31
|
| Address receives nothing | Verify receiving-subdomain onboarding and the address route's Worker. Sending-domain verification is separate. |
|
|
32
32
|
| Route provisioning refuses a subdomain | Complete Email Routing subdomain setup and DNS preflight first. Do not enable or replace apex routing just to bypass the error. |
|
|
33
|
-
| Worker reports 401 from Rails | Check matching ingress secrets and compatible signature versions. The Worker defaults to v2.
|
|
33
|
+
| Worker reports 401 from Rails | Check matching ingress secrets and compatible signature versions. The Worker defaults to v2. Use the [custom-ingress API](custom-ingress.md) for v3 Worker metadata. |
|
|
34
34
|
| Worker reports 408 | Check clock accuracy and the five-minute signing window. |
|
|
35
35
|
| Worker reports 413 or rejects size | Raw MIME exceeds the configured limit. Match `MAX_EMAIL_BYTES` on Rails and Worker and check upstream request limits. |
|
|
36
36
|
| Worker reports 3xx | Point it directly at the final HTTPS ingress URL; redirects are intentionally rejected. |
|
|
@@ -42,7 +42,7 @@ FROM=hello@mail.example.com TO=you@example.net bin/rails cloudflare:email:send_t
|
|
|
42
42
|
|
|
43
43
|
## Local development
|
|
44
44
|
|
|
45
|
-
Restart Rails after
|
|
45
|
+
Restart Rails after configuring the gem so the ingress-only guard is installed. Start
|
|
46
46
|
Rails on the expected port before `cloudflare:email:dev`, install `cloudflared`,
|
|
47
47
|
and use development credentials and a development receiving route.
|
|
48
48
|
|
|
@@ -99,6 +99,6 @@ bin/rails cloudflare:email:replay_events
|
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
See [delivery events](delivery-events.md) for queue setup and
|
|
102
|
-
[observability](
|
|
102
|
+
[observability](reference.md#observability-and-permissions) for notifications you
|
|
103
103
|
can connect to your monitoring. A delivery notification wraps handler execution;
|
|
104
104
|
it is not proof that queue acknowledgement completed.
|
|
@@ -15,6 +15,7 @@ module Cloudflare
|
|
|
15
15
|
DEFAULT_BACKOFF = 0.5
|
|
16
16
|
DEFAULT_MAX_RESPONSE_BYTES = 1_048_576
|
|
17
17
|
MAX_RETRY_AFTER = 60 # seconds; never sleep longer than this even if server says so
|
|
18
|
+
PROVIDER_TEXT_LIMIT = 200 # characters of an unstructured error body quoted in a message
|
|
18
19
|
|
|
19
20
|
RETRYABLE_NETWORK = [
|
|
20
21
|
Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNRESET,
|
|
@@ -159,7 +160,7 @@ module Cloudflare
|
|
|
159
160
|
do_request(method, uri, body)
|
|
160
161
|
rescue *RETRYABLE_NETWORK => e
|
|
161
162
|
unless PRE_SEND_NETWORK.any? { |type| e.is_a?(type) } || @retry_ambiguous
|
|
162
|
-
raise NetworkError.new("provider response could not be read;
|
|
163
|
+
raise NetworkError.new("provider response could not be read; request outcome is unknown; automatic retry disabled"), cause: nil
|
|
163
164
|
end
|
|
164
165
|
raise NetworkError.new("provider request could not be completed; request outcome is unknown"), cause: nil if attempts > @retries
|
|
165
166
|
log_retry(attempts, e)
|
|
@@ -235,7 +236,7 @@ module Cloudflare
|
|
|
235
236
|
|
|
236
237
|
def handle_response(response, raw = response.body)
|
|
237
238
|
status = response.code.to_i
|
|
238
|
-
body = parse_body(raw)
|
|
239
|
+
body = parse_body(raw, status)
|
|
239
240
|
retry_after = response["Retry-After"]
|
|
240
241
|
|
|
241
242
|
# Stash Retry-After on the error response so retry logic can use it.
|
|
@@ -267,11 +268,28 @@ module Cloudflare
|
|
|
267
268
|
end
|
|
268
269
|
end
|
|
269
270
|
|
|
270
|
-
|
|
271
|
+
# A 2xx that is not readable JSON fails closed: the request may have taken
|
|
272
|
+
# effect. A non-2xx was refused, so a body that is not a Cloudflare JSON
|
|
273
|
+
# object (a plain-text throttle, a bare JSON string) is reported as the
|
|
274
|
+
# provider's own words rather than as an unknown outcome.
|
|
275
|
+
def parse_body(raw, status)
|
|
271
276
|
return {} if raw.nil? || raw.empty?
|
|
272
|
-
JSON.parse(raw)
|
|
277
|
+
parsed = JSON.parse(raw)
|
|
278
|
+
return parsed if parsed.is_a?(Hash) || (200..299).cover?(status)
|
|
279
|
+
unstructured_error(status, parsed.is_a?(String) ? parsed : raw)
|
|
273
280
|
rescue JSON::ParserError
|
|
274
|
-
{ "errors" => [{ "message" => "invalid JSON API response; request outcome is unknown" }] }
|
|
281
|
+
return { "errors" => [{ "message" => "invalid JSON API response; request outcome is unknown" }] } if (200..299).cover?(status)
|
|
282
|
+
unstructured_error(status, raw)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def unstructured_error(status, text)
|
|
286
|
+
text = text.to_s.dup.force_encoding(Encoding::UTF_8).scrub("").gsub(/[[:cntrl:][:space:]]+/, " ").strip
|
|
287
|
+
# Markup is an error page, not a reason; never echo it.
|
|
288
|
+
text = "invalid JSON API response" if text.empty? || text.start_with?("<")
|
|
289
|
+
text = "#{text[0, PROVIDER_TEXT_LIMIT]}..." if text.length > PROVIDER_TEXT_LIMIT
|
|
290
|
+
# A 5xx may have failed after taking effect; a 4xx/429 was refused.
|
|
291
|
+
text = "#{text}; request outcome is unknown" if status >= 500
|
|
292
|
+
{ "errors" => [{ "message" => "HTTP #{status}: #{text}" }] }
|
|
275
293
|
end
|
|
276
294
|
|
|
277
295
|
def extract_message(body)
|
|
@@ -44,9 +44,17 @@ module Cloudflare
|
|
|
44
44
|
# missing membership without scheduling Rails processing again.
|
|
45
45
|
def receive_into_mailbox!
|
|
46
46
|
result = nil
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
prepared = nil
|
|
48
|
+
recipient = envelope.fetch("to")
|
|
49
|
+
# Upload before the membership transaction opens (see InboundEmail).
|
|
50
|
+
Mailboxes.with_recipient(recipient: recipient) { prepared = prepare_rails_record }
|
|
51
|
+
begin
|
|
52
|
+
Mailboxes.receive(recipient: recipient) do
|
|
53
|
+
result = prepared.persist
|
|
54
|
+
result.record
|
|
55
|
+
end
|
|
56
|
+
ensure
|
|
57
|
+
Mailboxes.for_tenant(prepared.tenant_key) { prepared.discard } if prepared
|
|
50
58
|
end
|
|
51
59
|
result.record if result.created?
|
|
52
60
|
end
|
|
@@ -54,8 +62,12 @@ module Cloudflare
|
|
|
54
62
|
private
|
|
55
63
|
|
|
56
64
|
def persist_rails_record
|
|
65
|
+
prepare_rails_record.persist
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def prepare_rails_record
|
|
57
69
|
require "mailbox_kit/inbound_email"
|
|
58
|
-
MailboxKit::InboundEmail.
|
|
70
|
+
MailboxKit::InboundEmail.prepare(source: body, message_checksum: message_checksum,
|
|
59
71
|
message_id: stable_message_id, metadata: storage_metadata)
|
|
60
72
|
end
|
|
61
73
|
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Receive email in Rails with the [cloudflare-email gem](https://github.com/cole-robertson/cloudflare-email).
|
|
4
4
|
This template stores inbound mail in private R2, delivers it through a Queue,
|
|
5
5
|
and retries from storage when Rails is unavailable. It includes scheduled
|
|
6
|
-
recovery and Worker logs.
|
|
6
|
+
recovery and Worker logs.
|
|
7
7
|
|
|
8
8
|
[](https://deploy.workers.cloudflare.com/?url=https://github.com/cole-robertson/cloudflare-email/tree/main/templates/deploy-to-cloudflare)
|
|
9
9
|
|
|
10
10
|
## 1. Prepare Rails
|
|
11
11
|
|
|
12
|
-
Install `gem "cloudflare-email", "~> 0.
|
|
12
|
+
Install `gem "cloudflare-email", "~> 0.4.0"` and follow the
|
|
13
13
|
[Rails setup guide](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/getting-started.md).
|
|
14
14
|
Deploy Rails with Action Mailbox storage and routing configured before connecting
|
|
15
15
|
real email. Generate a secret with `openssl rand -hex 32` and configure it as
|
|
@@ -40,8 +40,8 @@ cron are present. Keep the bucket private with no lifecycle rule expiring
|
|
|
40
40
|
|
|
41
41
|
**Choose your address shape:** `acme@in.example.com` on one receiving domain,
|
|
42
42
|
or `invoices@acme.in.example.com` with dynamic organization subdomains.
|
|
43
|
-
Use the [shared domain setup guide](docs/domain-setup.md) for
|
|
44
|
-
|
|
43
|
+
Use the [shared domain setup guide](docs/domain-setup.md) for DNS,
|
|
44
|
+
Worker routing, and verification commands.
|
|
45
45
|
The same Worker serves both; there is no per-organization Worker allowlist.
|
|
46
46
|
|
|
47
47
|
In Cloudflare, enable Email Routing for the intended receiving domain/subdomain
|
|
@@ -56,8 +56,7 @@ If using the gem's managed mailboxes, register the receiving mailbox/address in
|
|
|
56
56
|
Rails too. Tenant support and domain catch-all receiving remain explicit choices.
|
|
57
57
|
After verifying a dynamic namespace as described in the guide, register new
|
|
58
58
|
organization domains in Rails without routinely adding provider rules per org.
|
|
59
|
-
|
|
60
|
-
distinguishes working Rebulk evidence from Cloudflare's explicit onboarding docs.
|
|
59
|
+
Verify wildcard receiving on your account before relying on unlisted subdomains.
|
|
61
60
|
Outbound Email Sending credentials and delivery-event subscriptions are separate
|
|
62
61
|
from this inbound Worker setup.
|
|
63
62
|
|
|
@@ -87,10 +86,9 @@ also tolerate retries. Configure operational alerts in your monitoring service.
|
|
|
87
86
|
|
|
88
87
|
## Updates and local development
|
|
89
88
|
|
|
90
|
-
Your copied repository
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
storage. Keep production resources separate from test resources.
|
|
89
|
+
Your copied repository contains your Worker deployment. Deploy changes from that
|
|
90
|
+
repository; updating the gem does not deploy the Worker. Preserve resource names,
|
|
91
|
+
secrets, and pending storage.
|
|
94
92
|
|
|
95
93
|
Use Node 22.12+ (or a supported newer version): `npm ci`, `npm test`, and
|
|
96
94
|
`npm run check`. For local development, copy `.dev.vars.example` to `.dev.vars`,
|