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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -2
  3. data/README.md +63 -379
  4. data/SECURITY.md +1 -9
  5. data/docs/activerecord-tenanted.md +0 -11
  6. data/docs/custom-ingress.md +1 -15
  7. data/docs/delivery-events.md +2 -5
  8. data/docs/features.md +49 -123
  9. data/docs/getting-started.md +90 -172
  10. data/docs/mailboxes.md +24 -60
  11. data/docs/management-engine.md +1 -3
  12. data/docs/outbox.md +5 -6
  13. data/docs/reference.md +190 -0
  14. data/docs/routing-diagnostics.md +0 -2
  15. data/docs/thread-correlation.md +2 -4
  16. data/docs/troubleshooting.md +4 -4
  17. data/lib/cloudflare/email/client.rb +23 -5
  18. data/lib/cloudflare/email/ingress.rb +16 -4
  19. data/lib/cloudflare/email/version.rb +1 -1
  20. data/templates/deploy-to-cloudflare/README.md +8 -10
  21. data/templates/deploy-to-cloudflare/docs/domain-setup.md +86 -166
  22. data/templates/worker/README.md +6 -8
  23. data/templates/worker/docs/domain-setup.md +86 -166
  24. data/templates/worker/docs/durable-inbound.md +4 -16
  25. metadata +4 -19
  26. data/docs/architecture.md +0 -105
  27. data/docs/tenant-mailboxes-plan.md +0 -149
  28. data/docs/upgrading-0.2.md +0 -101
  29. data/docs/upgrading-0.3.md +0 -55
  30. data/docs/verification/2026-09-10-followup.md +0 -49
  31. data/docs/verification/2026-09-10-install-upgrade.md +0 -86
  32. data/docs/verification/2026-09-10-live.md +0 -102
  33. data/docs/verification/2026-09-10.md +0 -175
  34. data/docs/verification/2026-09-11-custom-ingress.md +0 -49
  35. data/docs/verification/2026-09-11-extraction.md +0 -32
  36. data/docs/verification/2026-09-11-outbound-ledger.md +0 -60
  37. data/docs/verification/2026-09-11-security.md +0 -76
  38. data/docs/verification/2026-09-11-tenant-mailboxes.md +0 -75
  39. data/docs/verification/2026-09-12-destination-context.md +0 -46
  40. data/docs/verification/2026-09-13-action-mailbox-core.md +0 -96
  41. 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.
@@ -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.
@@ -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
- The old `SecureMessageId` helper was removed before the 0.2 release. Cloudflare
20
- controls Message-ID and our [live test](verification/2026-09-10-live.md) confirmed
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)`.
@@ -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-the-current-code). |
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. The opt-in v3 [custom-ingress API](custom-ingress.md) requires upgrading Rails before enabling Worker metadata. |
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 upgrading so the ingress-only guard is installed. Start
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](../README.md#observability-and-permissions) for notifications you
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; delivery outcome is unknown; automatic retry disabled"), cause: nil
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
- def parse_body(raw)
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
- Mailboxes.receive(recipient: envelope.fetch("to")) do
48
- result = persist_rails_record
49
- result.record
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.persist(source: body, message_checksum: message_checksum,
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
 
@@ -1,5 +1,5 @@
1
1
  module Cloudflare
2
2
  module Email
3
- VERSION = "0.4.0"
3
+ VERSION = "0.4.2"
4
4
  end
5
5
  end
@@ -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. No React app or extra server is needed.
6
+ recovery and Worker logs.
7
7
 
8
8
  [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](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.3.0"` and follow the
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 the setup worksheet,
44
- Cloudflare DNS/routing steps, cited Rebulk example, and verification commands.
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
- Wildcard DNS behavior must be verified on your Cloudflare account; the guide
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 belongs to you; gem updates do not update its Worker
91
- automatically. Review upstream release notes, deploy Rails first, then port Worker
92
- changes into your copy while preserving your resource names, secrets, and pending
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`,