cloudflare-email 0.3.0 → 0.4.1
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 +63 -1
- data/README.md +63 -369
- data/SECURITY.md +1 -9
- data/app/controllers/cloudflare/email/ingress_controller.rb +1 -2
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +2 -166
- data/app/controllers/cloudflare/email/management/styles_controller.rb +2 -6
- data/docs/activerecord-tenanted.md +0 -11
- data/docs/custom-ingress.md +6 -16
- data/docs/delivery-events.md +2 -5
- data/docs/features.md +49 -123
- data/docs/getting-started.md +91 -172
- data/docs/mailboxes.md +27 -56
- data/docs/management-engine.md +1 -3
- data/docs/outbox.md +5 -6
- data/docs/reference.md +190 -0
- data/docs/routing-diagnostics.md +7 -2
- data/docs/thread-correlation.md +2 -4
- data/docs/troubleshooting.md +4 -4
- data/lib/cloudflare/email/active_record/base.rb +3 -53
- data/lib/cloudflare/email/client.rb +23 -5
- data/lib/cloudflare/email/engine.rb +1 -13
- data/lib/cloudflare/email/envelope.rb +2 -12
- data/lib/cloudflare/email/error.rb +5 -12
- data/lib/cloudflare/email/ingress.rb +19 -8
- 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 -147
- data/lib/cloudflare/email/mailboxes/service.rb +7 -258
- data/lib/cloudflare/email/mailboxes.rb +1 -0
- data/lib/cloudflare/email/management/adapter.rb +3 -25
- data/lib/cloudflare/email/management/configuration.rb +4 -11
- data/lib/cloudflare/email/management/engine.rb +2 -0
- data/lib/cloudflare/email/tenancy.rb +2 -73
- data/lib/cloudflare/email/tenant_job_context.rb +2 -85
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare-email.rb +6 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +1 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
- data/templates/deploy-to-cloudflare/README.md +25 -7
- data/templates/deploy-to-cloudflare/docs/domain-setup.md +118 -0
- data/templates/deploy-to-cloudflare/package.json +1 -0
- data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
- data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
- data/templates/worker/README.md +11 -7
- data/templates/worker/docs/domain-setup.md +118 -0
- data/templates/worker/docs/durable-inbound.md +4 -16
- data/templates/worker/package.json +1 -0
- data/templates/worker/scripts/check-subdomains.mjs +54 -0
- data/templates/worker/test/subdomains.test.ts +51 -0
- metadata +22 -21
- data/app/views/cloudflare/email/management/mailboxes/index.html.erb +0 -65
- data/app/views/cloudflare/email/management/mailboxes/message.html.erb +0 -34
- data/app/views/cloudflare/email/management/mailboxes/show.html.erb +0 -86
- data/app/views/layouts/cloudflare/email/management.html.erb +0 -29
- data/docs/architecture.md +0 -105
- data/docs/tenant-mailboxes-plan.md +0 -149
- data/docs/upgrading-0.2.md +0 -101
- data/docs/upgrading-0.3.md +0 -55
- data/docs/verification/2026-09-10-followup.md +0 -49
- data/docs/verification/2026-09-10-install-upgrade.md +0 -86
- data/docs/verification/2026-09-10-live.md +0 -102
- data/docs/verification/2026-09-10.md +0 -175
- data/docs/verification/2026-09-11-custom-ingress.md +0 -49
- data/docs/verification/2026-09-11-extraction.md +0 -32
- data/docs/verification/2026-09-11-outbound-ledger.md +0 -60
- data/docs/verification/2026-09-11-security.md +0 -76
- data/docs/verification/2026-09-11-tenant-mailboxes.md +0 -75
- data/docs/verification/2026-09-12-destination-context.md +0 -46
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +0 -71
- data/lib/cloudflare/email/management/management.css +0 -68
|
@@ -1,90 +1,7 @@
|
|
|
1
1
|
require "cloudflare/email/tenancy"
|
|
2
|
-
|
|
2
|
+
require "mailbox_kit/tenant_job_context"
|
|
3
3
|
module Cloudflare
|
|
4
4
|
module Email
|
|
5
|
-
|
|
6
|
-
# arguments or work reference tenant records. Queue payloads must be trusted.
|
|
7
|
-
module TenantJobContext
|
|
8
|
-
PAYLOAD_KEY = "cloudflare_email_tenant_key".freeze
|
|
9
|
-
|
|
10
|
-
def serialize
|
|
11
|
-
key = cloudflare_email_job_tenant_key
|
|
12
|
-
return super unless key
|
|
13
|
-
Tenancy.with(key) do
|
|
14
|
-
payload = super
|
|
15
|
-
verify_cloudflare_email_host_tenant!(key, payload["tenant"])
|
|
16
|
-
payload.merge(PAYLOAD_KEY => key)
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def deserialize(job_data)
|
|
21
|
-
# Bind before super: host job integrations may deserialize model arguments
|
|
22
|
-
# eagerly. Never infer a missing tenant from the worker's ambient context.
|
|
23
|
-
if !job_data.key?(PAYLOAD_KEY) && cloudflare_email_optional_job_context?
|
|
24
|
-
@cloudflare_email_job_tenant_key = nil
|
|
25
|
-
return super
|
|
26
|
-
end
|
|
27
|
-
@cloudflare_email_job_tenant_key = Tenancy.normalize_key(job_data[PAYLOAD_KEY])
|
|
28
|
-
verify_cloudflare_email_host_tenant!(@cloudflare_email_job_tenant_key, job_data["tenant"])
|
|
29
|
-
Tenancy.with(@cloudflare_email_job_tenant_key) { super }
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def perform_now
|
|
33
|
-
# Active Job resolves GlobalIDs before around_perform, so that callback
|
|
34
|
-
# cannot safely implement database tenant selection.
|
|
35
|
-
key = cloudflare_email_job_tenant_key
|
|
36
|
-
return super unless key
|
|
37
|
-
if defined?(::ActiveRecord::Tenanted::Job) && respond_to?(:tenant)
|
|
38
|
-
verify_cloudflare_email_host_tenant!(key, tenant)
|
|
39
|
-
end
|
|
40
|
-
Tenancy.with(key) { super }
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def self.install_framework_jobs!
|
|
44
|
-
if defined?(::ActionMailbox)
|
|
45
|
-
%i[RoutingJob IncinerationJob].each do |name|
|
|
46
|
-
next unless ::ActionMailbox.const_defined?(name)
|
|
47
|
-
klass = ::ActionMailbox.const_get(name)
|
|
48
|
-
klass.define_singleton_method(:cloudflare_email_optional_job_context?) { !Tenancy.enabled? }
|
|
49
|
-
klass.prepend(self) unless klass.ancestors.include?(self)
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
if defined?(::ActiveStorage)
|
|
53
|
-
%i[BaseJob AnalyzeJob PurgeJob MirrorJob TransformJob PreviewImageJob].each do |name|
|
|
54
|
-
next unless ::ActiveStorage.const_defined?(name)
|
|
55
|
-
klass = ::ActiveStorage.const_get(name)
|
|
56
|
-
klass.define_singleton_method(:cloudflare_email_optional_job_context?) { !Tenancy.enabled? }
|
|
57
|
-
klass.prepend(self) unless klass.ancestors.include?(self)
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
private
|
|
63
|
-
|
|
64
|
-
def cloudflare_email_job_tenant_key
|
|
65
|
-
# Once serialized/deserialized, retries retain their original tenant even
|
|
66
|
-
# if re-enqueued from a different tenant or outside a tenant context.
|
|
67
|
-
return @cloudflare_email_job_tenant_key if instance_variable_defined?(:@cloudflare_email_job_tenant_key)
|
|
68
|
-
return nil if cloudflare_email_optional_job_context? && !Tenancy.current_key
|
|
69
|
-
# Native uploads/mailbox work may originate in the host's own with_tenant
|
|
70
|
-
# block. Framework jobs can capture that trusted context on first use.
|
|
71
|
-
# deserialize never takes this path for missing persisted metadata.
|
|
72
|
-
if !Tenancy.current_key && self.class.respond_to?(:cloudflare_email_optional_job_context?)
|
|
73
|
-
key = Tenancy.host_current_key
|
|
74
|
-
return @cloudflare_email_job_tenant_key = key if key
|
|
75
|
-
end
|
|
76
|
-
@cloudflare_email_job_tenant_key = Tenancy.require_context!
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def cloudflare_email_optional_job_context?
|
|
80
|
-
self.class.respond_to?(:cloudflare_email_optional_job_context?) && self.class.cloudflare_email_optional_job_context?
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def verify_cloudflare_email_host_tenant!(key, host_key)
|
|
84
|
-
if host_key && host_key != key
|
|
85
|
-
raise ConfigurationError, "job host tenant conflicts with Cloudflare Email tenant context"
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
5
|
+
TenantJobContext = MailboxKit::TenantJobContext
|
|
89
6
|
end
|
|
90
7
|
end
|
data/lib/cloudflare-email.rb
CHANGED
data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb
CHANGED
|
@@ -40,6 +40,7 @@ class CreateCloudflareEmailMailboxes < ActiveRecord::Migration[7.1]
|
|
|
40
40
|
t.timestamps
|
|
41
41
|
end
|
|
42
42
|
add_index :cloudflare_email_mailbox_messages, [:mailbox_id, :inbound_email_id], unique: true, name: "idx_cf_email_mailbox_inbound"
|
|
43
|
+
add_index :cloudflare_email_mailbox_messages, :inbound_email_id, name: "idx_cf_email_message_inbound"
|
|
43
44
|
add_index :cloudflare_email_mailbox_messages, [:tenant_key, :mailbox_id, :archived_at, :id], name: "idx_cf_email_mailbox_inbox"
|
|
44
45
|
|
|
45
46
|
create_table :cloudflare_email_mailbox_outbound_messages do |t|
|
|
@@ -3,7 +3,7 @@ class CreateCloudflareEmailReceivingDomains < ActiveRecord::Migration[7.1]
|
|
|
3
3
|
create_table :cloudflare_email_receiving_domains do |t|
|
|
4
4
|
t.string :domain, null: false
|
|
5
5
|
t.string :tenant_key, null: false
|
|
6
|
-
t.string :account_id
|
|
6
|
+
t.string :account_id # Required by the sending adapter only.
|
|
7
7
|
t.string :state, null: false, default: "pending"
|
|
8
8
|
t.boolean :sending_enabled, null: false, default: false
|
|
9
9
|
t.text :provisioning_evidence
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Receive email in Rails with the [cloudflare-email gem](https://github.com/cole-robertson/cloudflare-email).
|
|
4
4
|
This template stores inbound mail in private R2, delivers it through a Queue,
|
|
5
5
|
and retries from storage when Rails is unavailable. It includes scheduled
|
|
6
|
-
recovery and Worker logs.
|
|
6
|
+
recovery and Worker logs.
|
|
7
7
|
|
|
8
8
|
[](https://deploy.workers.cloudflare.com/?url=https://github.com/cole-robertson/cloudflare-email/tree/main/templates/deploy-to-cloudflare)
|
|
9
9
|
|
|
10
10
|
## 1. Prepare Rails
|
|
11
11
|
|
|
12
|
-
Install `gem "cloudflare-email", "~> 0.
|
|
12
|
+
Install `gem "cloudflare-email", "~> 0.4.0"` and follow the
|
|
13
13
|
[Rails setup guide](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/getting-started.md).
|
|
14
14
|
Deploy Rails with Action Mailbox storage and routing configured before connecting
|
|
15
15
|
real email. Generate a secret with `openssl rand -hex 32` and configure it as
|
|
@@ -38,20 +38,39 @@ cron are present. Keep the bucket private with no lifecycle rule expiring
|
|
|
38
38
|
|
|
39
39
|
## 3. Connect an email address
|
|
40
40
|
|
|
41
|
+
**Choose your address shape:** `acme@in.example.com` on one receiving domain,
|
|
42
|
+
or `invoices@acme.in.example.com` with dynamic organization subdomains.
|
|
43
|
+
Use the [shared domain setup guide](docs/domain-setup.md) for DNS,
|
|
44
|
+
Worker routing, and verification commands.
|
|
45
|
+
The same Worker serves both; there is no per-organization Worker allowlist.
|
|
46
|
+
|
|
41
47
|
In Cloudflare, enable Email Routing for the intended receiving domain/subdomain
|
|
42
48
|
and complete its required DNS setup. Add an Email Routing rule for your receiving
|
|
43
|
-
address with **Send to a Worker**, selecting the Worker you just deployed.
|
|
49
|
+
address with **Send to a Worker**, selecting the Worker you just deployed. For
|
|
50
|
+
many local parts, configure the intended domain's catch-all to this Worker once.
|
|
44
51
|
The button does not change your MX records, provision addresses in Rails, or
|
|
45
52
|
replace your existing mailbox provider. Use a receiving subdomain when your apex
|
|
46
53
|
domain's email belongs to Google Workspace or Microsoft 365.
|
|
47
54
|
|
|
48
55
|
If using the gem's managed mailboxes, register the receiving mailbox/address in
|
|
49
56
|
Rails too. Tenant support and domain catch-all receiving remain explicit choices.
|
|
57
|
+
After verifying a dynamic namespace as described in the guide, register new
|
|
58
|
+
organization domains in Rails without routinely adding provider rules per org.
|
|
59
|
+
Verify wildcard receiving on your account before relying on unlisted subdomains.
|
|
50
60
|
Outbound Email Sending credentials and delivery-event subscriptions are separate
|
|
51
61
|
from this inbound Worker setup.
|
|
52
62
|
|
|
53
63
|
## 4. Verify and operate
|
|
54
64
|
|
|
65
|
+
For dynamic organization subdomains, start with the read-only DNS check:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
npm run check:subdomains -- --base in.example.com --labels acme,globex
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
It checks named and fresh labels without API credentials or configuration changes.
|
|
72
|
+
It reports DNS observations only; finish with the real-email checks below.
|
|
73
|
+
|
|
55
74
|
Send to the configured address and verify that Rails retains and routes it.
|
|
56
75
|
In staging, stop Rails, send another message, confirm it remains in R2, restore
|
|
57
76
|
Rails, and confirm one inbox record and removal of the pending object. Verify a
|
|
@@ -67,10 +86,9 @@ also tolerate retries. Configure operational alerts in your monitoring service.
|
|
|
67
86
|
|
|
68
87
|
## Updates and local development
|
|
69
88
|
|
|
70
|
-
Your copied repository
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
storage. Keep production resources separate from test resources.
|
|
89
|
+
Your copied repository contains your Worker deployment. Deploy changes from that
|
|
90
|
+
repository; updating the gem does not deploy the Worker. Preserve resource names,
|
|
91
|
+
secrets, and pending storage.
|
|
74
92
|
|
|
75
93
|
Use Node 22.12+ (or a supported newer version): `npm ci`, `npm test`, and
|
|
76
94
|
`npm run check`. For local development, copy `.dev.vars.example` to `.dev.vars`,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Set up addresses and customer subdomains
|
|
2
|
+
|
|
3
|
+
Choose the address pattern for your app:
|
|
4
|
+
|
|
5
|
+
| Pattern | Example | Rails setup |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| One receiving domain | `acme@in.example.com` | Register the domain once; create a mailbox for each customer |
|
|
8
|
+
| Customer subdomains | `invoices@acme.in.example.com` | Register each exact customer domain and its mailboxes |
|
|
9
|
+
|
|
10
|
+
One Worker can serve all your accepted addresses. Rails looks up the mailbox;
|
|
11
|
+
you do not need to maintain an address list in the Worker.
|
|
12
|
+
Database multi-tenancy is optional.
|
|
13
|
+
|
|
14
|
+
## 1. Deploy the Worker
|
|
15
|
+
|
|
16
|
+
[](https://deploy.workers.cloudflare.com/?url=https://github.com/cole-robertson/cloudflare-email/tree/main/templates/deploy-to-cloudflare)
|
|
17
|
+
|
|
18
|
+
Deploy Rails first, then enter its HTTPS ingress URL and shared secret in the
|
|
19
|
+
Worker deployment form. Cloudflare provisions private R2 storage and a Queue.
|
|
20
|
+
Use separate deployments for development and production.
|
|
21
|
+
|
|
22
|
+
The [setup guide](https://github.com/cole-robertson/cloudflare-email/tree/main/templates/deploy-to-cloudflare)
|
|
23
|
+
covers the form and required resources.
|
|
24
|
+
|
|
25
|
+
## 2. Connect your receiving domain
|
|
26
|
+
|
|
27
|
+
Use a subdomain such as `in.example.com` if your main domain already receives
|
|
28
|
+
mail through Google Workspace or Microsoft 365.
|
|
29
|
+
|
|
30
|
+
In Cloudflare Email Routing:
|
|
31
|
+
|
|
32
|
+
1. [Add the receiving domain or subdomain](https://developers.cloudflare.com/email-service/configuration/subdomains/).
|
|
33
|
+
2. Add the DNS records Cloudflare provides, preserving your main domain's mail records.
|
|
34
|
+
3. Create a route with **Send to a Worker** and select your deployed Worker.
|
|
35
|
+
4. To accept many local parts, configure the receiving domain's
|
|
36
|
+
[catch-all rule](https://developers.cloudflare.com/email-service/configuration/email-routing-addresses/#catch-all-rule)
|
|
37
|
+
to use that Worker.
|
|
38
|
+
|
|
39
|
+
A Cloudflare catch-all gets email to your Worker. Rails still decides which
|
|
40
|
+
addresses are accepted. To put unknown local parts into an inbox, also enable
|
|
41
|
+
the gem's [mailbox catch-all](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/mailboxes.md#receive-unregistered-local-parts-with-an-optional-catch-all).
|
|
42
|
+
|
|
43
|
+
## 3. Customer subdomains
|
|
44
|
+
|
|
45
|
+
Cloudflare documents explicit subdomain onboarding. If you want new customer
|
|
46
|
+
subdomains to work without adding each one in Cloudflare, verify wildcard
|
|
47
|
+
receiving on your account before relying on it.
|
|
48
|
+
|
|
49
|
+
For a receiving namespace under `in.example.com`, the DNS pattern is:
|
|
50
|
+
|
|
51
|
+
| Type | Name in zone `example.com` | Value |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| MX | `*.in` | Each receiving MX target and priority assigned by Cloudflare |
|
|
54
|
+
|
|
55
|
+
Wildcard DNS alone does not guarantee Email Routing accepts an unlisted
|
|
56
|
+
subdomain. Test with a fresh customer subdomain and confirm email reaches the
|
|
57
|
+
Worker and the correct Rails inbox. If your account requires explicit onboarding,
|
|
58
|
+
add those subdomains in Cloudflare or use the single-domain pattern.
|
|
59
|
+
|
|
60
|
+
[Explicit DNS records override wildcard inheritance](https://developers.cloudflare.com/dns/manage-dns-records/reference/wildcard-dns-records/),
|
|
61
|
+
including names with A or TXT records. Check existing customer names as well as
|
|
62
|
+
new ones. The receiving base `in.example.com` needs its own records.
|
|
63
|
+
|
|
64
|
+
### Check DNS
|
|
65
|
+
|
|
66
|
+
From either Worker template:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
npm run check:subdomains -- --base in.example.com --labels acme,globex
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This checks your chosen labels and two fresh labels without changing DNS.
|
|
73
|
+
It needs Node and DNS access, with no API key. A passing result confirms MX
|
|
74
|
+
answers; send a real email to verify delivery.
|
|
75
|
+
|
|
76
|
+
For configured Worker rules, use the
|
|
77
|
+
[Rails routing diagnostic](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/routing-diagnostics.md).
|
|
78
|
+
That check inspects exact DNS records, so inherited wildcard MX may appear missing.
|
|
79
|
+
|
|
80
|
+
## 4. Create the mailbox in Rails
|
|
81
|
+
|
|
82
|
+
After verifying the receiving route, run this from trusted application setup code:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
registry = Cloudflare::Email::Mailboxes
|
|
86
|
+
domain = registry.register_domain(
|
|
87
|
+
domain: "acme.in.example.com",
|
|
88
|
+
tenant_key: "organization-123"
|
|
89
|
+
)
|
|
90
|
+
registry.activate_domain!(domain.id, evidence: "Receiving route verified")
|
|
91
|
+
|
|
92
|
+
registry.for_tenant("organization-123") do |inboxes|
|
|
93
|
+
mailbox = inboxes.create(
|
|
94
|
+
name: "Invoices",
|
|
95
|
+
address: "invoices@acme.in.example.com",
|
|
96
|
+
owner_ref: "Organization:123"
|
|
97
|
+
)
|
|
98
|
+
inboxes.activate_address!(mailbox.addresses.first.id,
|
|
99
|
+
evidence: "Receiving route verified")
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Use your application's stable organization identity and validate customer slugs.
|
|
104
|
+
Activation records the verification you performed; it does not configure DNS.
|
|
105
|
+
Registration takes exact domains, not `*` patterns.
|
|
106
|
+
|
|
107
|
+
Once wildcard receiving is verified, create new customer domains and mailboxes
|
|
108
|
+
through these same APIs. Send a test message and confirm it appears only in the
|
|
109
|
+
intended inbox. Monitor pending mail so unregistered or suspended destinations
|
|
110
|
+
can be resolved.
|
|
111
|
+
|
|
112
|
+
## Sending from customer addresses
|
|
113
|
+
|
|
114
|
+
Receiving and sending are separate. Cloudflare requires
|
|
115
|
+
[verification of each sending domain](https://developers.cloudflare.com/email-service/configuration/subdomains/#add-a-subdomain-to-email-sending).
|
|
116
|
+
A simple setup uses a shared verified From address and a customer-specific
|
|
117
|
+
Reply-To address. To send through a managed mailbox, follow the
|
|
118
|
+
[mailbox sending setup](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/mailboxes.md#send-from-the-mailbox).
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { checkSubdomains } from "../scripts/check-subdomains.mjs";
|
|
3
|
+
|
|
4
|
+
function probes() {
|
|
5
|
+
let id = 0;
|
|
6
|
+
return () => `fresh-${++id}`;
|
|
7
|
+
}
|
|
8
|
+
const cloudflare = [{ priority: 10, exchange: "route1.mx.cloudflare.net" }];
|
|
9
|
+
|
|
10
|
+
describe("subdomain setup DNS observations", () => {
|
|
11
|
+
it("checks chosen exact names plus fresh labels without claiming delivery", async () => {
|
|
12
|
+
const lookup = vi.fn(async () => cloudflare);
|
|
13
|
+
const report = await checkSubdomains({ base: "in.example.com", labels: ["acme"] }, lookup, probes());
|
|
14
|
+
expect(lookup.mock.calls.map(call => call[0])).toEqual(["acme.in.example.com", "fresh-1.in.example.com", "fresh-2.in.example.com"]);
|
|
15
|
+
expect(report.dns_observation).toBe("cloudflare_mx_observed_for_all_names");
|
|
16
|
+
expect(report.delivery_verified).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("reports an exact-name exception even when fresh probes resolve", async () => {
|
|
20
|
+
const report = await checkSubdomains({ base: "in.example.com", labels: ["acme"] }, async domain => {
|
|
21
|
+
if (domain.startsWith("acme.")) throw Object.assign(new Error("no answer"), { code: "ENODATA" });
|
|
22
|
+
return cloudflare;
|
|
23
|
+
}, probes());
|
|
24
|
+
expect(report.dns_observation).toBe("needs_attention");
|
|
25
|
+
expect(report.checks[0].status).toBe("unknown");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it.each([
|
|
29
|
+
{ mx: [] }, { mx: [{ priority: 0, exchange: "." }] },
|
|
30
|
+
{ mx: [{ priority: 10, exchange: "mx.example.com" }] },
|
|
31
|
+
{ mx: [...cloudflare, { priority: 5, exchange: "mx.other.test" }] }
|
|
32
|
+
])("does not accept absent, null, foreign or mixed MX: $mx", async ({ mx }) => {
|
|
33
|
+
const report = await checkSubdomains({ base: "in.example.com" }, async () => mx, probes());
|
|
34
|
+
expect(report.dns_observation).toBe("needs_attention");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("preserves DNS timeout uncertainty", async () => {
|
|
38
|
+
const report = await checkSubdomains({ base: "in.example.com" }, async () => { throw Object.assign(new Error("timeout"), { code: "ETIMEOUT" }); }, probes());
|
|
39
|
+
expect(report.checks.every(check => check.status === "unknown")).toBe(true);
|
|
40
|
+
expect(report.delivery_verified).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects malformed domains and nested labels before DNS", async () => {
|
|
44
|
+
const lookup = vi.fn();
|
|
45
|
+
for (const base of ["*.example.com", "https://example.com", "a@b.com", "localhost", "a..com"]) {
|
|
46
|
+
await expect(checkSubdomains({ base }, lookup)).rejects.toThrow();
|
|
47
|
+
}
|
|
48
|
+
await expect(checkSubdomains({ base: "example.com", labels: ["nested.acme"] }, lookup)).rejects.toThrow();
|
|
49
|
+
expect(lookup).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
});
|
data/templates/worker/README.md
CHANGED
|
@@ -6,6 +6,12 @@ gem.
|
|
|
6
6
|
|
|
7
7
|
## Deploy
|
|
8
8
|
|
|
9
|
+
Choose a single receiving domain or dynamic organization subdomains using the
|
|
10
|
+
[domain setup guide](docs/domain-setup.md). Once your
|
|
11
|
+
receiving namespace is verified, new mailboxes can be created in Rails without
|
|
12
|
+
editing a Worker recipient list. `npm run check:subdomains -- --base in.example.com`
|
|
13
|
+
checks public MX for fresh names; it does not claim live delivery verification.
|
|
14
|
+
|
|
9
15
|
For a guided dashboard deployment that provisions resources and prompts for
|
|
10
16
|
secrets, use the [Deploy to Cloudflare template](../deploy-to-cloudflare/README.md).
|
|
11
17
|
It uses this same Worker with a standalone configuration. The CLI setup below
|
|
@@ -60,7 +66,7 @@ For each inbound message, the Worker:
|
|
|
60
66
|
pass. Even exhausted queue retries leave the original message recoverable.
|
|
61
67
|
|
|
62
68
|
The signed request uses HMAC-SHA256 and authenticates the original SMTP envelope
|
|
63
|
-
alongside the MIME bytes. See [durable delivery setup and recovery](
|
|
69
|
+
alongside the MIME bytes. See [durable delivery setup and recovery](docs/durable-inbound.md).
|
|
64
70
|
`INBOUND_DELIVERY_MODE=direct` selects an explicit single-attempt fallback that
|
|
65
71
|
calls `message.setReject` on non-2xx, timeout or network failure. Existing pending
|
|
66
72
|
mail keeps draining through Queue and cron while this fallback is selected.
|
|
@@ -70,8 +76,7 @@ timestamps outside its 5-minute acceptance window. A signature authenticates the
|
|
|
70
76
|
Worker request, not the original email sender. Captured requests remain valid
|
|
71
77
|
inside that window; Rails deduplicates messages through Action Mailbox.
|
|
72
78
|
|
|
73
|
-
|
|
74
|
-
transition. Rails accepts v2 and v3 signatures and rejects v1/missing versions. It stores authenticated
|
|
79
|
+
Rails accepts v2 and v3 signatures and rejects v1/missing versions. It stores authenticated
|
|
75
80
|
SMTP metadata separately from MIME; applications read it using
|
|
76
81
|
`Cloudflare::Email::Envelope.for(inbound_email)`. Duplicate detection includes the
|
|
77
82
|
exact SMTP recipient, preserving separate To/Cc/Bcc deliveries. The format accepts
|
|
@@ -138,8 +143,7 @@ encoded as unpadded base64url, limited to 16,384 encoded characters, and sent as
|
|
|
138
143
|
HMAC-SHA256(secret, "v3.{timestamp}.{encoded_envelope}.{encoded_metadata}.{raw_body}")
|
|
139
144
|
```
|
|
140
145
|
|
|
141
|
-
Neither the MIME nor its headers are rewritten.
|
|
142
|
-
supports v3 before enabling metadata in a custom Worker. Keep sensitive provider
|
|
146
|
+
Neither the MIME nor its headers are rewritten. Keep sensitive provider
|
|
143
147
|
data out of metadata unless your application's retention and access policies
|
|
144
148
|
permit storing it with email records.
|
|
145
149
|
|
|
@@ -234,10 +238,10 @@ after delivery and does not replace this optional outage archive.
|
|
|
234
238
|
|
|
235
239
|
## Durable inbound delivery
|
|
236
240
|
|
|
237
|
-
[Durable inbound delivery](
|
|
241
|
+
[Durable inbound delivery](docs/durable-inbound.md) is the default. Provision the
|
|
238
242
|
configured private R2 bucket, Queue producer/consumer and cron before deploying.
|
|
239
243
|
There is no rollout enable flag or automatic downgrade when bindings are missing.
|
|
240
|
-
The guide covers
|
|
244
|
+
The guide covers monitoring, recovery tests, and direct fallback.
|
|
241
245
|
|
|
242
246
|
## Validate changes
|
|
243
247
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Set up addresses and customer subdomains
|
|
2
|
+
|
|
3
|
+
Choose the address pattern for your app:
|
|
4
|
+
|
|
5
|
+
| Pattern | Example | Rails setup |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| One receiving domain | `acme@in.example.com` | Register the domain once; create a mailbox for each customer |
|
|
8
|
+
| Customer subdomains | `invoices@acme.in.example.com` | Register each exact customer domain and its mailboxes |
|
|
9
|
+
|
|
10
|
+
One Worker can serve all your accepted addresses. Rails looks up the mailbox;
|
|
11
|
+
you do not need to maintain an address list in the Worker.
|
|
12
|
+
Database multi-tenancy is optional.
|
|
13
|
+
|
|
14
|
+
## 1. Deploy the Worker
|
|
15
|
+
|
|
16
|
+
[](https://deploy.workers.cloudflare.com/?url=https://github.com/cole-robertson/cloudflare-email/tree/main/templates/deploy-to-cloudflare)
|
|
17
|
+
|
|
18
|
+
Deploy Rails first, then enter its HTTPS ingress URL and shared secret in the
|
|
19
|
+
Worker deployment form. Cloudflare provisions private R2 storage and a Queue.
|
|
20
|
+
Use separate deployments for development and production.
|
|
21
|
+
|
|
22
|
+
The [setup guide](https://github.com/cole-robertson/cloudflare-email/tree/main/templates/deploy-to-cloudflare)
|
|
23
|
+
covers the form and required resources.
|
|
24
|
+
|
|
25
|
+
## 2. Connect your receiving domain
|
|
26
|
+
|
|
27
|
+
Use a subdomain such as `in.example.com` if your main domain already receives
|
|
28
|
+
mail through Google Workspace or Microsoft 365.
|
|
29
|
+
|
|
30
|
+
In Cloudflare Email Routing:
|
|
31
|
+
|
|
32
|
+
1. [Add the receiving domain or subdomain](https://developers.cloudflare.com/email-service/configuration/subdomains/).
|
|
33
|
+
2. Add the DNS records Cloudflare provides, preserving your main domain's mail records.
|
|
34
|
+
3. Create a route with **Send to a Worker** and select your deployed Worker.
|
|
35
|
+
4. To accept many local parts, configure the receiving domain's
|
|
36
|
+
[catch-all rule](https://developers.cloudflare.com/email-service/configuration/email-routing-addresses/#catch-all-rule)
|
|
37
|
+
to use that Worker.
|
|
38
|
+
|
|
39
|
+
A Cloudflare catch-all gets email to your Worker. Rails still decides which
|
|
40
|
+
addresses are accepted. To put unknown local parts into an inbox, also enable
|
|
41
|
+
the gem's [mailbox catch-all](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/mailboxes.md#receive-unregistered-local-parts-with-an-optional-catch-all).
|
|
42
|
+
|
|
43
|
+
## 3. Customer subdomains
|
|
44
|
+
|
|
45
|
+
Cloudflare documents explicit subdomain onboarding. If you want new customer
|
|
46
|
+
subdomains to work without adding each one in Cloudflare, verify wildcard
|
|
47
|
+
receiving on your account before relying on it.
|
|
48
|
+
|
|
49
|
+
For a receiving namespace under `in.example.com`, the DNS pattern is:
|
|
50
|
+
|
|
51
|
+
| Type | Name in zone `example.com` | Value |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| MX | `*.in` | Each receiving MX target and priority assigned by Cloudflare |
|
|
54
|
+
|
|
55
|
+
Wildcard DNS alone does not guarantee Email Routing accepts an unlisted
|
|
56
|
+
subdomain. Test with a fresh customer subdomain and confirm email reaches the
|
|
57
|
+
Worker and the correct Rails inbox. If your account requires explicit onboarding,
|
|
58
|
+
add those subdomains in Cloudflare or use the single-domain pattern.
|
|
59
|
+
|
|
60
|
+
[Explicit DNS records override wildcard inheritance](https://developers.cloudflare.com/dns/manage-dns-records/reference/wildcard-dns-records/),
|
|
61
|
+
including names with A or TXT records. Check existing customer names as well as
|
|
62
|
+
new ones. The receiving base `in.example.com` needs its own records.
|
|
63
|
+
|
|
64
|
+
### Check DNS
|
|
65
|
+
|
|
66
|
+
From either Worker template:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
npm run check:subdomains -- --base in.example.com --labels acme,globex
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This checks your chosen labels and two fresh labels without changing DNS.
|
|
73
|
+
It needs Node and DNS access, with no API key. A passing result confirms MX
|
|
74
|
+
answers; send a real email to verify delivery.
|
|
75
|
+
|
|
76
|
+
For configured Worker rules, use the
|
|
77
|
+
[Rails routing diagnostic](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/routing-diagnostics.md).
|
|
78
|
+
That check inspects exact DNS records, so inherited wildcard MX may appear missing.
|
|
79
|
+
|
|
80
|
+
## 4. Create the mailbox in Rails
|
|
81
|
+
|
|
82
|
+
After verifying the receiving route, run this from trusted application setup code:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
registry = Cloudflare::Email::Mailboxes
|
|
86
|
+
domain = registry.register_domain(
|
|
87
|
+
domain: "acme.in.example.com",
|
|
88
|
+
tenant_key: "organization-123"
|
|
89
|
+
)
|
|
90
|
+
registry.activate_domain!(domain.id, evidence: "Receiving route verified")
|
|
91
|
+
|
|
92
|
+
registry.for_tenant("organization-123") do |inboxes|
|
|
93
|
+
mailbox = inboxes.create(
|
|
94
|
+
name: "Invoices",
|
|
95
|
+
address: "invoices@acme.in.example.com",
|
|
96
|
+
owner_ref: "Organization:123"
|
|
97
|
+
)
|
|
98
|
+
inboxes.activate_address!(mailbox.addresses.first.id,
|
|
99
|
+
evidence: "Receiving route verified")
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Use your application's stable organization identity and validate customer slugs.
|
|
104
|
+
Activation records the verification you performed; it does not configure DNS.
|
|
105
|
+
Registration takes exact domains, not `*` patterns.
|
|
106
|
+
|
|
107
|
+
Once wildcard receiving is verified, create new customer domains and mailboxes
|
|
108
|
+
through these same APIs. Send a test message and confirm it appears only in the
|
|
109
|
+
intended inbox. Monitor pending mail so unregistered or suspended destinations
|
|
110
|
+
can be resolved.
|
|
111
|
+
|
|
112
|
+
## Sending from customer addresses
|
|
113
|
+
|
|
114
|
+
Receiving and sending are separate. Cloudflare requires
|
|
115
|
+
[verification of each sending domain](https://developers.cloudflare.com/email-service/configuration/subdomains/#add-a-subdomain-to-email-sending).
|
|
116
|
+
A simple setup uses a shared verified From address and a customer-specific
|
|
117
|
+
Reply-To address. To send through a managed mailbox, follow the
|
|
118
|
+
[mailbox sending setup](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/mailboxes.md#send-from-the-mailbox).
|