cloudflare-email 0.3.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +14 -4
  4. data/app/controllers/cloudflare/email/ingress_controller.rb +1 -2
  5. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +2 -166
  6. data/app/controllers/cloudflare/email/management/styles_controller.rb +2 -6
  7. data/docs/custom-ingress.md +5 -1
  8. data/docs/features.md +1 -1
  9. data/docs/getting-started.md +4 -3
  10. data/docs/mailboxes.md +8 -1
  11. data/docs/routing-diagnostics.md +7 -0
  12. data/docs/troubleshooting.md +1 -1
  13. data/docs/verification/2026-09-13-action-mailbox-core.md +96 -0
  14. data/lib/cloudflare/email/active_record/base.rb +3 -53
  15. data/lib/cloudflare/email/engine.rb +1 -13
  16. data/lib/cloudflare/email/envelope.rb +2 -12
  17. data/lib/cloudflare/email/error.rb +5 -12
  18. data/lib/cloudflare/email/ingress.rb +19 -8
  19. data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
  20. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
  21. data/lib/cloudflare/email/mailboxes/models.rb +8 -147
  22. data/lib/cloudflare/email/mailboxes/service.rb +7 -258
  23. data/lib/cloudflare/email/mailboxes.rb +1 -0
  24. data/lib/cloudflare/email/management/adapter.rb +3 -25
  25. data/lib/cloudflare/email/management/configuration.rb +4 -11
  26. data/lib/cloudflare/email/management/engine.rb +2 -0
  27. data/lib/cloudflare/email/tenancy.rb +2 -73
  28. data/lib/cloudflare/email/tenant_job_context.rb +2 -85
  29. data/lib/cloudflare/email/version.rb +1 -1
  30. data/lib/cloudflare-email.rb +6 -0
  31. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +1 -0
  32. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
  33. data/templates/deploy-to-cloudflare/README.md +21 -1
  34. data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
  35. data/templates/deploy-to-cloudflare/package.json +1 -0
  36. data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
  37. data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
  38. data/templates/worker/README.md +6 -0
  39. data/templates/worker/docs/domain-setup.md +198 -0
  40. data/templates/worker/package.json +1 -0
  41. data/templates/worker/scripts/check-subdomains.mjs +54 -0
  42. data/templates/worker/test/subdomains.test.ts +51 -0
  43. metadata +22 -6
  44. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +0 -65
  45. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +0 -34
  46. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +0 -86
  47. data/app/views/layouts/cloudflare/email/management.html.erb +0 -29
  48. data/lib/cloudflare/email/management/management.css +0 -68
@@ -1,78 +1,7 @@
1
1
  require "cloudflare/email/error"
2
-
2
+ require "mailbox_kit/tenancy"
3
3
  module Cloudflare
4
4
  module Email
5
- # An adapter boundary: the host owns tenant discovery and connection switching.
6
- # Configure once, before requiring any optional Active Record models.
7
- module Tenancy
8
- CONTEXT_KEY = :cloudflare_email_tenant_key
9
-
10
- class << self
11
- def configure(base_class:, switch:, current:)
12
- raise ConfigurationError, "configure tenancy before loading Cloudflare Email models" if @models_loaded
13
- raise ConfigurationError, "tenancy is already configured" if enabled?
14
- unless base_class.is_a?(Class) && base_class.respond_to?(:abstract_class?) && base_class.abstract_class?
15
- raise ConfigurationError, "base_class must be an abstract Active Record class"
16
- end
17
- unless switch.respond_to?(:call) && current.respond_to?(:call)
18
- raise ConfigurationError, "switch and current must be callable"
19
- end
20
-
21
- @base_class, @switch, @current = base_class, switch, current
22
- self
23
- end
24
-
25
- def enabled?
26
- !@switch.nil?
27
- end
28
-
29
- def model_base(default)
30
- @models_loaded = true
31
- @base_class || default
32
- end
33
-
34
- def current_key
35
- Thread.current[CONTEXT_KEY]
36
- end
37
-
38
- # Only trusted host integration code should use this to capture new work.
39
- # Model access and persisted job deserialization still require explicit
40
- # gem context; the adapter must not invent a default tenant here.
41
- def host_current_key
42
- return unless enabled?
43
- key = @current.call
44
- normalize_key(key) unless key.nil?
45
- end
46
-
47
- def normalize_key(key)
48
- unless key.is_a?(String) && !key.empty? && key == key.strip && !key.match?(/[[:cntrl:]]/)
49
- raise ConfigurationError, "tenant key must be a nonempty string without surrounding whitespace or control characters"
50
- end
51
- key.dup.freeze
52
- end
53
-
54
- def require_context!
55
- key = current_key
56
- raise ConfigurationError, "an explicit Cloudflare Email tenant context is required" unless key
57
- if enabled? && @current.call != key
58
- raise ConfigurationError, "host database tenant does not match Cloudflare Email tenant context"
59
- end
60
- key
61
- end
62
-
63
- def with(key)
64
- key = normalize_key(key)
65
- previous = current_key
66
- run = proc do
67
- Thread.current[CONTEXT_KEY] = key
68
- require_context!
69
- yield
70
- ensure
71
- Thread.current[CONTEXT_KEY] = previous
72
- end
73
- enabled? ? @switch.call(key, &run) : run.call
74
- end
75
- end
76
- end
5
+ Tenancy = MailboxKit::Tenancy
77
6
  end
78
7
  end
@@ -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
- # Opt in with `prepend Cloudflare::Email::TenantJobContext` on jobs whose
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
@@ -1,5 +1,5 @@
1
1
  module Cloudflare
2
2
  module Email
3
- VERSION = "0.3.0"
3
+ VERSION = "0.4.0"
4
4
  end
5
5
  end
@@ -1,3 +1,9 @@
1
+ require "mailbox-kit"
2
+ module Cloudflare
3
+ module Email
4
+ Mailboxes = MailboxKit::Mailboxes
5
+ end
6
+ end
1
7
  require "cloudflare/email/version"
2
8
  require "cloudflare/email/error"
3
9
  require "cloudflare/email/response"
@@ -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, null: false
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
@@ -38,20 +38,40 @@ 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 the setup worksheet,
44
+ Cloudflare DNS/routing steps, cited Rebulk example, 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
+ Wildcard DNS behavior must be verified on your Cloudflare account; the guide
60
+ distinguishes working Rebulk evidence from Cloudflare's explicit onboarding docs.
50
61
  Outbound Email Sending credentials and delivery-event subscriptions are separate
51
62
  from this inbound Worker setup.
52
63
 
53
64
  ## 4. Verify and operate
54
65
 
66
+ For dynamic organization subdomains, start with the read-only DNS check:
67
+
68
+ ```sh
69
+ npm run check:subdomains -- --base in.example.com --labels acme,globex
70
+ ```
71
+
72
+ It checks named and fresh labels without API credentials or configuration changes.
73
+ It reports DNS observations only; finish with the real-email checks below.
74
+
55
75
  Send to the configured address and verify that Rails retains and routes it.
56
76
  In staging, stop Rails, send another message, confirm it remains in R2, restore
57
77
  Rails, and confirm one inbox record and removal of the pending object. Verify a
@@ -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
+ [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cole-robertson/cloudflare-email/tree/main/templates/deploy-to-cloudflare)
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.
@@ -11,6 +11,7 @@
11
11
  "deploy": "wrangler deploy",
12
12
  "dev": "wrangler dev",
13
13
  "check": "wrangler deploy --dry-run",
14
+ "check:subdomains": "node scripts/check-subdomains.mjs",
14
15
  "test": "vitest run"
15
16
  },
16
17
  "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
+ }
@@ -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
+ });
@@ -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 worksheet and cited examples](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