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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -1
  3. data/README.md +63 -369
  4. data/SECURITY.md +1 -9
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +1 -2
  6. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +2 -166
  7. data/app/controllers/cloudflare/email/management/styles_controller.rb +2 -6
  8. data/docs/activerecord-tenanted.md +0 -11
  9. data/docs/custom-ingress.md +6 -16
  10. data/docs/delivery-events.md +2 -5
  11. data/docs/features.md +49 -123
  12. data/docs/getting-started.md +91 -172
  13. data/docs/mailboxes.md +27 -56
  14. data/docs/management-engine.md +1 -3
  15. data/docs/outbox.md +5 -6
  16. data/docs/reference.md +190 -0
  17. data/docs/routing-diagnostics.md +7 -2
  18. data/docs/thread-correlation.md +2 -4
  19. data/docs/troubleshooting.md +4 -4
  20. data/lib/cloudflare/email/active_record/base.rb +3 -53
  21. data/lib/cloudflare/email/client.rb +23 -5
  22. data/lib/cloudflare/email/engine.rb +1 -13
  23. data/lib/cloudflare/email/envelope.rb +2 -12
  24. data/lib/cloudflare/email/error.rb +5 -12
  25. data/lib/cloudflare/email/ingress.rb +19 -8
  26. data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
  27. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
  28. data/lib/cloudflare/email/mailboxes/models.rb +8 -147
  29. data/lib/cloudflare/email/mailboxes/service.rb +7 -258
  30. data/lib/cloudflare/email/mailboxes.rb +1 -0
  31. data/lib/cloudflare/email/management/adapter.rb +3 -25
  32. data/lib/cloudflare/email/management/configuration.rb +4 -11
  33. data/lib/cloudflare/email/management/engine.rb +2 -0
  34. data/lib/cloudflare/email/tenancy.rb +2 -73
  35. data/lib/cloudflare/email/tenant_job_context.rb +2 -85
  36. data/lib/cloudflare/email/version.rb +1 -1
  37. data/lib/cloudflare-email.rb +6 -0
  38. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +1 -0
  39. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
  40. data/templates/deploy-to-cloudflare/README.md +25 -7
  41. data/templates/deploy-to-cloudflare/docs/domain-setup.md +118 -0
  42. data/templates/deploy-to-cloudflare/package.json +1 -0
  43. data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
  44. data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
  45. data/templates/worker/README.md +11 -7
  46. data/templates/worker/docs/domain-setup.md +118 -0
  47. data/templates/worker/docs/durable-inbound.md +4 -16
  48. data/templates/worker/package.json +1 -0
  49. data/templates/worker/scripts/check-subdomains.mjs +54 -0
  50. data/templates/worker/test/subdomains.test.ts +51 -0
  51. metadata +22 -21
  52. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +0 -65
  53. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +0 -34
  54. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +0 -86
  55. data/app/views/layouts/cloudflare/email/management.html.erb +0 -29
  56. data/docs/architecture.md +0 -105
  57. data/docs/tenant-mailboxes-plan.md +0 -149
  58. data/docs/upgrading-0.2.md +0 -101
  59. data/docs/upgrading-0.3.md +0 -55
  60. data/docs/verification/2026-09-10-followup.md +0 -49
  61. data/docs/verification/2026-09-10-install-upgrade.md +0 -86
  62. data/docs/verification/2026-09-10-live.md +0 -102
  63. data/docs/verification/2026-09-10.md +0 -175
  64. data/docs/verification/2026-09-11-custom-ingress.md +0 -49
  65. data/docs/verification/2026-09-11-extraction.md +0 -32
  66. data/docs/verification/2026-09-11-outbound-ledger.md +0 -60
  67. data/docs/verification/2026-09-11-security.md +0 -76
  68. data/docs/verification/2026-09-11-tenant-mailboxes.md +0 -75
  69. data/docs/verification/2026-09-12-destination-context.md +0 -46
  70. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +0 -71
  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
- # 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.1"
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
@@ -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. No React app or extra server is needed.
6
+ recovery and Worker logs.
7
7
 
8
8
  [![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)
9
9
 
10
10
  ## 1. Prepare Rails
11
11
 
12
- Install `gem "cloudflare-email", "~> 0.3.0"` and follow the
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 belongs to you; gem updates do not update its Worker
71
- automatically. Review upstream release notes, deploy Rails first, then port Worker
72
- changes into your copy while preserving your resource names, secrets, and pending
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
+ [![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)
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).
@@ -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 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](./docs/durable-inbound.md).
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
- Deploy the Rails gem and this Worker together with ingress paused during the
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. Upgrade Rails to a version that
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](./docs/durable-inbound.md) is the default. Provision the
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 upgrades, monitoring, recovery tests and explicit direct fallback.
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
+ [![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)
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).