cloudflare-email 0.2.0 → 0.4.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 +109 -0
- data/README.md +44 -9
- data/app/controllers/cloudflare/email/ingress_controller.rb +15 -60
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +9 -0
- data/app/controllers/cloudflare/email/management/styles_controller.rb +9 -0
- data/docs/activerecord-tenanted.md +46 -0
- data/docs/architecture.md +13 -3
- data/docs/custom-ingress.md +211 -0
- data/docs/delivery-events.md +9 -0
- data/docs/features.md +36 -5
- data/docs/getting-started.md +19 -8
- data/docs/mailboxes.md +74 -3
- data/docs/management-engine.md +190 -0
- data/docs/routing-deliveries.md +94 -0
- data/docs/routing-diagnostics.md +75 -0
- data/docs/troubleshooting.md +5 -4
- 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-action-mailbox-core.md +96 -0
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
- data/lib/cloudflare/email/active_record/base.rb +3 -41
- 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 +6 -13
- data/lib/cloudflare/email/envelope.rb +4 -14
- data/lib/cloudflare/email/error.rb +5 -12
- data/lib/cloudflare/email/event_consumer.rb +12 -3
- data/lib/cloudflare/email/ingress.rb +126 -0
- data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
- data/lib/cloudflare/email/mailboxes/models.rb +8 -130
- data/lib/cloudflare/email/mailboxes/service.rb +7 -174
- data/lib/cloudflare/email/mailboxes.rb +1 -0
- data/lib/cloudflare/email/management/adapter.rb +9 -0
- data/lib/cloudflare/email/management/configuration.rb +10 -0
- data/lib/cloudflare/email/management/engine.rb +17 -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/tenancy.rb +2 -73
- data/lib/cloudflare/email/tenant_job_context.rb +2 -85
- 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/cloudflare-email.rb +6 -0
- 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 +5 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
- 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 +104 -0
- data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
- data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
- data/templates/deploy-to-cloudflare/package.json +37 -0
- data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -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/test/subdomains.test.ts +51 -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 +186 -15
- data/templates/worker/docs/domain-setup.md +198 -0
- data/templates/worker/docs/durable-inbound.md +149 -0
- data/templates/worker/package-lock.json +2 -2
- data/templates/worker/package.json +2 -1
- data/templates/worker/scripts/check-subdomains.mjs +54 -0
- 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/test/subdomains.test.ts +51 -0
- data/templates/worker/wrangler.toml +50 -0
- metadata +65 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c65b29d22af39c25cb2affb776984bc9a9086bcad75c7388c2017692f61eb9ef
|
|
4
|
+
data.tar.gz: 2c0d1f15f4159e780257260af62cca8d414c7cc00d7c1c8b2677ac243cf81b04
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4b76b7c08c99da504f7c90bbfb79f6f3130345e128c0fad9988a1449270d79019ffa5af8fb62a0759ad881c56019ffe6d9f6b967d13136e988b9e98ef6d7043e
|
|
7
|
+
data.tar.gz: d3f2d2573c6d41140ceff2395c4aa84194eb10ad2c56379df133f1fa1687424b009513800e4c67d1077584750146c2f5b9e0dfedfb53d2b9a351c5f207f80636
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,114 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 — 2026-09-14
|
|
4
|
+
|
|
5
|
+
Released alongside Mailbox Kit 0.1.0. Cloudflare installs the core automatically.
|
|
6
|
+
Existing installations should follow the [core upgrade guide](mailbox-kit/docs/upgrading.md).
|
|
7
|
+
|
|
8
|
+
- Allow receiving domains without a sending account. The upgrade generator
|
|
9
|
+
preserves existing directory records while making account ownership optional.
|
|
10
|
+
|
|
11
|
+
- Index inbox memberships by inbound email for recipient isolation checks,
|
|
12
|
+
retention and purge. New schemas include the index; `mailbox_kit:upgrade`
|
|
13
|
+
generates a data-preserving migration for existing installations.
|
|
14
|
+
- Delegate shared configuration to Mailbox Kit and isolate Cloudflare session
|
|
15
|
+
additions in an integration module. Provide a short core quickstart plus
|
|
16
|
+
packaged integration and upgrade guides.
|
|
17
|
+
|
|
18
|
+
- Lean on Action Mailbox for original email records, processing and configurable
|
|
19
|
+
retention. Add idempotent attachment of existing Rails records and source-based
|
|
20
|
+
receiving that resolves duplicates for inbox membership. Share the persistence
|
|
21
|
+
bridge with Cloudflare while preserving its signed delivery identity and legacy
|
|
22
|
+
duplicate return value. Reuse Rails' model load hook for selective retention.
|
|
23
|
+
|
|
24
|
+
- Extract provider-neutral mailbox storage, recipient routing, optional tenancy,
|
|
25
|
+
raw-message retention, Rails job context and the server-rendered UI into the
|
|
26
|
+
sibling `mailbox-kit` gem. Cloudflare APIs, Worker transport, sending outbox and
|
|
27
|
+
delivery feedback remain in `cloudflare-email`. Preserve existing table names,
|
|
28
|
+
public constants, job payload keys and mounted Cloudflare engine routes.
|
|
29
|
+
See [Mailbox Kit](mailbox-kit/README.md) for standalone setup and release order.
|
|
30
|
+
|
|
31
|
+
- Add a cited one-time domain setup guide for both receiving domains and dynamic
|
|
32
|
+
organization subdomains, shared between Worker templates. Add a read-only
|
|
33
|
+
`npm run check:subdomains` DNS helper with named/fresh probes and explicit limits
|
|
34
|
+
on what DNS can verify. The Worker transport and gem routing behavior are unchanged.
|
|
35
|
+
|
|
36
|
+
## 0.3.0 — 2026-09-13
|
|
37
|
+
|
|
38
|
+
See [upgrading from 0.2](docs/upgrading-0.3.md) before deploying the updated Worker.
|
|
39
|
+
|
|
40
|
+
- Add a standalone Deploy to Cloudflare template with R2/Queue provisioning,
|
|
41
|
+
secret prompts, scheduled recovery, and logs. CI checks runtime/configuration
|
|
42
|
+
synchronization with the gem's Worker and tests an isolated template copy.
|
|
43
|
+
|
|
44
|
+
- Make durable inbound delivery the Worker default, with explicit direct fallback: commit original mail and envelope context
|
|
45
|
+
to R2 before Worker acceptance, deliver queue pointers with fresh signatures,
|
|
46
|
+
and recover pending mail through a bounded scheduled sweep. Rails errors,
|
|
47
|
+
lost responses, enqueue failures and exhausted queue retries retain pending
|
|
48
|
+
mail. Provisioning, protected storage and monitoring remain explicit.
|
|
49
|
+
- Make missing Message-ID fallback deterministic across Rails hosts so replay
|
|
50
|
+
after a deployment does not create another inbound record. Original MIME and
|
|
51
|
+
parseable provider Message-IDs remain unchanged.
|
|
52
|
+
- Add optional authenticated Routing analytics delivery confirmation with
|
|
53
|
+
encrypted immutable receipts, strict single-envelope-recipient correlation,
|
|
54
|
+
replay and transactional callbacks. Sending acceptance remains unchanged;
|
|
55
|
+
missing or ambiguous evidence never authorizes a resend.
|
|
56
|
+
- Bound shared sending/queue HTTP responses by streamed bytes and a total
|
|
57
|
+
per-attempt deadline; disable implicit HTTP retries and sanitize transport
|
|
58
|
+
failures. Sending and queue acknowledgement ambiguity stays safe to replay
|
|
59
|
+
through durable receipts rather than resending accepted email.
|
|
60
|
+
- Document verified Routing destinations, separate analytics credentials and
|
|
61
|
+
explicit system-mail storage with optional SQLite tenancy.
|
|
62
|
+
|
|
63
|
+
- Add optional domain catch-all receiving on an existing active mailbox address,
|
|
64
|
+
with explicit verification evidence and a unique active catch-all constraint.
|
|
65
|
+
Exact registered addresses always win, including rejected inactive addresses;
|
|
66
|
+
unknown local parts retain their actual envelope recipient without new aliases.
|
|
67
|
+
Destination snapshots identify fallback, while sending stays exact-address only.
|
|
68
|
+
A separate generator upgrades existing mailbox schemas; catch-all is never
|
|
69
|
+
enabled automatically.
|
|
70
|
+
|
|
71
|
+
- Allow production Rails schema preloading to boot without a selected tenant.
|
|
72
|
+
Tenant model pool access still fails closed with
|
|
73
|
+
`Cloudflare::Email::ActiveRecord::TenantConnectionUnavailable`, an
|
|
74
|
+
`ActiveRecord::ConnectionNotEstablished` error retaining the original
|
|
75
|
+
`ConfigurationError` as its cause. No default tenant is selected.
|
|
76
|
+
|
|
77
|
+
- Add a reusable Worker `relayEmail` pipeline with host backend/header policies,
|
|
78
|
+
bounded raw reads, optional fail-open archive hooks, safe HTTP outcomes and
|
|
79
|
+
deadlines. `archiveEmail` writes raw bytes and envelope metadata to an optional
|
|
80
|
+
R2 bucket. Default and custom forwarding share the same HTTP transport.
|
|
81
|
+
- Keep provider authentication interpretation, archive keys, retry decisions and
|
|
82
|
+
fallback destinations with the host; existing default v2/v3 forwarding remains
|
|
83
|
+
compatible. Archive deadlines bound waiting but cannot cancel an in-flight write.
|
|
84
|
+
|
|
85
|
+
- Add `Mailboxes.with_recipient` for active-address resolution and immutable
|
|
86
|
+
destination context with host-owned persistence, without ActionMailbox.
|
|
87
|
+
- Yield the same destination from `Mailboxes.receive` so apps can link their
|
|
88
|
+
business records while retaining gem-managed raw email, metadata and membership
|
|
89
|
+
persistence. Host exceptions propagate instead of being reported as missing
|
|
90
|
+
mailbox records; existing no-argument receive blocks continue to work.
|
|
91
|
+
|
|
92
|
+
- Add a bounded, Rails-independent `Ingress.verify` API for existing ingestion
|
|
93
|
+
pipelines, with optional ActionMailbox persistence and tenant registry routing.
|
|
94
|
+
- Add opt-in v3 signatures for provider metadata supplied by custom Workers.
|
|
95
|
+
Metadata is authenticated transport evidence, not proof of sender authenticity.
|
|
96
|
+
Default Worker forwarding remains v2; upgrade Rails before enabling v3.
|
|
97
|
+
- Verify signatures against exact timestamp header bytes; decimal parsing is
|
|
98
|
+
used only for freshness. The bundled Worker's timestamp format is unchanged.
|
|
99
|
+
- Add GET-only `cloudflare:email:check_route` diagnostics for exact receiving-domain
|
|
100
|
+
DNS and Worker routing. Reports uncertainty without changing configuration or
|
|
101
|
+
claiming live delivery is verified.
|
|
102
|
+
|
|
103
|
+
- Add an opt-in server-rendered mailbox management engine with host authentication,
|
|
104
|
+
mailbox/domain scoping and per-action permissions. Manage mailboxes and aliases,
|
|
105
|
+
suspend/resume, preview plain-text messages and mark read/archive without
|
|
106
|
+
JavaScript or asset pipeline dependencies. The engine is never mounted by default.
|
|
107
|
+
|
|
108
|
+
- Fix managed domain and address activation in Rails apps with strict readonly
|
|
109
|
+
attributes enabled. Normalize immutable identities only when creating records;
|
|
110
|
+
later lifecycle updates retain the original domain and address.
|
|
111
|
+
|
|
3
112
|
## 0.2.0 — 2026-09-11
|
|
4
113
|
|
|
5
114
|
- Add optional managed mailboxes: domain directory, addresses/aliases, lifecycle,
|
data/README.md
CHANGED
|
@@ -2,10 +2,21 @@
|
|
|
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.4.0**, with **Mailbox Kit 0.1.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. Rails and database dependencies remain optional for the plain Ruby client. 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
|
|
|
13
|
+
**Starting in 0.4.0:** provider-neutral mailbox functionality lives in
|
|
14
|
+
the sibling [Mailbox Kit](mailbox-kit/README.md) gem. Existing Cloudflare setup
|
|
15
|
+
continues to work; the core requires no Cloudflare account. Installing
|
|
16
|
+
`cloudflare-email` also installs `mailbox-kit` 0.1.x.
|
|
17
|
+
Existing mailbox installations should use the [core upgrade guide](mailbox-kit/docs/upgrading.md)
|
|
18
|
+
to add the inbound lookup index without recreating their tables.
|
|
19
|
+
|
|
9
20
|
**Sending email?** Start with the [step-by-step Rails guide](docs/getting-started.md).
|
|
10
21
|
**Building a mailbox?** Follow the same guide through receiving, the SQLite-compatible outbox, and delivery tracking.
|
|
11
22
|
**Using plain Ruby?** Jump to [Plain Ruby](#plain-ruby); Rails and a database are optional.
|
|
@@ -14,21 +25,30 @@ Version **0.2.0**. Ruby 3.2+, Rails 7.2–8.1; Ruby 4.0 is tested with Rails 8.1
|
|
|
14
25
|
| --- | --- |
|
|
15
26
|
| [Features at a glance](docs/features.md) | Everything the gem handles, and what your app supplies |
|
|
16
27
|
| [Getting started](docs/getting-started.md) | Install, send your first email, receive replies, and save reliable send operations |
|
|
28
|
+
| [Cloudflare domain setup](templates/worker/docs/domain-setup.md) | Configure once for many mailboxes or dynamic organization subdomains, with a Rebulk example and DNS checks |
|
|
29
|
+
| [Hello-world Rails template](https://github.com/cole-robertson/cloudflare-email-rails-starter) | Clone a minimal SQLite mailbox app using the published gem and its management UI |
|
|
17
30
|
| [Troubleshooting](docs/troubleshooting.md) | What to check when mail or delivery updates do not arrive |
|
|
18
31
|
| [Managed mailboxes](docs/mailboxes.md) | Create inboxes and aliases, read/archive mail, and send from a mailbox |
|
|
32
|
+
| [Management engine](docs/management-engine.md) | Mount an optional server-rendered mailbox UI using your app's authentication |
|
|
33
|
+
| [Custom ingress](docs/custom-ingress.md) | Reuse authentication, signed metadata, and tenant routing in your existing ingestion pipeline |
|
|
34
|
+
| [Routing diagnostics](docs/routing-diagnostics.md) | Inspect exact-domain DNS and Worker routes without changing infrastructure |
|
|
35
|
+
| [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 |
|
|
36
|
+
| [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 |
|
|
37
|
+
| [Routing delivery confirmation](docs/routing-deliveries.md) | Confirm qualifying normal-address deliveries using authenticated Routing analytics |
|
|
19
38
|
| [SQLite tenant databases](docs/activerecord-tenanted.md) | Give each organization its own SQLite database with `activerecord-tenanted` |
|
|
20
39
|
| [Durable outbox](docs/outbox.md) | Detailed setup, callbacks, retries, and recovery |
|
|
21
40
|
| [Delivery events](docs/delivery-events.md) | Cloudflare Queue setup and recipient status tracking |
|
|
22
|
-
| [Upgrading to 0.
|
|
41
|
+
| [Upgrading to 0.4](mailbox-kit/docs/upgrading.md) | Core extraction and data-preserving migrations for existing mailboxes |
|
|
42
|
+
| [Upgrading from 0.2](docs/upgrading-0.3.md) | Additional changes before deploying the durable Worker |
|
|
23
43
|
|
|
24
44
|
The sections below are the configuration and API reference.
|
|
25
45
|
|
|
26
46
|
## Install and send from Rails
|
|
27
47
|
|
|
28
|
-
Add version 0.
|
|
48
|
+
Add version 0.4 to your Gemfile. Existing mailbox users should follow the [core upgrade guide](mailbox-kit/docs/upgrading.md). When upgrading from 0.2, also follow the [durable Worker upgrade guide](docs/upgrading-0.3.md):
|
|
29
49
|
|
|
30
50
|
```ruby
|
|
31
|
-
gem "cloudflare-email", "~> 0.
|
|
51
|
+
gem "cloudflare-email", "~> 0.4.0"
|
|
32
52
|
```
|
|
33
53
|
|
|
34
54
|
```sh
|
|
@@ -209,6 +229,11 @@ Equivalent environment names are `CLOUDFLARE_MANAGEMENT_TOKEN` and `CLOUDFLARE_I
|
|
|
209
229
|
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
230
|
2. Deploy the environment's Worker and create its route:
|
|
211
231
|
|
|
232
|
+
First create the private R2 bucket and Queue, then deploy the generated Wrangler
|
|
233
|
+
configuration using the [durable setup guide](templates/worker/docs/durable-inbound.md).
|
|
234
|
+
The Ruby task below updates that provisioned Worker while preserving its bindings.
|
|
235
|
+
It refuses a missing bucket/queue binding or recovery schedule before changing code.
|
|
236
|
+
|
|
212
237
|
```sh
|
|
213
238
|
RAILS_ENV=production bin/rails cloudflare:email:deploy_worker URL=https://app.example.com/rails/action_mailbox/cloudflare/inbound_emails
|
|
214
239
|
RAILS_ENV=production bin/rails cloudflare:email:provision_route ADDRESS=support@in.example.com
|
|
@@ -228,18 +253,18 @@ end
|
|
|
228
253
|
|
|
229
254
|
`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
255
|
|
|
231
|
-
|
|
256
|
+
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
257
|
|
|
233
258
|
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
259
|
|
|
235
|
-
The Worker has a 15-second Rails request timeout and rejects redirects.
|
|
260
|
+
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
261
|
|
|
237
262
|
### Local development and deployment
|
|
238
263
|
|
|
239
|
-
Start Rails, then:
|
|
264
|
+
For a simple development tunnel, use the explicit direct fallback. Start Rails, then:
|
|
240
265
|
|
|
241
266
|
```sh
|
|
242
|
-
bin/rails cloudflare:email:deploy_worker
|
|
267
|
+
INBOUND_DELIVERY_MODE=direct bin/rails cloudflare:email:deploy_worker
|
|
243
268
|
bin/rails cloudflare:email:dev
|
|
244
269
|
```
|
|
245
270
|
|
|
@@ -247,7 +272,7 @@ The dev task requires `cloudflared`, refuses environments other than development
|
|
|
247
272
|
|
|
248
273
|
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
274
|
|
|
250
|
-
Ruby deployment and Wrangler use matching names: `cloudflare-email-ingress-development`, `-staging`, and `-production`.
|
|
275
|
+
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
276
|
|
|
252
277
|
```sh
|
|
253
278
|
cd cloudflare-worker
|
|
@@ -312,6 +337,8 @@ Cloudflare's current [header documentation](https://developers.cloudflare.com/em
|
|
|
312
337
|
| `account_id`, `api_token` | Required |
|
|
313
338
|
| `base_url` | `https://api.cloudflare.com/client/v4` |
|
|
314
339
|
| `timeout` | 30 seconds for open/read/write |
|
|
340
|
+
| `total_timeout` | Defaults to `timeout`; bounds one complete HTTP attempt, including headers and streamed body |
|
|
341
|
+
| `max_response_bytes` | 1 MiB for `Client`; 32 MiB for `EventConsumer` queue batches |
|
|
315
342
|
| `retries` | 3 additional attempts |
|
|
316
343
|
| `initial_backoff` | 0.5 seconds, doubling |
|
|
317
344
|
| `max_retry_after` | 60 seconds |
|
|
@@ -320,6 +347,14 @@ Cloudflare's current [header documentation](https://developers.cloudflare.com/em
|
|
|
320
347
|
|
|
321
348
|
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
349
|
|
|
350
|
+
Responses are streamed with a byte limit, including decompressed content and
|
|
351
|
+
error responses. A total deadline or response-size failure after transmission
|
|
352
|
+
does not prove a send failed: the outbox preserves the ambiguous claim and blocks
|
|
353
|
+
automatic resend. Net::HTTP's implicit retries are disabled. Explicit retries
|
|
354
|
+
and backoff can make a logical call longer than `total_timeout`; queue handlers,
|
|
355
|
+
database transactions and a complete polling run have separate runtime budgets.
|
|
356
|
+
An uncertain queue acknowledgement permits receipt replay, never email resending.
|
|
357
|
+
|
|
323
358
|
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
359
|
|
|
325
360
|
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,29 @@ 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 = Cloudflare::Email::Envelope.decode(request.headers["X-CF-Email-Envelope"]).fetch("to")
|
|
58
38
|
begin
|
|
59
|
-
|
|
39
|
+
result.message.receive_into_mailbox!
|
|
60
40
|
rescue Cloudflare::Email::Mailboxes::Unavailable
|
|
61
41
|
payload[:result] = :unavailable_mailbox
|
|
62
42
|
next head(:unprocessable_entity)
|
|
63
43
|
end
|
|
64
44
|
else
|
|
65
|
-
|
|
45
|
+
result.message.persist_action_mailbox!
|
|
66
46
|
end
|
|
67
47
|
payload[:result] = inbound ? :ok : :duplicate
|
|
68
48
|
payload[:message_id] = inbound&.message_id
|
|
@@ -73,37 +53,12 @@ module Cloudflare
|
|
|
73
53
|
|
|
74
54
|
private
|
|
75
55
|
|
|
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
56
|
# Override ActionMailbox::BaseController's default name inference so
|
|
95
57
|
# `config.action_mailbox.ingress = :cloudflare` gates this controller.
|
|
96
58
|
def ingress_name
|
|
97
59
|
:cloudflare
|
|
98
60
|
end
|
|
99
61
|
|
|
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
62
|
def max_email_bytes
|
|
108
63
|
@max_email_bytes ||= begin
|
|
109
64
|
value = Integer(ENV.fetch("MAX_EMAIL_BYTES", DEFAULT_MAX_EMAIL_BYTES.to_s), 10)
|
|
@@ -123,6 +123,52 @@ end
|
|
|
123
123
|
|
|
124
124
|
See the [mailbox guide](mailboxes.md) for domain activation, address provisioning, sending, and event replay. Your application still authorizes which organization, mailbox, and message each user can access.
|
|
125
125
|
|
|
126
|
+
## Account emails without an organization
|
|
127
|
+
|
|
128
|
+
Password resets and portal invitations can belong to the application rather
|
|
129
|
+
than an organization. If you enabled separate tenant databases, give those
|
|
130
|
+
outbox records an explicitly provisioned, host-reserved database too. Keep the
|
|
131
|
+
business record's organization empty; a storage key is not an organization or
|
|
132
|
+
an authorization grant.
|
|
133
|
+
|
|
134
|
+
For example, after configuring the adapter above, provision and migrate a
|
|
135
|
+
reserved database through your deployment workflow:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
# Run once through trusted provisioning, not during sending or event replay.
|
|
139
|
+
TenantRecord.create_tenant("_system_outbound")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Your own service can select storage consistently for sending and feedback:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
module EmailStorage
|
|
146
|
+
SYSTEM_KEY = "_system_outbound".freeze
|
|
147
|
+
|
|
148
|
+
def self.with_outbound_storage(organization_key, &block)
|
|
149
|
+
# The caller must supply an already-authorized organization key.
|
|
150
|
+
key = organization_key.nil? ? SYSTEM_KEY : organization_key
|
|
151
|
+
Cloudflare::Email::Tenancy.with(key, &block)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
EmailStorage.with_outbound_storage(nil) do
|
|
156
|
+
# Prepare/read/reconcile the system email's saved outbox operation here.
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Reserve the key against customer selection, include this database in migrations,
|
|
161
|
+
backups and restoration drills, and use the same selection for receipt replay.
|
|
162
|
+
If your shared business records use another database, retain their own durable
|
|
163
|
+
receipt/projection boundary: a tenant commit and a shared commit are not atomic.
|
|
164
|
+
Replay a committed gem operation rather than sending it again.
|
|
165
|
+
|
|
166
|
+
Never modify the tenancy adapter to treat every missing context as the system
|
|
167
|
+
database. Missing context must continue to fail closed. Your organization's job
|
|
168
|
+
fan-out should enumerate real organizations, not every database file. Applications
|
|
169
|
+
without configured multi-tenancy can keep ordinary system and organization mail
|
|
170
|
+
in their existing single database; this recipe does not enable tenancy by default.
|
|
171
|
+
|
|
126
172
|
## What is verified
|
|
127
173
|
|
|
128
174
|
The optional CI job boots a real Rails application with `activerecord-tenanted`, runs the gem's tenant migrations in two SQLite databases, and checks identical numeric IDs remain isolated. It also verifies context restoration, refusal to create unknown tenant databases, and GlobalID's missing/wrong-tenant rejection. Separate integration tests exercise mailbox ingress, sending, jobs, and delivery-event replay.
|
data/docs/architecture.md
CHANGED
|
@@ -14,7 +14,7 @@ durable infrastructure without copying the reference inbox's models and services
|
|
|
14
14
|
| Shared event intake, tenant correlation and tenant-aware jobs | Durable queue workers, scheduled recovery and schema rollout to every tenant |
|
|
15
15
|
| Structured/raw sending and ActionMailer transport | Compose UI, recipients and send authorization |
|
|
16
16
|
| `Response#accepted?`, plus individual recipient outcomes | Handle partial acceptance without resending accepted recipients |
|
|
17
|
-
| Authenticated v2 ingress and
|
|
17
|
+
| Authenticated ingress (default v2; opt-in v3 [metadata](custom-ingress.md)) and context-scoped deduplication | Map trusted recipient to an authorized mailbox; apply sender and document policy |
|
|
18
18
|
| `MessageId.normalize` and returned provider IDs | Store conversation membership and scope reply lookups |
|
|
19
19
|
| Queue decoding, validation and ACK | Queue/subscription setup and recurring execution |
|
|
20
20
|
| Optional ActiveRecord event receipts, deduplication and indexed replay | Configure queue polling and receipt retention |
|
|
@@ -75,7 +75,15 @@ does not automatically provision domains or deploy infrastructure.
|
|
|
75
75
|
|
|
76
76
|
Current limitations are deliberate and visible: no provider exactly-once API,
|
|
77
77
|
no automatic resend of uncertain or rejected operations, no multi-provider-ID
|
|
78
|
-
operation (conflicting IDs require review)
|
|
78
|
+
operation (conflicting IDs require review). Direct inbound forwarding can reject
|
|
79
|
+
mail during a Rails outage. The default [durable Worker path](../templates/worker/README.md#durable-inbound-delivery)
|
|
80
|
+
commits raw mail and stable envelope context to R2 before returning successfully.
|
|
81
|
+
Queue handoffs and a scheduled recovery pass retry pending mail; a Rails 2xx
|
|
82
|
+
allows removal from the pending store. Queue expiration does not remove R2 mail.
|
|
83
|
+
This boundary requires durable Rails persistence before 2xx, idempotent replay,
|
|
84
|
+
private R2 storage without expiration of pending objects, and operational
|
|
85
|
+
monitoring. It cannot guarantee acceptance when Cloudflare itself cannot store
|
|
86
|
+
the incoming message.
|
|
79
87
|
A prepared operation can be dispatched using its existing identity after a job
|
|
80
88
|
enqueue failure; sending/unknown operations require evidence-based reconciliation.
|
|
81
89
|
Notifications indicate method execution and may run inside an outer transaction;
|
|
@@ -92,4 +100,6 @@ silently impose a marketing subscription model on transactional mail.
|
|
|
92
100
|
The optional [mailbox module](mailboxes.md) now packages mailbox persistence and
|
|
93
101
|
address management over the existing transport/receipt APIs. It includes a
|
|
94
102
|
[tenant adapter](activerecord-tenanted.md), without adding a tenant library to
|
|
95
|
-
the plain Ruby client.
|
|
103
|
+
the plain Ruby client. The optional [management engine](management-engine.md)
|
|
104
|
+
consumes these same APIs with host-supplied authentication and authorization.
|
|
105
|
+
Full inbox products can continue using them through their own frontends.
|