cloudflare-email 0.2.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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/README.md +34 -9
  4. data/app/controllers/cloudflare/email/ingress_controller.rb +16 -60
  5. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
  6. data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
  7. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
  8. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
  9. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
  10. data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
  11. data/docs/activerecord-tenanted.md +46 -0
  12. data/docs/architecture.md +13 -3
  13. data/docs/custom-ingress.md +207 -0
  14. data/docs/delivery-events.md +9 -0
  15. data/docs/features.md +36 -5
  16. data/docs/getting-started.md +18 -8
  17. data/docs/mailboxes.md +67 -3
  18. data/docs/management-engine.md +190 -0
  19. data/docs/routing-deliveries.md +94 -0
  20. data/docs/routing-diagnostics.md +68 -0
  21. data/docs/troubleshooting.md +4 -3
  22. data/docs/upgrading-0.2.md +5 -0
  23. data/docs/upgrading-0.3.md +55 -0
  24. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  25. data/docs/verification/2026-09-12-destination-context.md +46 -0
  26. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  27. data/lib/cloudflare/email/active_record/base.rb +13 -1
  28. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  29. data/lib/cloudflare/email/check_route_task.rb +21 -0
  30. data/lib/cloudflare/email/client.rb +45 -10
  31. data/lib/cloudflare/email/delivery_method.rb +2 -0
  32. data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
  33. data/lib/cloudflare/email/engine.rb +5 -0
  34. data/lib/cloudflare/email/envelope.rb +2 -2
  35. data/lib/cloudflare/email/event_consumer.rb +12 -3
  36. data/lib/cloudflare/email/ingress.rb +115 -0
  37. data/lib/cloudflare/email/mailboxes/models.rb +19 -2
  38. data/lib/cloudflare/email/mailboxes/service.rb +99 -15
  39. data/lib/cloudflare/email/management/adapter.rb +31 -0
  40. data/lib/cloudflare/email/management/configuration.rb +17 -0
  41. data/lib/cloudflare/email/management/engine.rb +15 -0
  42. data/lib/cloudflare/email/management/management.css +68 -0
  43. data/lib/cloudflare/email/management/routes.rb +14 -0
  44. data/lib/cloudflare/email/management.rb +7 -0
  45. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  46. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  47. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  48. data/lib/cloudflare/email/verification.rb +17 -9
  49. data/lib/cloudflare/email/version.rb +1 -1
  50. data/lib/cloudflare/email/worker_deployer.rb +36 -5
  51. data/lib/generators/cloudflare/email/install_generator.rb +6 -5
  52. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  53. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  54. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +4 -0
  55. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  56. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  57. data/lib/tasks/cloudflare_email.rake +2 -1
  58. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  59. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  60. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  61. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  62. data/templates/deploy-to-cloudflare/README.md +84 -0
  63. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  64. data/templates/deploy-to-cloudflare/package.json +36 -0
  65. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  66. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  67. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  68. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  69. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  70. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  71. data/templates/worker/README.md +180 -15
  72. data/templates/worker/docs/durable-inbound.md +149 -0
  73. data/templates/worker/package-lock.json +2 -2
  74. data/templates/worker/package.json +1 -1
  75. data/templates/worker/src/index.js +331 -38
  76. data/templates/worker/test/durable.test.ts +193 -0
  77. data/templates/worker/test/index.test.ts +78 -20
  78. data/templates/worker/test/relay.test.ts +226 -0
  79. data/templates/worker/wrangler.toml +50 -0
  80. metadata +49 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5188438b941345c4164baca6ab1400e2fc74259c5fdd209a6d2ed9ac618147b5
4
- data.tar.gz: 5e3a6ff25c460c0a73ffb3fdf44dcc62f87679f4278e328c605291d62de35fa9
3
+ metadata.gz: b8b7e8e8e9c8f102cf640de5aa577092947f829853daea59463263b8729ede21
4
+ data.tar.gz: 7303add7cb750e309c338132e114ecd40340bf5e9ee39b6b03c3f28acd9887d1
5
5
  SHA512:
6
- metadata.gz: 77fe9d9c571311065e9d10d00e2c021156113ff63dec5a970f63456a6cee9d3413bfe157dcfd8e37992e5b747e1ab957d342bfc01a399d41e2c3838462067aa3
7
- data.tar.gz: bf580baaec84aa3dc8894f0fbc9b770ca4fe2521a1534b248fe75bd67812a40adaf4538d321a7740b911b1fc42b80e5fd468418c4d804d635f5496710591add6
6
+ metadata.gz: 77cbd0de1b51f621f834fa9493c31865cc0017e0f221885503ad6c891f783f39b3e0eaf07eaabb148a9e34c694bed9d8e4a0dfa27cd052e70e0d9e17164e9483
7
+ data.tar.gz: 3e3ee8aa24120e3b264efd7328b18dc3c48ab044656c0e15d0f73a13d2fd18e06247ab7392bc72aba472d37bee1b080a77113e492d4dcbe8c6945e2c5ceeb34a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-09-13
4
+
5
+ See [upgrading from 0.2](docs/upgrading-0.3.md) before deploying the updated Worker.
6
+
7
+ - Add a standalone Deploy to Cloudflare template with R2/Queue provisioning,
8
+ secret prompts, scheduled recovery, and logs. CI checks runtime/configuration
9
+ synchronization with the gem's Worker and tests an isolated template copy.
10
+
11
+ - Make durable inbound delivery the Worker default, with explicit direct fallback: commit original mail and envelope context
12
+ to R2 before Worker acceptance, deliver queue pointers with fresh signatures,
13
+ and recover pending mail through a bounded scheduled sweep. Rails errors,
14
+ lost responses, enqueue failures and exhausted queue retries retain pending
15
+ mail. Provisioning, protected storage and monitoring remain explicit.
16
+ - Make missing Message-ID fallback deterministic across Rails hosts so replay
17
+ after a deployment does not create another inbound record. Original MIME and
18
+ parseable provider Message-IDs remain unchanged.
19
+ - Add optional authenticated Routing analytics delivery confirmation with
20
+ encrypted immutable receipts, strict single-envelope-recipient correlation,
21
+ replay and transactional callbacks. Sending acceptance remains unchanged;
22
+ missing or ambiguous evidence never authorizes a resend.
23
+ - Bound shared sending/queue HTTP responses by streamed bytes and a total
24
+ per-attempt deadline; disable implicit HTTP retries and sanitize transport
25
+ failures. Sending and queue acknowledgement ambiguity stays safe to replay
26
+ through durable receipts rather than resending accepted email.
27
+ - Document verified Routing destinations, separate analytics credentials and
28
+ explicit system-mail storage with optional SQLite tenancy.
29
+
30
+ - Add optional domain catch-all receiving on an existing active mailbox address,
31
+ with explicit verification evidence and a unique active catch-all constraint.
32
+ Exact registered addresses always win, including rejected inactive addresses;
33
+ unknown local parts retain their actual envelope recipient without new aliases.
34
+ Destination snapshots identify fallback, while sending stays exact-address only.
35
+ A separate generator upgrades existing mailbox schemas; catch-all is never
36
+ enabled automatically.
37
+
38
+ - Allow production Rails schema preloading to boot without a selected tenant.
39
+ Tenant model pool access still fails closed with
40
+ `Cloudflare::Email::ActiveRecord::TenantConnectionUnavailable`, an
41
+ `ActiveRecord::ConnectionNotEstablished` error retaining the original
42
+ `ConfigurationError` as its cause. No default tenant is selected.
43
+
44
+ - Add a reusable Worker `relayEmail` pipeline with host backend/header policies,
45
+ bounded raw reads, optional fail-open archive hooks, safe HTTP outcomes and
46
+ deadlines. `archiveEmail` writes raw bytes and envelope metadata to an optional
47
+ R2 bucket. Default and custom forwarding share the same HTTP transport.
48
+ - Keep provider authentication interpretation, archive keys, retry decisions and
49
+ fallback destinations with the host; existing default v2/v3 forwarding remains
50
+ compatible. Archive deadlines bound waiting but cannot cancel an in-flight write.
51
+
52
+ - Add `Mailboxes.with_recipient` for active-address resolution and immutable
53
+ destination context with host-owned persistence, without ActionMailbox.
54
+ - Yield the same destination from `Mailboxes.receive` so apps can link their
55
+ business records while retaining gem-managed raw email, metadata and membership
56
+ persistence. Host exceptions propagate instead of being reported as missing
57
+ mailbox records; existing no-argument receive blocks continue to work.
58
+
59
+ - Add a bounded, Rails-independent `Ingress.verify` API for existing ingestion
60
+ pipelines, with optional ActionMailbox persistence and tenant registry routing.
61
+ - Add opt-in v3 signatures for provider metadata supplied by custom Workers.
62
+ Metadata is authenticated transport evidence, not proof of sender authenticity.
63
+ Default Worker forwarding remains v2; upgrade Rails before enabling v3.
64
+ - Verify signatures against exact timestamp header bytes; decimal parsing is
65
+ used only for freshness. The bundled Worker's timestamp format is unchanged.
66
+ - Add GET-only `cloudflare:email:check_route` diagnostics for exact receiving-domain
67
+ DNS and Worker routing. Reports uncertainty without changing configuration or
68
+ claiming live delivery is verified.
69
+
70
+ - Add an opt-in server-rendered mailbox management engine with host authentication,
71
+ mailbox/domain scoping and per-action permissions. Manage mailboxes and aliases,
72
+ suspend/resume, preview plain-text messages and mark read/archive without
73
+ JavaScript or asset pipeline dependencies. The engine is never mounted by default.
74
+
75
+ - Fix managed domain and address activation in Rails apps with strict readonly
76
+ attributes enabled. Normalize immutable identities only when creating records;
77
+ later lifecycle updates retain the original domain and address.
78
+
3
79
  ## 0.2.0 — 2026-09-11
4
80
 
5
81
  - Add optional managed mailboxes: domain directory, addresses/aliases, lifecycle,
data/README.md CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
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.
4
4
 
5
- Version **0.2.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.
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.
6
+
7
+ [![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)
8
+
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).
6
10
 
7
11
  ## Start here
8
12
 
@@ -16,19 +20,25 @@ Version **0.2.0**. Ruby 3.2+, Rails 7.2–8.1; Ruby 4.0 is tested with Rails 8.1
16
20
  | [Getting started](docs/getting-started.md) | Install, send your first email, receive replies, and save reliable send operations |
17
21
  | [Troubleshooting](docs/troubleshooting.md) | What to check when mail or delivery updates do not arrive |
18
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 |
19
29
  | [SQLite tenant databases](docs/activerecord-tenanted.md) | Give each organization its own SQLite database with `activerecord-tenanted` |
20
30
  | [Durable outbox](docs/outbox.md) | Detailed setup, callbacks, retries, and recovery |
21
31
  | [Delivery events](docs/delivery-events.md) | Cloudflare Queue setup and recipient status tracking |
22
- | [Upgrading to 0.2](docs/upgrading-0.2.md) | Changes needed for an existing installation |
32
+ | [Upgrading to 0.3](docs/upgrading-0.3.md) | Changes needed for an existing installation |
23
33
 
24
34
  The sections below are the configuration and API reference.
25
35
 
26
36
  ## Install and send from Rails
27
37
 
28
- Add version 0.2 to your Gemfile. Existing 0.1 users should follow the [upgrade guide](docs/upgrading-0.2.md), including the coordinated Rails/Worker update:
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:
29
39
 
30
40
  ```ruby
31
- gem "cloudflare-email", "~> 0.2.0"
41
+ gem "cloudflare-email", "~> 0.3.0"
32
42
  ```
33
43
 
34
44
  ```sh
@@ -209,6 +219,11 @@ Equivalent environment names are `CLOUDFLARE_MANAGEMENT_TOKEN` and `CLOUDFLARE_I
209
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.
210
220
  2. Deploy the environment's Worker and create its route:
211
221
 
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.
226
+
212
227
  ```sh
213
228
  RAILS_ENV=production bin/rails cloudflare:email:deploy_worker URL=https://app.example.com/rails/action_mailbox/cloudflare/inbound_emails
214
229
  RAILS_ENV=production bin/rails cloudflare:email:provision_route ADDRESS=support@in.example.com
@@ -228,18 +243,18 @@ end
228
243
 
229
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.
230
245
 
231
- Version 0.2 requires the bundled v2 Worker; missing or v1 signatures are rejected. Coordinate Rails and Worker deployment while ingress is paused. The Worker requires 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.
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.
232
247
 
233
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.
234
249
 
235
- The Worker has a 15-second Rails request timeout and rejects redirects. Non-2xx responses, timeouts, and network failures call `message.setReject`. There is no durable buffering of inbound email: an application outage can reject mail. Storage acceptance does not guarantee later mailbox-job success. Monitor Rails jobs and Cloudflare Worker logs.
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.
236
251
 
237
252
  ### Local development and deployment
238
253
 
239
- Start Rails, then:
254
+ For a simple development tunnel, use the explicit direct fallback. Start Rails, then:
240
255
 
241
256
  ```sh
242
- bin/rails cloudflare:email:deploy_worker
257
+ INBOUND_DELIVERY_MODE=direct bin/rails cloudflare:email:deploy_worker
243
258
  bin/rails cloudflare:email:dev
244
259
  ```
245
260
 
@@ -247,7 +262,7 @@ The dev task requires `cloudflared`, refuses environments other than development
247
262
 
248
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.
249
264
 
250
- Ruby deployment and Wrangler use matching names: `cloudflare-email-ingress-development`, `-staging`, and `-production`. The optional Wrangler path requires Node 22.12+ (or a supported newer version):
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):
251
266
 
252
267
  ```sh
253
268
  cd cloudflare-worker
@@ -312,6 +327,8 @@ Cloudflare's current [header documentation](https://developers.cloudflare.com/em
312
327
  | `account_id`, `api_token` | Required |
313
328
  | `base_url` | `https://api.cloudflare.com/client/v4` |
314
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 |
315
332
  | `retries` | 3 additional attempts |
316
333
  | `initial_backoff` | 0.5 seconds, doubling |
317
334
  | `max_retry_after` | 60 seconds |
@@ -320,6 +337,14 @@ Cloudflare's current [header documentation](https://developers.cloudflare.com/em
320
337
 
321
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.
322
339
 
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.
347
+
323
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.
324
349
 
325
350
  Errors inherit from `Cloudflare::Email::Error`: `ConfigurationError`, `AuthenticationError`, `ValidationError`, `RateLimitError`, `ServerError`, and `NetworkError`. API errors expose `status` and parsed `response`.
@@ -1,16 +1,12 @@
1
- require "cloudflare/email/verification"
1
+ require "cloudflare/email/ingress"
2
2
 
3
3
  module Cloudflare
4
4
  module Email
5
5
  # ActionMailbox ingress for Cloudflare Email Worker forwards.
6
6
  #
7
- # The shipped Worker template signs each forwarded message with HMAC-SHA256
8
- # over "v2.{timestamp}.{encoded_envelope}.{raw_body}" and sends:
9
- # X-CF-Email-Timestamp: <unix seconds>
10
- # X-CF-Email-Signature: <hex digest>
11
- # X-CF-Email-Signature-Version: 2
12
- # X-CF-Email-Envelope: <unpadded base64url JSON from/to>
13
- # Requests must include the v2 signature and authenticated SMTP envelope.
7
+ # The default Worker uses v2 authenticated SMTP envelopes; custom Workers
8
+ # can opt into v3 to authenticate additional provider metadata. Both use
9
+ # the shared, bounded Ingress verifier before any tenant lookup or storage.
14
10
  #
15
11
  # Set the shared secret in Rails credentials under cloudflare.ingress_secret
16
12
  # (or in the CLOUDFLARE_INGRESS_SECRET env var) and as the Worker secret
@@ -24,45 +20,30 @@ module Cloudflare
24
20
  "cloudflare_email.ingress",
25
21
  bytes: 0,
26
22
  ) do |payload|
27
- preflight = Cloudflare::Email::Verification.verify_headers(secret: secret,
28
- timestamp: request.headers["X-CF-Email-Timestamp"],
29
- signature: request.headers["X-CF-Email-Signature"],
30
- version: request.headers["X-CF-Email-Signature-Version"],
31
- envelope: request.headers["X-CF-Email-Envelope"])
32
- unless preflight == :ok
33
- payload[:result] = preflight
34
- next head(preflight == :stale ? :request_timeout : :unauthorized)
35
- end
36
- if request.content_length.to_i > max_email_bytes || raw_body.bytesize > max_email_bytes
37
- payload[:result] = :too_large
38
- next head(:payload_too_large)
39
- end
40
- payload[:bytes] = raw_body.bytesize
41
- case Cloudflare::Email::Verification.verify(
42
- secret: secret,
43
- body: raw_body,
44
- timestamp: request.headers["X-CF-Email-Timestamp"],
45
- signature: request.headers["X-CF-Email-Signature"],
46
- version: request.headers["X-CF-Email-Signature-Version"],
47
- envelope: request.headers["X-CF-Email-Envelope"],
48
- )
23
+ request.body.rewind if request.body.respond_to?(:rewind)
24
+ result = Cloudflare::Email::Ingress.verify(secret: secret,
25
+ headers: request.headers, body: request.body,
26
+ content_length: request.content_length, max_email_bytes: max_email_bytes)
27
+ payload[:bytes] = result.bytes.to_i
28
+ payload[:result] = result.status
29
+ case result.status
49
30
  when :stale
50
- payload[:result] = :stale
51
31
  head :request_timeout
52
32
  when :bad_signature
53
- payload[:result] = :bad_signature
54
33
  head :unauthorized
34
+ when :too_large
35
+ head :payload_too_large
55
36
  when :ok
56
37
  inbound = if defined?(Cloudflare::Email::Mailboxes) && Cloudflare::Email::Mailboxes.enabled?
57
- recipient = Cloudflare::Email::Envelope.decode(request.headers["X-CF-Email-Envelope"]).fetch("to")
38
+ recipient = result.message.envelope.fetch("to")
58
39
  begin
59
- Cloudflare::Email::Mailboxes.receive(recipient: recipient) { persist_inbound }
40
+ Cloudflare::Email::Mailboxes.receive(recipient: recipient) { result.message.persist_action_mailbox! }
60
41
  rescue Cloudflare::Email::Mailboxes::Unavailable
61
42
  payload[:result] = :unavailable_mailbox
62
43
  next head(:unprocessable_entity)
63
44
  end
64
45
  else
65
- persist_inbound
46
+ result.message.persist_action_mailbox!
66
47
  end
67
48
  payload[:result] = inbound ? :ok : :duplicate
68
49
  payload[:message_id] = inbound&.message_id
@@ -73,37 +54,12 @@ module Cloudflare
73
54
 
74
55
  private
75
56
 
76
- def persist_inbound
77
- envelope = Cloudflare::Email::Envelope.decode(request.headers["X-CF-Email-Envelope"])
78
-
79
- # Commit trusted routing metadata before ActionMailbox's after_create_commit
80
- # enqueues routing. Identical MIME for To/Cc/Bcc recipients is independent.
81
- checksum = OpenSSL::Digest::SHA256.hexdigest("v2\0#{envelope.fetch('to')}\0".b + raw_body.b)
82
- ActionMailbox::InboundEmail.transaction do
83
- inbound = ActionMailbox::InboundEmail.create_and_extract_message_id!(raw_body, message_checksum: checksum)
84
- if inbound
85
- blob = inbound.raw_email.blob
86
- blob.update!(metadata: blob.metadata.merge(
87
- Cloudflare::Email::Envelope::METADATA_KEY => envelope.merge("version" => 2),
88
- ))
89
- end
90
- inbound
91
- end
92
- end
93
-
94
57
  # Override ActionMailbox::BaseController's default name inference so
95
58
  # `config.action_mailbox.ingress = :cloudflare` gates this controller.
96
59
  def ingress_name
97
60
  :cloudflare
98
61
  end
99
62
 
100
- def raw_body
101
- @raw_body ||= begin
102
- request.body.rewind if request.body.respond_to?(:rewind)
103
- request.body.read(max_email_bytes + 1).to_s
104
- end
105
- end
106
-
107
63
  def max_email_bytes
108
64
  @max_email_bytes ||= begin
109
65
  value = Integer(ENV.fetch("MAX_EMAIL_BYTES", DEFAULT_MAX_EMAIL_BYTES.to_s), 10)
@@ -0,0 +1,173 @@
1
+ module Cloudflare
2
+ module Email
3
+ module Management
4
+ class MailboxesController < ActionController::Base
5
+ layout "layouts/cloudflare/email/management"
6
+ protect_from_forgery with: :exception
7
+ self.forgery_protection_origin_check = true
8
+ around_action :with_host_context
9
+ helper_method :allowed?, :host_back_path
10
+
11
+ class Denied < StandardError; end
12
+ class InvalidInput < StandardError; end
13
+
14
+ rescue_from Denied do
15
+ head :forbidden
16
+ end
17
+ rescue_from "ActiveRecord::RecordNotFound" do
18
+ head :not_found
19
+ end
20
+ rescue_from InvalidInput, "ActiveRecord::RecordInvalid", Cloudflare::Email::Error do
21
+ redirect_to(@mailbox ? mailbox_path(@mailbox) : root_path,
22
+ alert: "The changes could not be saved. Check the address, registered domain and required fields.",
23
+ status: :see_other)
24
+ end
25
+ rescue_from Cloudflare::Email::ConfigurationError do
26
+ head :service_unavailable
27
+ end
28
+
29
+ def index
30
+ authorize!(:index)
31
+ @mailboxes = page(scoped_mailboxes)
32
+ @domains = permitted_domains if allowed?(:create)
33
+ end
34
+
35
+ def show
36
+ load_mailbox!(:show)
37
+ @addresses = @session.addresses(@mailbox.id).order(:id).to_a
38
+ @domains = allowed?(:add_address, @mailbox) ? permitted_domains : []
39
+ @messages = allowed?(:show_message, @mailbox) ? page(@session.messages(@mailbox.id)) : []
40
+ end
41
+
42
+ def create
43
+ authorize!(:create)
44
+ input = params.require(:mailbox)
45
+ raise InvalidInput unless input.is_a?(ActionController::Parameters)
46
+ values = input.permit(:name, :address)
47
+ name = values[:name].to_s.strip
48
+ raise InvalidInput unless (1..255).cover?(name.length)
49
+ address = permitted_address!(values[:address])
50
+ Mailboxes::Mailbox.transaction do
51
+ @mailbox = @adapter.create_mailbox(@session, name: name, address: address)
52
+ # A hook cannot turn an arbitrary return value into a readable mailbox.
53
+ @mailbox = scoped_mailboxes.find(@mailbox.id)
54
+ end
55
+ redirect_to mailbox_path(@mailbox), notice: "Mailbox created. Review address setup below.", status: :see_other
56
+ end
57
+
58
+ def add_address
59
+ load_mailbox!(:add_address)
60
+ @adapter.add_address(@session, @mailbox, address: permitted_address!(params[:address]))
61
+ redirect_to mailbox_path(@mailbox), notice: "Address added. Review its setup status.", status: :see_other
62
+ end
63
+
64
+ def suspend
65
+ load_mailbox!(:suspend)
66
+ @session.suspend(@mailbox.id)
67
+ redirect_to mailbox_path(@mailbox), notice: "Mailbox suspended. Messages are retained.", status: :see_other
68
+ end
69
+
70
+ def resume
71
+ load_mailbox!(:resume)
72
+ @session.resume(@mailbox.id)
73
+ redirect_to mailbox_path(@mailbox), notice: "Mailbox resumed. Pending addresses still need setup.", status: :see_other
74
+ end
75
+
76
+ def message
77
+ load_mailbox!(:show_message)
78
+ @entry = @session.messages(@mailbox.id).find(params[:message_id])
79
+ raw = @session.inbound_email(@mailbox.id, @entry.id).mail
80
+ @subject = raw.subject.to_s
81
+ @from = Array(raw.from).join(", ")
82
+ part = raw.text_part || (raw.mime_type == "text/plain" ? raw : nil)
83
+ @body = part ? part.decoded.to_s.encode("UTF-8", invalid: :replace, undef: :replace).first(100_000) :
84
+ "This message has no plain-text body. HTML rendering is disabled in this management interface."
85
+ @attachments = raw.attachments.map { |attachment| attachment.filename.to_s }
86
+ render :message
87
+ end
88
+
89
+ def mark_read
90
+ load_mailbox!(:mark_read)
91
+ @session.mark_read(@mailbox.id, params[:message_id], read: boolean_param(:read))
92
+ redirect_to mailbox_path(@mailbox), status: :see_other
93
+ end
94
+
95
+ def archive
96
+ load_mailbox!(:archive)
97
+ @session.archive(@mailbox.id, params[:message_id], archived: boolean_param(:archived))
98
+ redirect_to mailbox_path(@mailbox), status: :see_other
99
+ end
100
+
101
+ private
102
+
103
+ def with_host_context
104
+ response.headers["Cache-Control"] = "no-store"
105
+ response.headers["Referrer-Policy"] = "same-origin"
106
+ response.headers["X-Content-Type-Options"] = "nosniff"
107
+ response.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'self'; img-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
108
+ factory = Management.configuration.adapter
109
+ unless factory.respond_to?(:call) && defined?(Mailboxes) && Mailboxes.enabled?
110
+ return head :service_unavailable
111
+ end
112
+ @adapter = factory.call(self)
113
+ authenticated = @adapter.authenticate!
114
+ return if performed?
115
+ return head :unauthorized unless authenticated == true
116
+ Mailboxes.for_tenant(@adapter.tenant_key) do |session|
117
+ @session = session
118
+ yield
119
+ end
120
+ end
121
+
122
+ def allowed?(action, mailbox = nil)
123
+ @adapter.allowed?(action, mailbox) == true
124
+ end
125
+
126
+ def authorize!(action, mailbox = nil)
127
+ raise Denied unless allowed?(action, mailbox)
128
+ end
129
+
130
+ def scoped_mailboxes
131
+ # Always retain the gem's tenant condition even if a host scope omits it.
132
+ @session.mailboxes.where(id: @adapter.mailboxes(@session).reselect(:id))
133
+ end
134
+
135
+ def load_mailbox!(action)
136
+ @mailbox = scoped_mailboxes.find(params[:id])
137
+ authorize!(action, @mailbox)
138
+ end
139
+
140
+ def permitted_domains
141
+ granted = Array(@adapter.domains(@session)).map(&:to_s)
142
+ Mailboxes::ReceivingDomain.active.where(tenant_key: @session.tenant_key, domain: granted).order(:domain).pluck(:domain)
143
+ end
144
+
145
+ def permitted_address!(value)
146
+ address = Mailboxes.canonical_address(value)
147
+ raise InvalidInput unless permitted_domains.include?(address.split("@", 2).last)
148
+ address
149
+ end
150
+
151
+ def page(relation)
152
+ cursor = params[:after].to_s
153
+ raise InvalidInput unless cursor.empty? || cursor.match?(/\A[0-9]{1,18}\z/)
154
+ rows = relation.where("id > ?", cursor.to_i).order(:id).limit(51).to_a
155
+ @next_cursor = rows.length > 50 ? rows[49].id : nil
156
+ rows.first(50)
157
+ end
158
+
159
+ def boolean_param(key)
160
+ value = params[key]
161
+ raise InvalidInput unless %w[true false].include?(value)
162
+ value == "true"
163
+ end
164
+
165
+ def host_back_path
166
+ callback = Management.configuration.back_path
167
+ value = callback.call(self) if callback.respond_to?(:call)
168
+ value if value.is_a?(String) && value.start_with?("/") && !value.start_with?("//") && !value.match?(/[\\\r\n]/)
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,13 @@
1
+ module Cloudflare
2
+ module Email
3
+ module Management
4
+ class StylesController < ActionController::Base
5
+ def show
6
+ response.headers["X-Content-Type-Options"] = "nosniff"
7
+ send_data File.binread(File.expand_path("../../../../../lib/cloudflare/email/management/management.css", __dir__)),
8
+ type: "text/css; charset=utf-8", disposition: "inline"
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,65 @@
1
+ <div class="page-heading">
2
+ <div>
3
+ <p class="eyebrow">Email workspace</p>
4
+ <h1>Mailboxes</h1>
5
+ <p class="muted">Manage accepted addresses and the messages delivered to them.</p>
6
+ </div>
7
+ </div>
8
+
9
+ <div class="workspace-grid">
10
+ <section class="panel" aria-labelledby="mailboxes-heading">
11
+ <div class="panel-heading"><h2 id="mailboxes-heading">Your mailboxes</h2></div>
12
+ <% if @mailboxes.empty? %>
13
+ <div class="empty-state">
14
+ <h3>No mailboxes to show</h3>
15
+ <p>Create a mailbox to give incoming email a home.</p>
16
+ </div>
17
+ <% else %>
18
+ <ul class="record-list">
19
+ <% @mailboxes.each do |mailbox| %>
20
+ <li class="record-row">
21
+ <div class="record-content">
22
+ <% if allowed?(:show, mailbox) %>
23
+ <%= link_to mailbox.name, mailbox_path(mailbox.id), class: "record-title" %>
24
+ <% else %>
25
+ <span class="record-title"><%= mailbox.name %></span>
26
+ <% end %>
27
+ <p class="muted small">Mailbox #<%= mailbox.id %></p>
28
+ </div>
29
+ <span class="badge"><%= mailbox.state.capitalize %></span>
30
+ </li>
31
+ <% end %>
32
+ </ul>
33
+ <% end %>
34
+ <% if @next_cursor.present? %>
35
+ <nav class="pagination" aria-label="Mailbox pages">
36
+ <%= link_to "Next mailboxes →", root_path(after: @next_cursor), class: "button button-secondary" %>
37
+ </nav>
38
+ <% end %>
39
+ </section>
40
+
41
+ <% if allowed?(:create) %>
42
+ <section class="panel" aria-labelledby="create-heading">
43
+ <div class="panel-heading"><h2 id="create-heading">Create a mailbox</h2></div>
44
+ <div class="panel-body">
45
+ <% if @domains.empty? %>
46
+ <p class="muted">An administrator needs to register an active receiving domain before you can create a mailbox.</p>
47
+ <% else %>
48
+ <%= form_with url: mailboxes_path, scope: :mailbox, local: true, class: "stack" do |form| %>
49
+ <div class="field">
50
+ <%= form.label :name, "Mailbox name" %>
51
+ <%= form.text_field :name, required: true, maxlength: 255, placeholder: "Customer support" %>
52
+ </div>
53
+ <div class="field">
54
+ <%= form.label :address, "Email address" %>
55
+ <%= form.email_field :address, required: true, maxlength: 254, placeholder: "support@#{@domains.first}", aria: { describedby: "create-domains" } %>
56
+ <p id="create-domains" class="field-help">Available domains: <%= @domains.join(", ") %></p>
57
+ </div>
58
+ <p class="field-help">New addresses start pending. Your administrator must confirm their routing before they can receive mail.</p>
59
+ <%= form.submit "Create mailbox", class: "button" %>
60
+ <% end %>
61
+ <% end %>
62
+ </div>
63
+ </section>
64
+ <% end %>
65
+ </div>
@@ -0,0 +1,34 @@
1
+ <nav class="breadcrumbs" aria-label="Breadcrumb">
2
+ <%= link_to "Mailboxes", root_path %> <span aria-hidden="true">/</span>
3
+ <%= link_to @mailbox.name, mailbox_path(@mailbox.id) %> <span aria-hidden="true">/</span>
4
+ <span>Message</span>
5
+ </nav>
6
+ <div class="page-heading">
7
+ <div><p class="eyebrow">Received message</p><h1><%= @subject.presence || "(No subject)" %></h1></div>
8
+ <div class="actions">
9
+ <% if allowed?(:mark_read, @mailbox) %>
10
+ <%= button_to(@entry.read_at ? "Mark unread" : "Mark read", mark_read_mailbox_path(@mailbox.id), method: :post, params: { message_id: @entry.id, read: @entry.read_at ? "false" : "true" }, class: "button button-secondary") %>
11
+ <% end %>
12
+ <% if allowed?(:archive, @mailbox) %>
13
+ <%= button_to(@entry.archived_at ? "Unarchive" : "Archive", archive_mailbox_path(@mailbox.id), method: :post, params: { message_id: @entry.id, archived: @entry.archived_at ? "false" : "true" }, class: "button button-secondary") %>
14
+ <% end %>
15
+ </div>
16
+ </div>
17
+ <article class="panel" aria-label="Email message">
18
+ <div class="panel-body">
19
+ <dl class="message-metadata">
20
+ <dt>From</dt><dd><%= @from.presence || "(Unknown sender)" %></dd>
21
+ <dt>To</dt><dd><%= @entry.recipient %></dd>
22
+ <dt>Received</dt><dd><time datetime="<%= @entry.created_at.iso8601 %>"><%= @entry.created_at.utc.strftime("%b %-d, %Y · %H:%M UTC") %></time></dd>
23
+ <dt>Status</dt><dd><%= @entry.read_at ? "Read" : "Unread" %><%= " · Archived" if @entry.archived_at %></dd>
24
+ </dl>
25
+ </div>
26
+ <div class="message-body"><pre><%= @body.presence || "No text content is available for this message." %></pre></div>
27
+ <% if @attachments.any? %>
28
+ <section class="panel-body attachments" aria-labelledby="attachments-heading">
29
+ <h2 id="attachments-heading">Attachments</h2>
30
+ <p class="field-help">File names are shown for reference. Downloads are not available in this dashboard.</p>
31
+ <ul><% @attachments.each do |filename| %><li><%= filename.presence || "Unnamed attachment" %></li><% end %></ul>
32
+ </section>
33
+ <% end %>
34
+ </article>