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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Set up once, create mailboxes in Rails
|
|
2
|
+
|
|
3
|
+
Use the same Worker for either address shape:
|
|
4
|
+
|
|
5
|
+
| Pattern | Example | What changes when a customer joins |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| One receiving domain | `acme@in.example.com` | Create a mailbox/address in Rails |
|
|
8
|
+
| Organization subdomains | `invoices@acme.in.example.com` | Register the exact organization domain and its mailboxes in Rails, within your verified receiving namespace |
|
|
9
|
+
|
|
10
|
+
Neither pattern needs a recipient allowlist in the Worker. Rails owns accepted
|
|
11
|
+
addresses and organization membership. Database multi-tenancy remains optional.
|
|
12
|
+
The [Rails hello-world template](https://github.com/cole-robertson/cloudflare-email-rails-starter)
|
|
13
|
+
demonstrates the first pattern with a normal SQLite database and generated login.
|
|
14
|
+
|
|
15
|
+
## What is verified, and what Cloudflare documents
|
|
16
|
+
|
|
17
|
+
**Rebulk's existing deployment uses the second pattern:**
|
|
18
|
+
`<site>@<organization>.rebulk.com`, wildcard MX, a catch-all Worker rule, and Rails
|
|
19
|
+
organization/mailbox lookup. New organizations do not need a Worker enrollment
|
|
20
|
+
list or a routine per-organization Cloudflare approval. Its September 2026
|
|
21
|
+
rehearsal checked exact-domain public MX answers and archived SMTP-envelope
|
|
22
|
+
delivery evidence, alongside the global catch-all rule. The apex keeps its
|
|
23
|
+
separate mail provider. Sources (Rebulk repository access required):
|
|
24
|
+
|
|
25
|
+
- [Worker architecture and organization setup](https://github.com/Rebulk/rebulk-system/blob/1cc26e0076ba0e9157e89f5879604df8c94430bc/cloudflare/README.md).
|
|
26
|
+
- [Wildcard MX observations and limits of zone-level API evidence](https://github.com/Rebulk/rebulk-system/blob/1cc26e0076ba0e9157e89f5879604df8c94430bc/docs/cloudflare-mailbox-onboarding-rehearsal-2026-09-12.md).
|
|
27
|
+
|
|
28
|
+
This is deployment evidence, not a Cloudflare-wide service guarantee. Cloudflare's
|
|
29
|
+
[subdomain onboarding guide](https://developers.cloudflare.com/email-service/configuration/subdomains/)
|
|
30
|
+
describes adding subdomains explicitly and currently lists a 30-domain combined
|
|
31
|
+
Routing/Sending limit. It does not document arbitrary wildcard Email Routing as
|
|
32
|
+
a way to bypass that limit. Cloudflare separately documents
|
|
33
|
+
[wildcard DNS and exact-record precedence](https://developers.cloudflare.com/dns/manage-dns-records/reference/wildcard-dns-records/).
|
|
34
|
+
Working wildcard DNS alone does not establish Email Routing acceptance. If your
|
|
35
|
+
account does not accept unlisted subdomains, use documented explicit onboarding
|
|
36
|
+
within its limits, or the single-domain pattern. Do not assume that an existing
|
|
37
|
+
Rebulk setup proves a fresh account's behavior.
|
|
38
|
+
|
|
39
|
+
A fresh [public DNS observation](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/verification/2026-09-13-wildcard-dns.json)
|
|
40
|
+
also records Cloudflare MX answers for `test.rebulk.com` and two random subdomains
|
|
41
|
+
without creating DNS records or adding those labels in Cloudflare. That report
|
|
42
|
+
is public and deliberately marks `delivery_verified: false`; no email was sent
|
|
43
|
+
by this DNS-only check.
|
|
44
|
+
|
|
45
|
+
## 1. Fill in your setup worksheet
|
|
46
|
+
|
|
47
|
+
Use a dedicated receiving namespace when the apex already serves Workspace or
|
|
48
|
+
Microsoft 365. Example values:
|
|
49
|
+
|
|
50
|
+
| Setting | Your example value |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| Cloudflare zone | `example.com` |
|
|
53
|
+
| Receiving base | `in.example.com` |
|
|
54
|
+
| Organization domain | `acme.in.example.com` |
|
|
55
|
+
| Worker | `cloudflare-email-ingress` |
|
|
56
|
+
| Rails ingress URL | `https://app.example.com/rails/action_mailbox/cloudflare/inbound_emails` |
|
|
57
|
+
| Shared ingress secret | Generate with `openssl rand -hex 32`; save privately in Rails and the Worker |
|
|
58
|
+
|
|
59
|
+
Keep staging and production Workers, buckets, queues, secrets, and receiving
|
|
60
|
+
namespaces separate. The standalone deploy-button template takes one environment
|
|
61
|
+
per copy; the regular CLI template uses `--env production` or another explicit env.
|
|
62
|
+
|
|
63
|
+
## 2. Deploy the shared infrastructure
|
|
64
|
+
|
|
65
|
+
[](https://deploy.workers.cloudflare.com/?url=https://github.com/cole-robertson/cloudflare-email/tree/main/templates/deploy-to-cloudflare)
|
|
66
|
+
|
|
67
|
+
Deploy Rails with gem 0.3+ first. The button copies the standalone Worker and
|
|
68
|
+
prompts for `RAILS_INGRESS_URL` and `INGRESS_SECRET`. Its configuration includes
|
|
69
|
+
private R2 storage, a Queue producer/consumer, once-per-minute recovery, and logs.
|
|
70
|
+
Cloudflare documents [automatic R2/Queue provisioning and secret prompts](https://developers.cloudflare.com/workers/platform/deploy-buttons/).
|
|
71
|
+
The button does not create email DNS, claim a domain, or register Rails mailboxes.
|
|
72
|
+
|
|
73
|
+
## 3. Connect DNS and the Worker once
|
|
74
|
+
|
|
75
|
+
For **one domain**, follow Cloudflare's documented
|
|
76
|
+
[Email Routing onboarding](https://developers.cloudflare.com/email-service/get-started/route-emails/)
|
|
77
|
+
and configure its [catch-all action](https://developers.cloudflare.com/email-service/configuration/email-routing-addresses/#catch-all-rule)
|
|
78
|
+
as **Send to a Worker**, selecting your deployed Worker. This avoids one provider
|
|
79
|
+
rule per local part. Review the actual zone/domain scope and any explicit rules
|
|
80
|
+
that take precedence before saving.
|
|
81
|
+
|
|
82
|
+
For a **Rebulk-style dynamic namespace**, first establish receiving on your account
|
|
83
|
+
and retain the exact MX targets/priorities assigned by Cloudflare. Then, when
|
|
84
|
+
reproducing and verifying wildcard receiving, the DNS template is:
|
|
85
|
+
|
|
86
|
+
| DNS type | Name in zone `example.com` | Value |
|
|
87
|
+
| --- | --- | --- |
|
|
88
|
+
| MX | `*.in` | Each Cloudflare-assigned receiving MX target, with its assigned priority |
|
|
89
|
+
|
|
90
|
+
Create one record per assigned target. Do not invent MX targets, point MX at a
|
|
91
|
+
Worker URL, or replace the apex's existing MX records. Complete the service's
|
|
92
|
+
other required DNS records through its onboarding instructions; do not blindly
|
|
93
|
+
duplicate SPF records. Inspect the account's catch-all **Send to a Worker** rule
|
|
94
|
+
and verify that it actually handles your intended namespace. A Cloudflare Worker
|
|
95
|
+
HTTP route such as `*.example.com/*` is unrelated to email routing.
|
|
96
|
+
|
|
97
|
+
An explicit DNS name can stop wildcard inheritance **even if its record is TXT
|
|
98
|
+
or A rather than MX**. For example, an existing `acme.in.example.com` record may
|
|
99
|
+
require its own receiving MX records. Test exact customer names as well as fresh
|
|
100
|
+
ones. A wildcard also does not supply MX for the receiving base itself.
|
|
101
|
+
|
|
102
|
+
## 4. Check without modifying infrastructure
|
|
103
|
+
|
|
104
|
+
From either current Worker template, run:
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
npm run check:subdomains -- --base in.example.com --labels acme,globex
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
This requires only Node and DNS access, not a Cloudflare API key. It resolves MX
|
|
111
|
+
for your chosen labels plus two fresh random labels. DNS lookups have bounded
|
|
112
|
+
timeouts. JSON output distinguishes observed Cloudflare MX, other/missing MX, and
|
|
113
|
+
lookup uncertainty. Exit zero means only that all queried names resolved to
|
|
114
|
+
Cloudflare MX; `delivery_verified` remains false. Compare returned targets with
|
|
115
|
+
your account's assigned targets. Existing explicit records can explain differences.
|
|
116
|
+
The command ships in the repository templates; gem 0.3.0's already-published
|
|
117
|
+
template predates it, so refresh your template copy to use it.
|
|
118
|
+
|
|
119
|
+
For provider rule inspection, the gem also offers:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
bin/rails cloudflare:email:check_route \
|
|
123
|
+
ADDRESS=invoices@acme.in.example.com \
|
|
124
|
+
WORKER_NAME=cloudflare-email-ingress \
|
|
125
|
+
ACCOUNT_ID=your-cloudflare-account-id
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
See [diagnostic credentials and limits](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/routing-diagnostics.md).
|
|
129
|
+
That diagnostic inspects exact-domain configured records; it does not resolve
|
|
130
|
+
wildcard DNS inheritance. A working inherited-MX setup can therefore report
|
|
131
|
+
missing exact records. Keep public DNS, provider rule inspection, and actual
|
|
132
|
+
delivery evidence separate; do not force every organization through Cloudflare
|
|
133
|
+
onboarding merely to make that diagnostic green.
|
|
134
|
+
|
|
135
|
+
## 5. Prove the dynamic path before adopting it
|
|
136
|
+
|
|
137
|
+
Use two new organization labels that have not been added individually in the
|
|
138
|
+
Cloudflare dashboard:
|
|
139
|
+
|
|
140
|
+
1. Check their public MX answers with the command above.
|
|
141
|
+
2. Through trusted provisioning code, register both **exact** domains in Rails,
|
|
142
|
+
each against the correct stable organization key. Create a test mailbox for
|
|
143
|
+
each. Record your independently checked configuration evidence and activate
|
|
144
|
+
those test addresses so the gem can receive the verification messages.
|
|
145
|
+
3. Send real email to both addresses from an external mailbox. Confirm the
|
|
146
|
+
Worker receives each, Rails retains the original message, and each appears
|
|
147
|
+
only in the correct organization's inbox. Record the message IDs and result.
|
|
148
|
+
4. Repeat with a newly generated label without changing Cloudflare. If delivery
|
|
149
|
+
fails before the Worker, resolve the Cloudflare acceptance/onboarding issue;
|
|
150
|
+
changing Rails cannot fix a provider SMTP rejection.
|
|
151
|
+
5. Rehearse Rails being unavailable, then verify retained R2 mail drains after
|
|
152
|
+
recovery without duplicate app records. Follow the
|
|
153
|
+
[durable recovery guide](https://github.com/cole-robertson/cloudflare-email/blob/main/templates/worker/docs/durable-inbound.md).
|
|
154
|
+
|
|
155
|
+
These are deliberate test addresses; no real-email test is sent by the DNS checker.
|
|
156
|
+
Successful tests establish evidence for your receiving namespace, not a guarantee
|
|
157
|
+
that every future DNS change or provider policy will preserve it.
|
|
158
|
+
|
|
159
|
+
## 6. Create organizations in Rails
|
|
160
|
+
|
|
161
|
+
Once your namespace policy is verified, use the same gem APIs on organization
|
|
162
|
+
creation. For example, from trusted app code after authorizing the organization:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
registry = Cloudflare::Email::Mailboxes
|
|
166
|
+
organization_key = "organization-123" # Trusted stable app identity, not request input.
|
|
167
|
+
domain = registry.register_domain(
|
|
168
|
+
domain: "acme.in.example.com",
|
|
169
|
+
tenant_key: organization_key,
|
|
170
|
+
account_id: Cloudflare::Email::Credentials.account_id
|
|
171
|
+
)
|
|
172
|
+
registry.activate_domain!(domain.id, evidence: verified_namespace_evidence)
|
|
173
|
+
registry.for_tenant(organization_key) do |inboxes|
|
|
174
|
+
mailbox = inboxes.create(name: "Invoices", address: "invoices@acme.in.example.com",
|
|
175
|
+
owner_ref: "Organization:123")
|
|
176
|
+
inboxes.activate_address!(inboxes.addresses(mailbox.id).first.id,
|
|
177
|
+
evidence: verified_namespace_evidence)
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`verified_namespace_evidence` is your stored operator evidence from the setup and
|
|
182
|
+
delivery checks, not a hard-coded claim of success. Make provisioning idempotent
|
|
183
|
+
in your app, validate/reserve slugs, and resolve organization identity from trusted
|
|
184
|
+
records. The gem stores exact domain registrations; it does not accept a `*`
|
|
185
|
+
domain registration or silently authorize an arbitrary recipient from a web form.
|
|
186
|
+
The management engine must obtain allowed domains from your host adapter. The
|
|
187
|
+
hello-world starter intentionally stays single-domain; this recipe is the next
|
|
188
|
+
step for an organization-aware app.
|
|
189
|
+
|
|
190
|
+
A **Cloudflare routing catch-all** gets mail to the Worker. The gem's optional
|
|
191
|
+
**mailbox catch-all** decides whether unknown local parts enter a mailbox. They
|
|
192
|
+
are separate. Without the latter, unknown/suspended recipients return non-2xx and
|
|
193
|
+
remain in durable pending storage until resolved. Monitor that backlog.
|
|
194
|
+
|
|
195
|
+
Receiving on dynamic domains does not authorize sending from them. Cloudflare
|
|
196
|
+
[Email Sending onboarding](https://developers.cloudflare.com/email-service/configuration/subdomains/#add-a-subdomain-to-email-sending)
|
|
197
|
+
is separate; keep a verified shared sending domain unless you have explicitly
|
|
198
|
+
onboarded the customer sending domain.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Durable inbound delivery
|
|
2
|
+
|
|
3
|
+
When Rails is down, keep the email at Cloudflare and deliver it when Rails returns.
|
|
4
|
+
The bundled Worker uses R2 storage, Queue delivery and scheduled recovery by
|
|
5
|
+
default. No enable flag, Rails model, database, or tenancy change is needed.
|
|
6
|
+
Missing infrastructure fails visibly; it never silently downgrades to direct delivery.
|
|
7
|
+
|
|
8
|
+
Upgrade the Rails gem before deploying this Worker. New messages without a parseable
|
|
9
|
+
Message-ID use a deterministic fallback for deduplication across Rails hosts.
|
|
10
|
+
Older records created before this change use ActionMailbox's hostname-dependent
|
|
11
|
+
fallback; replaying those historical messages after upgrading can create a new
|
|
12
|
+
record. Reconcile any existing archive backlog before replaying it automatically.
|
|
13
|
+
|
|
14
|
+
## Set it up
|
|
15
|
+
|
|
16
|
+
Create a private R2 bucket and a Queue for each environment:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npx wrangler r2 bucket create cloudflare-email-inbound-production
|
|
20
|
+
npx wrangler queues create cloudflare-email-inbound-production
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The bundled `wrangler.toml` already configures these resources for each environment.
|
|
24
|
+
Customize their names if needed. Configure **all three** pieces: the `INBOUND_EMAIL_STORE` R2
|
|
25
|
+
binding, `INBOUND_EMAIL_QUEUE` producer and consumer, and the once-per-minute
|
|
26
|
+
scheduled trigger. Keep `RAILS_INGRESS_URL` and `INGRESS_SECRET` configured as
|
|
27
|
+
before. Deploy with `npm run deploy -- --env production`.
|
|
28
|
+
|
|
29
|
+
Use separate buckets, queues and secrets for staging and production. Do not add
|
|
30
|
+
an R2 lifecycle rule that expires `cloudflare-email/pending/` objects. Pending
|
|
31
|
+
mail has no automatic expiration and can incur storage costs during a long
|
|
32
|
+
outage. Do not publish the bucket or expose an unauthenticated recovery endpoint.
|
|
33
|
+
Review storage access and retention as you would the Rails email database.
|
|
34
|
+
Provision and deploy the infrastructure with Wrangler first; retain that configuration
|
|
35
|
+
in your infrastructure repository. Subsequent `cloudflare:email:deploy_worker` Rails
|
|
36
|
+
task uploads preserve R2/Queue bindings and check the bucket binding, queue producer
|
|
37
|
+
binding, and minute schedule before changing code or secrets. This check does not
|
|
38
|
+
validate the bucket lifecycle or queue consumer: verify those in your infrastructure
|
|
39
|
+
configuration and live drill. The Ruby task does not provision resources.
|
|
40
|
+
|
|
41
|
+
## Direct fallback and upgrades
|
|
42
|
+
|
|
43
|
+
Set `INBOUND_DELIVERY_MODE = "direct"` in the environment's Wrangler vars only when
|
|
44
|
+
you deliberately want a single HTTP handoff. With the Ruby task, pass
|
|
45
|
+
`INBOUND_DELIVERY_MODE=direct bin/rails cloudflare:email:deploy_worker`.
|
|
46
|
+
Direct mode permanently rejects on HTTP/network failure and cannot buffer an outage.
|
|
47
|
+
Keep the existing R2/Queue bindings and cron during fallback: queue and scheduled
|
|
48
|
+
recovery continue draining previously retained mail. To return to durable delivery,
|
|
49
|
+
remove the Wrangler override or pass `INBOUND_DELIVERY_MODE=durable` to the Ruby task.
|
|
50
|
+
Unknown values fail rather than choosing a transport implicitly.
|
|
51
|
+
|
|
52
|
+
The old `DURABLE_INBOUND_ENABLED` rollout flag has been removed. Existing direct
|
|
53
|
+
installations must provision the resources before upgrading the Worker, or explicitly
|
|
54
|
+
select `INBOUND_DELIVERY_MODE=direct` before deploying. Upgrading the Ruby gem alone
|
|
55
|
+
does not redeploy a Worker or change live routing.
|
|
56
|
+
|
|
57
|
+
## What happens to a message
|
|
58
|
+
|
|
59
|
+
1. The Worker validates the envelope and actual MIME size. Invalid or oversized
|
|
60
|
+
mail is permanently rejected, as in direct mode.
|
|
61
|
+
2. It writes one R2 object containing the exact raw bytes, SMTP envelope, original
|
|
62
|
+
receive time, and optional provider metadata. It waits for the write before
|
|
63
|
+
returning from the Email Worker handler.
|
|
64
|
+
3. It queues a small `{ version: 1, key }` pointer. If enqueueing fails, the stored
|
|
65
|
+
object remains discoverable by the scheduled sweep.
|
|
66
|
+
4. The consumer signs the original bytes and metadata with a fresh timestamp and
|
|
67
|
+
POSTs to Rails. Only HTTP 2xx permits deleting the pending object; the queue
|
|
68
|
+
message is acknowledged only after that deletion completes.
|
|
69
|
+
5. Timeout, connection failure, redirect, 401, 413 and all other non-2xx responses
|
|
70
|
+
retain the object and retry. Fix credentials or size configuration for errors
|
|
71
|
+
that cannot resolve themselves. There is no automatic discard on client errors.
|
|
72
|
+
|
|
73
|
+
If Rails saved a message but its response was lost, replay sends the same message
|
|
74
|
+
identity. Rails ingress deduplicates using the original bytes and envelope (and
|
|
75
|
+
provider metadata for v3); changing the signing timestamp does not change that
|
|
76
|
+
identity. A failed R2 deletion may also cause replay. A queue pointer to an
|
|
77
|
+
already deleted object is a successful no-op and never resends mail. Do not reuse
|
|
78
|
+
pending keys, alter saved context, or replace the ingress with an endpoint that
|
|
79
|
+
acknowledges before durable ingestion. Retain Rails deduplication records for at
|
|
80
|
+
least your maximum recovery horizon; manual restore/replay after Rails incineration
|
|
81
|
+
can produce another message. Application processing must tolerate duplicates too.
|
|
82
|
+
|
|
83
|
+
The scheduled handler scans five pending objects per minute and attempts their
|
|
84
|
+
handoff directly. It persists an R2 pagination cursor and advances past failed
|
|
85
|
+
objects, wrapping back to the beginning at the end. Poison messages therefore do
|
|
86
|
+
not permanently block later pages. This sweep handles both enqueue failure and
|
|
87
|
+
exhausted Queue retries without a separate dead-letter queue. During recovery,
|
|
88
|
+
Queue consumers provide the primary throughput; scheduled-only recovery takes
|
|
89
|
+
at least `ceil(pending_count / 5)` minutes per full pass. Overlapping cron runs
|
|
90
|
+
may repeat a page; delivery remains idempotent. Scale and exercise this recovery
|
|
91
|
+
rate against your expected backlog before depending on it.
|
|
92
|
+
|
|
93
|
+
The template buffers MIME to sign it, and the durable path also builds a framed
|
|
94
|
+
storage payload. Test messages near your configured size limit with realistic
|
|
95
|
+
concurrent traffic against the Worker's memory limit before production rollout.
|
|
96
|
+
Lower `MAX_EMAIL_BYTES` in both Worker and Rails if your workload requires it;
|
|
97
|
+
lowering it below already retained message sizes leaves those messages pending
|
|
98
|
+
until the limit is restored or they are recovered through an authorized process.
|
|
99
|
+
|
|
100
|
+
## Operate and verify
|
|
101
|
+
|
|
102
|
+
Enable Worker logs and alerts for the structured
|
|
103
|
+
`component: "cloudflare_email_inbound"` events. They include a random storage key
|
|
104
|
+
and fixed reason, never raw messages, addresses, secrets or HTTP response bodies.
|
|
105
|
+
Alert on `storage_failed`, `enqueue_failed_retained`, repeated `queue_retry_retained`
|
|
106
|
+
or `sweep_retry_retained`, Worker exceptions, and growth/age of objects under the
|
|
107
|
+
pending prefix. `receivedAt` is inside the private stored frame; R2 object upload
|
|
108
|
+
time is also available to infrastructure monitoring. Queue backlog alone is not
|
|
109
|
+
sufficient: exhausted pointers may disappear while retained mail remains in R2.
|
|
110
|
+
This template emits logs; configure your monitoring service to page the operator.
|
|
111
|
+
|
|
112
|
+
In staging, stop Rails, send mail, confirm pending R2 objects, restore Rails, then
|
|
113
|
+
verify one inbound message and removal of its pending object. Also verify recovery
|
|
114
|
+
with the queue consumer disabled or retries exhausted, a lost HTTP response after
|
|
115
|
+
Rails saved the message, invalid ingress credentials, and a failed R2 write.
|
|
116
|
+
The repository includes unit coverage and a local Worker/Rails integration harness.
|
|
117
|
+
Before changing ingress URLs, remember retained messages use the current configured
|
|
118
|
+
URL and secret. Drain existing retained mail before repointing a bucket to another
|
|
119
|
+
application or disabling the scheduled recovery trigger.
|
|
120
|
+
|
|
121
|
+
For a custom Worker, import `retainEmail(message, env, { metadata })`,
|
|
122
|
+
`consumeRetainedEmails(batch, env)` and `sweepRetainedEmails(env)` from `src/index.js`.
|
|
123
|
+
Wire the latter two to your queue and scheduled handlers as in the default export.
|
|
124
|
+
For a secondary raw archive, pass `archive: async ({ raw, from, to, key }) => ...`
|
|
125
|
+
to `retainEmail`. It runs after the primary R2 commit with the original MIME bytes;
|
|
126
|
+
`key` is the pending object identity. Its errors or default 10-second timeout are
|
|
127
|
+
logged as `archive_failed_retained` and do not prevent queue delivery. A timed-out
|
|
128
|
+
callback is not canceled. Set `archiveTimeoutMs` to an integer from 1 to 120,000
|
|
129
|
+
milliseconds to fit your host's runtime budget. Treat bytes as read-only and never parse the private
|
|
130
|
+
pending frame to build an archive. The primary R2 write is always mandatory.
|
|
131
|
+
Trusted provider metadata is captured once at receipt, never recomputed on replay.
|
|
132
|
+
The existing `relayEmail` archive callback is best effort and does not enable this
|
|
133
|
+
durable protocol automatically.
|
|
134
|
+
|
|
135
|
+
## Guarantee boundary
|
|
136
|
+
|
|
137
|
+
After a successful R2 write, Rails outages and finite Queue retention do not
|
|
138
|
+
discard the stored payload. This is at-least-once handoff, with Rails deduplication;
|
|
139
|
+
it is not an absolute guarantee against all loss. R2 write failures, upstream
|
|
140
|
+
SMTP acceptance behavior, account suspension, deleted infrastructure, misconfigured
|
|
141
|
+
lifecycle rules and manual intervention remain outside that promise. A failed
|
|
142
|
+
initial durable write throws and emits an error; it must not be mistaken for
|
|
143
|
+
successful storage. Monitor that failure separately.
|
|
144
|
+
|
|
145
|
+
Cloudflare documents [`setReject`](https://developers.cloudflare.com/email-service/api/route-emails/email-handler/)
|
|
146
|
+
as a permanent SMTP rejection. Throwing an Email Worker error is not treated here
|
|
147
|
+
as a documented durable SMTP retry contract. Cloudflare Queues has a
|
|
148
|
+
[128 KB message limit and finite retention/retries](https://developers.cloudflare.com/queues/platform/limits/),
|
|
149
|
+
which is why the queue stores only pointers and R2 retains the message independently.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloudflare-email-ingress",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "cloudflare-email-ingress",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.3.0",
|
|
10
10
|
"devDependencies": {
|
|
11
11
|
"vitest": "^5.0.0",
|
|
12
12
|
"wrangler": "^4.131.0"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloudflare-email-ingress",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": { "node": "^22.12.0 || ^24.0.0 || >=26.0.0" },
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"deploy": "node scripts/wrangler.mjs deploy",
|
|
10
10
|
"dev": "node scripts/wrangler.mjs dev",
|
|
11
11
|
"check": "wrangler deploy --env development --dry-run",
|
|
12
|
+
"check:subdomains": "node scripts/check-subdomains.mjs",
|
|
12
13
|
"test": "vitest run"
|
|
13
14
|
},
|
|
14
15
|
"devDependencies": {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Read-only public DNS check. No Cloudflare credentials or configuration writes.
|
|
2
|
+
import { Resolver } from "node:dns/promises";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const labelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
8
|
+
|
|
9
|
+
export async function checkSubdomains({ base, labels = [] }, lookup, randomLabel = () => `cf-probe-${randomBytes(8).toString("hex")}`) {
|
|
10
|
+
base = String(base || "").trim().toLowerCase();
|
|
11
|
+
if (base.length > 220 || base.split(".").length < 2 || !base.split(".").every(label => labelPattern.test(label))) {
|
|
12
|
+
throw new Error("Provide a DNS base such as in.example.com, without @, a scheme, or *.");
|
|
13
|
+
}
|
|
14
|
+
if (labels.length > 10 || !labels.every(label => labelPattern.test(label) && `${label}.${base}`.length <= 253)) {
|
|
15
|
+
throw new Error("Provide at most 10 comma-separated single-label subdomains, such as acme,globex.");
|
|
16
|
+
}
|
|
17
|
+
const probes = [randomLabel(), randomLabel()];
|
|
18
|
+
const names = [...new Set([...labels, ...probes])];
|
|
19
|
+
const checks = await Promise.all(names.map(async label => {
|
|
20
|
+
const domain = `${label}.${base}`;
|
|
21
|
+
try {
|
|
22
|
+
const mx = (await lookup(domain)).map(record => ({ priority: record.priority, exchange: record.exchange.toLowerCase().replace(/\.$/, "") }));
|
|
23
|
+
const cloudflare = mx.length > 0 && mx.every(record => record.exchange.endsWith(".mx.cloudflare.net"));
|
|
24
|
+
return { domain, fresh_probe: probes.includes(label), status: cloudflare ? "cloudflare_mx_observed" : "other_or_missing_mx", mx };
|
|
25
|
+
} catch (error) {
|
|
26
|
+
return { domain, fresh_probe: probes.includes(label), status: "unknown", reason: error.code || "DNS_LOOKUP_FAILED" };
|
|
27
|
+
}
|
|
28
|
+
}));
|
|
29
|
+
return {
|
|
30
|
+
checked_at: new Date().toISOString(),
|
|
31
|
+
base, dns_observation: checks.every(check => check.status === "cloudflare_mx_observed") ? "cloudflare_mx_observed_for_all_names" : "needs_attention",
|
|
32
|
+
delivery_verified: false, checks,
|
|
33
|
+
next_step: "Inspect the catch-all Worker rule, register two exact domains/mailboxes in Rails, and send real test emails. MX answers do not verify account ownership, Email Routing acceptance, Worker delivery, or Rails persistence."
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
38
|
+
try {
|
|
39
|
+
const { values } = parseArgs({ options: { base: { type: "string" }, labels: { type: "string" }, help: { type: "boolean" } } });
|
|
40
|
+
if (values.help) {
|
|
41
|
+
console.log("npm run check:subdomains -- --base in.example.com [--labels acme,globex]\nRead-only DNS observations, including two fresh labels. This does not verify email delivery.");
|
|
42
|
+
} else {
|
|
43
|
+
const resolver = new Resolver({ timeout: 2000, tries: 1 });
|
|
44
|
+
const labels = values.labels ? values.labels.split(",").map(label => label.trim().toLowerCase()) : [];
|
|
45
|
+
const report = await checkSubdomains({ base: values.base, labels }, domain => resolver.resolveMx(domain));
|
|
46
|
+
console.log(JSON.stringify(report, null, 2));
|
|
47
|
+
if (report.dns_observation === "needs_attention") process.exitCode = 1;
|
|
48
|
+
}
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error(error.message);
|
|
51
|
+
console.error("Usage: npm run check:subdomains -- --base in.example.com [--labels acme,globex]");
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
}
|
|
54
|
+
}
|