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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -0
- data/README.md +34 -9
- data/app/controllers/cloudflare/email/ingress_controller.rb +16 -60
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
- data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
- data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
- data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
- data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
- data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
- data/docs/activerecord-tenanted.md +46 -0
- data/docs/architecture.md +13 -3
- data/docs/custom-ingress.md +207 -0
- data/docs/delivery-events.md +9 -0
- data/docs/features.md +36 -5
- data/docs/getting-started.md +18 -8
- data/docs/mailboxes.md +67 -3
- data/docs/management-engine.md +190 -0
- data/docs/routing-deliveries.md +94 -0
- data/docs/routing-diagnostics.md +68 -0
- data/docs/troubleshooting.md +4 -3
- data/docs/upgrading-0.2.md +5 -0
- data/docs/upgrading-0.3.md +55 -0
- data/docs/verification/2026-09-11-custom-ingress.md +49 -0
- data/docs/verification/2026-09-12-destination-context.md +46 -0
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
- data/lib/cloudflare/email/active_record/base.rb +13 -1
- data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
- data/lib/cloudflare/email/check_route_task.rb +21 -0
- data/lib/cloudflare/email/client.rb +45 -10
- data/lib/cloudflare/email/delivery_method.rb +2 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
- data/lib/cloudflare/email/engine.rb +5 -0
- data/lib/cloudflare/email/envelope.rb +2 -2
- data/lib/cloudflare/email/event_consumer.rb +12 -3
- data/lib/cloudflare/email/ingress.rb +115 -0
- data/lib/cloudflare/email/mailboxes/models.rb +19 -2
- data/lib/cloudflare/email/mailboxes/service.rb +99 -15
- data/lib/cloudflare/email/management/adapter.rb +31 -0
- data/lib/cloudflare/email/management/configuration.rb +17 -0
- data/lib/cloudflare/email/management/engine.rb +15 -0
- data/lib/cloudflare/email/management/management.css +68 -0
- data/lib/cloudflare/email/management/routes.rb +14 -0
- data/lib/cloudflare/email/management.rb +7 -0
- data/lib/cloudflare/email/provider_metadata.rb +77 -0
- data/lib/cloudflare/email/routing_analytics.rb +196 -0
- data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
- data/lib/cloudflare/email/verification.rb +17 -9
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +36 -5
- data/lib/generators/cloudflare/email/install_generator.rb +6 -5
- data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
- data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +4 -0
- data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
- data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
- data/lib/tasks/cloudflare_email.rake +2 -1
- data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
- data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
- data/templates/deploy-to-cloudflare/.gitignore +6 -0
- data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
- data/templates/deploy-to-cloudflare/README.md +84 -0
- data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
- data/templates/deploy-to-cloudflare/package.json +36 -0
- data/templates/deploy-to-cloudflare/src/index.js +445 -0
- data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
- data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
- data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
- data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
- data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
- data/templates/worker/README.md +180 -15
- data/templates/worker/docs/durable-inbound.md +149 -0
- data/templates/worker/package-lock.json +2 -2
- data/templates/worker/package.json +1 -1
- data/templates/worker/src/index.js +331 -38
- data/templates/worker/test/durable.test.ts +193 -0
- data/templates/worker/test/index.test.ts +78 -20
- data/templates/worker/test/relay.test.ts +226 -0
- data/templates/worker/wrangler.toml +50 -0
- metadata +49 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b8b7e8e8e9c8f102cf640de5aa577092947f829853daea59463263b8729ede21
|
|
4
|
+
data.tar.gz: 7303add7cb750e309c338132e114ecd40340bf5e9ee39b6b03c3f28acd9887d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
+
[](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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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`.
|
|
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/
|
|
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
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 =
|
|
38
|
+
recipient = result.message.envelope.fetch("to")
|
|
58
39
|
begin
|
|
59
|
-
Cloudflare::Email::Mailboxes.receive(recipient: recipient) {
|
|
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
|
-
|
|
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>
|