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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +14 -4
- 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/custom-ingress.md +5 -1
- data/docs/features.md +1 -1
- data/docs/getting-started.md +4 -3
- data/docs/mailboxes.md +8 -1
- data/docs/routing-diagnostics.md +7 -0
- data/docs/troubleshooting.md +1 -1
- data/docs/verification/2026-09-13-action-mailbox-core.md +96 -0
- data/lib/cloudflare/email/active_record/base.rb +3 -53
- 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 +21 -1
- data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -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 +6 -0
- data/templates/worker/docs/domain-setup.md +198 -0
- 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 -6
- 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/lib/cloudflare/email/management/management.css +0 -68
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Set up once, create mailboxes in Rails
|
|
2
|
+
|
|
3
|
+
Use the same Worker for either address shape:
|
|
4
|
+
|
|
5
|
+
| Pattern | Example | What changes when a customer joins |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| One receiving domain | `acme@in.example.com` | Create a mailbox/address in Rails |
|
|
8
|
+
| Organization subdomains | `invoices@acme.in.example.com` | Register the exact organization domain and its mailboxes in Rails, within your verified receiving namespace |
|
|
9
|
+
|
|
10
|
+
Neither pattern needs a recipient allowlist in the Worker. Rails owns accepted
|
|
11
|
+
addresses and organization membership. Database multi-tenancy remains optional.
|
|
12
|
+
The [Rails hello-world template](https://github.com/cole-robertson/cloudflare-email-rails-starter)
|
|
13
|
+
demonstrates the first pattern with a normal SQLite database and generated login.
|
|
14
|
+
|
|
15
|
+
## What is verified, and what Cloudflare documents
|
|
16
|
+
|
|
17
|
+
**Rebulk's existing deployment uses the second pattern:**
|
|
18
|
+
`<site>@<organization>.rebulk.com`, wildcard MX, a catch-all Worker rule, and Rails
|
|
19
|
+
organization/mailbox lookup. New organizations do not need a Worker enrollment
|
|
20
|
+
list or a routine per-organization Cloudflare approval. Its September 2026
|
|
21
|
+
rehearsal checked exact-domain public MX answers and archived SMTP-envelope
|
|
22
|
+
delivery evidence, alongside the global catch-all rule. The apex keeps its
|
|
23
|
+
separate mail provider. Sources (Rebulk repository access required):
|
|
24
|
+
|
|
25
|
+
- [Worker architecture and organization setup](https://github.com/Rebulk/rebulk-system/blob/1cc26e0076ba0e9157e89f5879604df8c94430bc/cloudflare/README.md).
|
|
26
|
+
- [Wildcard MX observations and limits of zone-level API evidence](https://github.com/Rebulk/rebulk-system/blob/1cc26e0076ba0e9157e89f5879604df8c94430bc/docs/cloudflare-mailbox-onboarding-rehearsal-2026-09-12.md).
|
|
27
|
+
|
|
28
|
+
This is deployment evidence, not a Cloudflare-wide service guarantee. Cloudflare's
|
|
29
|
+
[subdomain onboarding guide](https://developers.cloudflare.com/email-service/configuration/subdomains/)
|
|
30
|
+
describes adding subdomains explicitly and currently lists a 30-domain combined
|
|
31
|
+
Routing/Sending limit. It does not document arbitrary wildcard Email Routing as
|
|
32
|
+
a way to bypass that limit. Cloudflare separately documents
|
|
33
|
+
[wildcard DNS and exact-record precedence](https://developers.cloudflare.com/dns/manage-dns-records/reference/wildcard-dns-records/).
|
|
34
|
+
Working wildcard DNS alone does not establish Email Routing acceptance. If your
|
|
35
|
+
account does not accept unlisted subdomains, use documented explicit onboarding
|
|
36
|
+
within its limits, or the single-domain pattern. Do not assume that an existing
|
|
37
|
+
Rebulk setup proves a fresh account's behavior.
|
|
38
|
+
|
|
39
|
+
A fresh [public DNS observation](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/verification/2026-09-13-wildcard-dns.json)
|
|
40
|
+
also records Cloudflare MX answers for `test.rebulk.com` and two random subdomains
|
|
41
|
+
without creating DNS records or adding those labels in Cloudflare. That report
|
|
42
|
+
is public and deliberately marks `delivery_verified: false`; no email was sent
|
|
43
|
+
by this DNS-only check.
|
|
44
|
+
|
|
45
|
+
## 1. Fill in your setup worksheet
|
|
46
|
+
|
|
47
|
+
Use a dedicated receiving namespace when the apex already serves Workspace or
|
|
48
|
+
Microsoft 365. Example values:
|
|
49
|
+
|
|
50
|
+
| Setting | Your example value |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| Cloudflare zone | `example.com` |
|
|
53
|
+
| Receiving base | `in.example.com` |
|
|
54
|
+
| Organization domain | `acme.in.example.com` |
|
|
55
|
+
| Worker | `cloudflare-email-ingress` |
|
|
56
|
+
| Rails ingress URL | `https://app.example.com/rails/action_mailbox/cloudflare/inbound_emails` |
|
|
57
|
+
| Shared ingress secret | Generate with `openssl rand -hex 32`; save privately in Rails and the Worker |
|
|
58
|
+
|
|
59
|
+
Keep staging and production Workers, buckets, queues, secrets, and receiving
|
|
60
|
+
namespaces separate. The standalone deploy-button template takes one environment
|
|
61
|
+
per copy; the regular CLI template uses `--env production` or another explicit env.
|
|
62
|
+
|
|
63
|
+
## 2. Deploy the shared infrastructure
|
|
64
|
+
|
|
65
|
+
[](https://deploy.workers.cloudflare.com/?url=https://github.com/cole-robertson/cloudflare-email/tree/main/templates/deploy-to-cloudflare)
|
|
66
|
+
|
|
67
|
+
Deploy Rails with gem 0.3+ first. The button copies the standalone Worker and
|
|
68
|
+
prompts for `RAILS_INGRESS_URL` and `INGRESS_SECRET`. Its configuration includes
|
|
69
|
+
private R2 storage, a Queue producer/consumer, once-per-minute recovery, and logs.
|
|
70
|
+
Cloudflare documents [automatic R2/Queue provisioning and secret prompts](https://developers.cloudflare.com/workers/platform/deploy-buttons/).
|
|
71
|
+
The button does not create email DNS, claim a domain, or register Rails mailboxes.
|
|
72
|
+
|
|
73
|
+
## 3. Connect DNS and the Worker once
|
|
74
|
+
|
|
75
|
+
For **one domain**, follow Cloudflare's documented
|
|
76
|
+
[Email Routing onboarding](https://developers.cloudflare.com/email-service/get-started/route-emails/)
|
|
77
|
+
and configure its [catch-all action](https://developers.cloudflare.com/email-service/configuration/email-routing-addresses/#catch-all-rule)
|
|
78
|
+
as **Send to a Worker**, selecting your deployed Worker. This avoids one provider
|
|
79
|
+
rule per local part. Review the actual zone/domain scope and any explicit rules
|
|
80
|
+
that take precedence before saving.
|
|
81
|
+
|
|
82
|
+
For a **Rebulk-style dynamic namespace**, first establish receiving on your account
|
|
83
|
+
and retain the exact MX targets/priorities assigned by Cloudflare. Then, when
|
|
84
|
+
reproducing and verifying wildcard receiving, the DNS template is:
|
|
85
|
+
|
|
86
|
+
| DNS type | Name in zone `example.com` | Value |
|
|
87
|
+
| --- | --- | --- |
|
|
88
|
+
| MX | `*.in` | Each Cloudflare-assigned receiving MX target, with its assigned priority |
|
|
89
|
+
|
|
90
|
+
Create one record per assigned target. Do not invent MX targets, point MX at a
|
|
91
|
+
Worker URL, or replace the apex's existing MX records. Complete the service's
|
|
92
|
+
other required DNS records through its onboarding instructions; do not blindly
|
|
93
|
+
duplicate SPF records. Inspect the account's catch-all **Send to a Worker** rule
|
|
94
|
+
and verify that it actually handles your intended namespace. A Cloudflare Worker
|
|
95
|
+
HTTP route such as `*.example.com/*` is unrelated to email routing.
|
|
96
|
+
|
|
97
|
+
An explicit DNS name can stop wildcard inheritance **even if its record is TXT
|
|
98
|
+
or A rather than MX**. For example, an existing `acme.in.example.com` record may
|
|
99
|
+
require its own receiving MX records. Test exact customer names as well as fresh
|
|
100
|
+
ones. A wildcard also does not supply MX for the receiving base itself.
|
|
101
|
+
|
|
102
|
+
## 4. Check without modifying infrastructure
|
|
103
|
+
|
|
104
|
+
From either current Worker template, run:
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
npm run check:subdomains -- --base in.example.com --labels acme,globex
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
This requires only Node and DNS access, not a Cloudflare API key. It resolves MX
|
|
111
|
+
for your chosen labels plus two fresh random labels. DNS lookups have bounded
|
|
112
|
+
timeouts. JSON output distinguishes observed Cloudflare MX, other/missing MX, and
|
|
113
|
+
lookup uncertainty. Exit zero means only that all queried names resolved to
|
|
114
|
+
Cloudflare MX; `delivery_verified` remains false. Compare returned targets with
|
|
115
|
+
your account's assigned targets. Existing explicit records can explain differences.
|
|
116
|
+
The command ships in the repository templates; gem 0.3.0's already-published
|
|
117
|
+
template predates it, so refresh your template copy to use it.
|
|
118
|
+
|
|
119
|
+
For provider rule inspection, the gem also offers:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
bin/rails cloudflare:email:check_route \
|
|
123
|
+
ADDRESS=invoices@acme.in.example.com \
|
|
124
|
+
WORKER_NAME=cloudflare-email-ingress \
|
|
125
|
+
ACCOUNT_ID=your-cloudflare-account-id
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
See [diagnostic credentials and limits](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/routing-diagnostics.md).
|
|
129
|
+
That diagnostic inspects exact-domain configured records; it does not resolve
|
|
130
|
+
wildcard DNS inheritance. A working inherited-MX setup can therefore report
|
|
131
|
+
missing exact records. Keep public DNS, provider rule inspection, and actual
|
|
132
|
+
delivery evidence separate; do not force every organization through Cloudflare
|
|
133
|
+
onboarding merely to make that diagnostic green.
|
|
134
|
+
|
|
135
|
+
## 5. Prove the dynamic path before adopting it
|
|
136
|
+
|
|
137
|
+
Use two new organization labels that have not been added individually in the
|
|
138
|
+
Cloudflare dashboard:
|
|
139
|
+
|
|
140
|
+
1. Check their public MX answers with the command above.
|
|
141
|
+
2. Through trusted provisioning code, register both **exact** domains in Rails,
|
|
142
|
+
each against the correct stable organization key. Create a test mailbox for
|
|
143
|
+
each. Record your independently checked configuration evidence and activate
|
|
144
|
+
those test addresses so the gem can receive the verification messages.
|
|
145
|
+
3. Send real email to both addresses from an external mailbox. Confirm the
|
|
146
|
+
Worker receives each, Rails retains the original message, and each appears
|
|
147
|
+
only in the correct organization's inbox. Record the message IDs and result.
|
|
148
|
+
4. Repeat with a newly generated label without changing Cloudflare. If delivery
|
|
149
|
+
fails before the Worker, resolve the Cloudflare acceptance/onboarding issue;
|
|
150
|
+
changing Rails cannot fix a provider SMTP rejection.
|
|
151
|
+
5. Rehearse Rails being unavailable, then verify retained R2 mail drains after
|
|
152
|
+
recovery without duplicate app records. Follow the
|
|
153
|
+
[durable recovery guide](https://github.com/cole-robertson/cloudflare-email/blob/main/templates/worker/docs/durable-inbound.md).
|
|
154
|
+
|
|
155
|
+
These are deliberate test addresses; no real-email test is sent by the DNS checker.
|
|
156
|
+
Successful tests establish evidence for your receiving namespace, not a guarantee
|
|
157
|
+
that every future DNS change or provider policy will preserve it.
|
|
158
|
+
|
|
159
|
+
## 6. Create organizations in Rails
|
|
160
|
+
|
|
161
|
+
Once your namespace policy is verified, use the same gem APIs on organization
|
|
162
|
+
creation. For example, from trusted app code after authorizing the organization:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
registry = Cloudflare::Email::Mailboxes
|
|
166
|
+
organization_key = "organization-123" # Trusted stable app identity, not request input.
|
|
167
|
+
domain = registry.register_domain(
|
|
168
|
+
domain: "acme.in.example.com",
|
|
169
|
+
tenant_key: organization_key,
|
|
170
|
+
account_id: Cloudflare::Email::Credentials.account_id
|
|
171
|
+
)
|
|
172
|
+
registry.activate_domain!(domain.id, evidence: verified_namespace_evidence)
|
|
173
|
+
registry.for_tenant(organization_key) do |inboxes|
|
|
174
|
+
mailbox = inboxes.create(name: "Invoices", address: "invoices@acme.in.example.com",
|
|
175
|
+
owner_ref: "Organization:123")
|
|
176
|
+
inboxes.activate_address!(inboxes.addresses(mailbox.id).first.id,
|
|
177
|
+
evidence: verified_namespace_evidence)
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`verified_namespace_evidence` is your stored operator evidence from the setup and
|
|
182
|
+
delivery checks, not a hard-coded claim of success. Make provisioning idempotent
|
|
183
|
+
in your app, validate/reserve slugs, and resolve organization identity from trusted
|
|
184
|
+
records. The gem stores exact domain registrations; it does not accept a `*`
|
|
185
|
+
domain registration or silently authorize an arbitrary recipient from a web form.
|
|
186
|
+
The management engine must obtain allowed domains from your host adapter. The
|
|
187
|
+
hello-world starter intentionally stays single-domain; this recipe is the next
|
|
188
|
+
step for an organization-aware app.
|
|
189
|
+
|
|
190
|
+
A **Cloudflare routing catch-all** gets mail to the Worker. The gem's optional
|
|
191
|
+
**mailbox catch-all** decides whether unknown local parts enter a mailbox. They
|
|
192
|
+
are separate. Without the latter, unknown/suspended recipients return non-2xx and
|
|
193
|
+
remain in durable pending storage until resolved. Monitor that backlog.
|
|
194
|
+
|
|
195
|
+
Receiving on dynamic domains does not authorize sending from them. Cloudflare
|
|
196
|
+
[Email Sending onboarding](https://developers.cloudflare.com/email-service/configuration/subdomains/#add-a-subdomain-to-email-sending)
|
|
197
|
+
is separate; keep a verified shared sending domain unless you have explicitly
|
|
198
|
+
onboarded the customer sending domain.
|
|
@@ -0,0 +1,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
|
+
});
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cloudflare-email
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cole
|
|
@@ -9,6 +9,20 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: mailbox-kit
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.1.0
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: base64
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -141,10 +155,6 @@ files:
|
|
|
141
155
|
- app/controllers/cloudflare/email/ingress_controller.rb
|
|
142
156
|
- app/controllers/cloudflare/email/management/mailboxes_controller.rb
|
|
143
157
|
- app/controllers/cloudflare/email/management/styles_controller.rb
|
|
144
|
-
- app/views/cloudflare/email/management/mailboxes/index.html.erb
|
|
145
|
-
- app/views/cloudflare/email/management/mailboxes/message.html.erb
|
|
146
|
-
- app/views/cloudflare/email/management/mailboxes/show.html.erb
|
|
147
|
-
- app/views/layouts/cloudflare/email/management.html.erb
|
|
148
158
|
- docs/activerecord-tenanted.md
|
|
149
159
|
- docs/architecture.md
|
|
150
160
|
- docs/custom-ingress.md
|
|
@@ -171,6 +181,7 @@ files:
|
|
|
171
181
|
- docs/verification/2026-09-11-security.md
|
|
172
182
|
- docs/verification/2026-09-11-tenant-mailboxes.md
|
|
173
183
|
- docs/verification/2026-09-12-destination-context.md
|
|
184
|
+
- docs/verification/2026-09-13-action-mailbox-core.md
|
|
174
185
|
- docs/verification/2026-09-13-durable-inbound-and-routing.md
|
|
175
186
|
- examples/plain_ruby.rb
|
|
176
187
|
- lib/cloudflare-email.rb
|
|
@@ -215,7 +226,6 @@ files:
|
|
|
215
226
|
- lib/cloudflare/email/management/adapter.rb
|
|
216
227
|
- lib/cloudflare/email/management/configuration.rb
|
|
217
228
|
- lib/cloudflare/email/management/engine.rb
|
|
218
|
-
- lib/cloudflare/email/management/management.css
|
|
219
229
|
- lib/cloudflare/email/management/routes.rb
|
|
220
230
|
- lib/cloudflare/email/message_id.rb
|
|
221
231
|
- lib/cloudflare/email/provider_metadata.rb
|
|
@@ -256,23 +266,29 @@ files:
|
|
|
256
266
|
- templates/deploy-to-cloudflare/.gitignore
|
|
257
267
|
- templates/deploy-to-cloudflare/LICENSE.txt
|
|
258
268
|
- templates/deploy-to-cloudflare/README.md
|
|
269
|
+
- templates/deploy-to-cloudflare/docs/domain-setup.md
|
|
259
270
|
- templates/deploy-to-cloudflare/package-lock.json
|
|
260
271
|
- templates/deploy-to-cloudflare/package.json
|
|
272
|
+
- templates/deploy-to-cloudflare/scripts/check-subdomains.mjs
|
|
261
273
|
- templates/deploy-to-cloudflare/src/index.js
|
|
262
274
|
- templates/deploy-to-cloudflare/test/durable.test.ts
|
|
263
275
|
- templates/deploy-to-cloudflare/test/index.test.ts
|
|
264
276
|
- templates/deploy-to-cloudflare/test/relay.test.ts
|
|
277
|
+
- templates/deploy-to-cloudflare/test/subdomains.test.ts
|
|
265
278
|
- templates/deploy-to-cloudflare/vitest.config.ts
|
|
266
279
|
- templates/deploy-to-cloudflare/wrangler.toml
|
|
267
280
|
- templates/worker/README.md
|
|
281
|
+
- templates/worker/docs/domain-setup.md
|
|
268
282
|
- templates/worker/docs/durable-inbound.md
|
|
269
283
|
- templates/worker/package-lock.json
|
|
270
284
|
- templates/worker/package.json
|
|
285
|
+
- templates/worker/scripts/check-subdomains.mjs
|
|
271
286
|
- templates/worker/scripts/wrangler.mjs
|
|
272
287
|
- templates/worker/src/index.js
|
|
273
288
|
- templates/worker/test/durable.test.ts
|
|
274
289
|
- templates/worker/test/index.test.ts
|
|
275
290
|
- templates/worker/test/relay.test.ts
|
|
291
|
+
- templates/worker/test/subdomains.test.ts
|
|
276
292
|
- templates/worker/vitest.config.ts
|
|
277
293
|
- templates/worker/wrangler.toml
|
|
278
294
|
homepage: https://github.com/cole-robertson/cloudflare-email
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
<div class="page-heading">
|
|
2
|
-
<div>
|
|
3
|
-
<p class="eyebrow">Email workspace</p>
|
|
4
|
-
<h1>Mailboxes</h1>
|
|
5
|
-
<p class="muted">Manage accepted addresses and the messages delivered to them.</p>
|
|
6
|
-
</div>
|
|
7
|
-
</div>
|
|
8
|
-
|
|
9
|
-
<div class="workspace-grid">
|
|
10
|
-
<section class="panel" aria-labelledby="mailboxes-heading">
|
|
11
|
-
<div class="panel-heading"><h2 id="mailboxes-heading">Your mailboxes</h2></div>
|
|
12
|
-
<% if @mailboxes.empty? %>
|
|
13
|
-
<div class="empty-state">
|
|
14
|
-
<h3>No mailboxes to show</h3>
|
|
15
|
-
<p>Create a mailbox to give incoming email a home.</p>
|
|
16
|
-
</div>
|
|
17
|
-
<% else %>
|
|
18
|
-
<ul class="record-list">
|
|
19
|
-
<% @mailboxes.each do |mailbox| %>
|
|
20
|
-
<li class="record-row">
|
|
21
|
-
<div class="record-content">
|
|
22
|
-
<% if allowed?(:show, mailbox) %>
|
|
23
|
-
<%= link_to mailbox.name, mailbox_path(mailbox.id), class: "record-title" %>
|
|
24
|
-
<% else %>
|
|
25
|
-
<span class="record-title"><%= mailbox.name %></span>
|
|
26
|
-
<% end %>
|
|
27
|
-
<p class="muted small">Mailbox #<%= mailbox.id %></p>
|
|
28
|
-
</div>
|
|
29
|
-
<span class="badge"><%= mailbox.state.capitalize %></span>
|
|
30
|
-
</li>
|
|
31
|
-
<% end %>
|
|
32
|
-
</ul>
|
|
33
|
-
<% end %>
|
|
34
|
-
<% if @next_cursor.present? %>
|
|
35
|
-
<nav class="pagination" aria-label="Mailbox pages">
|
|
36
|
-
<%= link_to "Next mailboxes →", root_path(after: @next_cursor), class: "button button-secondary" %>
|
|
37
|
-
</nav>
|
|
38
|
-
<% end %>
|
|
39
|
-
</section>
|
|
40
|
-
|
|
41
|
-
<% if allowed?(:create) %>
|
|
42
|
-
<section class="panel" aria-labelledby="create-heading">
|
|
43
|
-
<div class="panel-heading"><h2 id="create-heading">Create a mailbox</h2></div>
|
|
44
|
-
<div class="panel-body">
|
|
45
|
-
<% if @domains.empty? %>
|
|
46
|
-
<p class="muted">An administrator needs to register an active receiving domain before you can create a mailbox.</p>
|
|
47
|
-
<% else %>
|
|
48
|
-
<%= form_with url: mailboxes_path, scope: :mailbox, local: true, class: "stack" do |form| %>
|
|
49
|
-
<div class="field">
|
|
50
|
-
<%= form.label :name, "Mailbox name" %>
|
|
51
|
-
<%= form.text_field :name, required: true, maxlength: 255, placeholder: "Customer support" %>
|
|
52
|
-
</div>
|
|
53
|
-
<div class="field">
|
|
54
|
-
<%= form.label :address, "Email address" %>
|
|
55
|
-
<%= form.email_field :address, required: true, maxlength: 254, placeholder: "support@#{@domains.first}", aria: { describedby: "create-domains" } %>
|
|
56
|
-
<p id="create-domains" class="field-help">Available domains: <%= @domains.join(", ") %></p>
|
|
57
|
-
</div>
|
|
58
|
-
<p class="field-help">New addresses start pending. Your administrator must confirm their routing before they can receive mail.</p>
|
|
59
|
-
<%= form.submit "Create mailbox", class: "button" %>
|
|
60
|
-
<% end %>
|
|
61
|
-
<% end %>
|
|
62
|
-
</div>
|
|
63
|
-
</section>
|
|
64
|
-
<% end %>
|
|
65
|
-
</div>
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
|
2
|
-
<%= link_to "Mailboxes", root_path %> <span aria-hidden="true">/</span>
|
|
3
|
-
<%= link_to @mailbox.name, mailbox_path(@mailbox.id) %> <span aria-hidden="true">/</span>
|
|
4
|
-
<span>Message</span>
|
|
5
|
-
</nav>
|
|
6
|
-
<div class="page-heading">
|
|
7
|
-
<div><p class="eyebrow">Received message</p><h1><%= @subject.presence || "(No subject)" %></h1></div>
|
|
8
|
-
<div class="actions">
|
|
9
|
-
<% if allowed?(:mark_read, @mailbox) %>
|
|
10
|
-
<%= button_to(@entry.read_at ? "Mark unread" : "Mark read", mark_read_mailbox_path(@mailbox.id), method: :post, params: { message_id: @entry.id, read: @entry.read_at ? "false" : "true" }, class: "button button-secondary") %>
|
|
11
|
-
<% end %>
|
|
12
|
-
<% if allowed?(:archive, @mailbox) %>
|
|
13
|
-
<%= button_to(@entry.archived_at ? "Unarchive" : "Archive", archive_mailbox_path(@mailbox.id), method: :post, params: { message_id: @entry.id, archived: @entry.archived_at ? "false" : "true" }, class: "button button-secondary") %>
|
|
14
|
-
<% end %>
|
|
15
|
-
</div>
|
|
16
|
-
</div>
|
|
17
|
-
<article class="panel" aria-label="Email message">
|
|
18
|
-
<div class="panel-body">
|
|
19
|
-
<dl class="message-metadata">
|
|
20
|
-
<dt>From</dt><dd><%= @from.presence || "(Unknown sender)" %></dd>
|
|
21
|
-
<dt>To</dt><dd><%= @entry.recipient %></dd>
|
|
22
|
-
<dt>Received</dt><dd><time datetime="<%= @entry.created_at.iso8601 %>"><%= @entry.created_at.utc.strftime("%b %-d, %Y · %H:%M UTC") %></time></dd>
|
|
23
|
-
<dt>Status</dt><dd><%= @entry.read_at ? "Read" : "Unread" %><%= " · Archived" if @entry.archived_at %></dd>
|
|
24
|
-
</dl>
|
|
25
|
-
</div>
|
|
26
|
-
<div class="message-body"><pre><%= @body.presence || "No text content is available for this message." %></pre></div>
|
|
27
|
-
<% if @attachments.any? %>
|
|
28
|
-
<section class="panel-body attachments" aria-labelledby="attachments-heading">
|
|
29
|
-
<h2 id="attachments-heading">Attachments</h2>
|
|
30
|
-
<p class="field-help">File names are shown for reference. Downloads are not available in this dashboard.</p>
|
|
31
|
-
<ul><% @attachments.each do |filename| %><li><%= filename.presence || "Unnamed attachment" %></li><% end %></ul>
|
|
32
|
-
</section>
|
|
33
|
-
<% end %>
|
|
34
|
-
</article>
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
<nav class="breadcrumbs" aria-label="Breadcrumb"><%= link_to "Mailboxes", root_path %> <span aria-hidden="true">/</span> <span><%= @mailbox.name %></span></nav>
|
|
2
|
-
<div class="page-heading">
|
|
3
|
-
<div>
|
|
4
|
-
<p class="eyebrow">Mailbox</p>
|
|
5
|
-
<h1><%= @mailbox.name %></h1>
|
|
6
|
-
<p class="muted"><%= @mailbox.state == "suspended" ? "Receiving is paused for every address in this mailbox." : "Addresses receive mail when both their address and domain are active." %></p>
|
|
7
|
-
</div>
|
|
8
|
-
<div class="actions">
|
|
9
|
-
<span class="badge"><%= @mailbox.state.capitalize %></span>
|
|
10
|
-
<% if @mailbox.state == "active" && allowed?(:suspend, @mailbox) %>
|
|
11
|
-
<%= button_to "Suspend mailbox", suspend_mailbox_path(@mailbox.id), method: :post, class: "button button-secondary" %>
|
|
12
|
-
<% elsif @mailbox.state == "suspended" && allowed?(:resume, @mailbox) %>
|
|
13
|
-
<%= button_to "Resume mailbox", resume_mailbox_path(@mailbox.id), method: :post, class: "button" %>
|
|
14
|
-
<% end %>
|
|
15
|
-
</div>
|
|
16
|
-
</div>
|
|
17
|
-
|
|
18
|
-
<div class="workspace-grid">
|
|
19
|
-
<section class="panel" aria-labelledby="messages-heading">
|
|
20
|
-
<div class="panel-heading"><h2 id="messages-heading">Received messages</h2><p class="muted small">Includes archived messages</p></div>
|
|
21
|
-
<% if @messages.empty? %>
|
|
22
|
-
<div class="empty-state"><h3>No messages to show</h3><p>Messages appear here after delivery to an active address.</p></div>
|
|
23
|
-
<% else %>
|
|
24
|
-
<ul class="record-list">
|
|
25
|
-
<% @messages.each do |entry| %>
|
|
26
|
-
<li class="record-row">
|
|
27
|
-
<div class="record-content">
|
|
28
|
-
<% if allowed?(:show_message, @mailbox) %>
|
|
29
|
-
<%= link_to "Message to #{entry.recipient}", message_mailbox_path(@mailbox.id, message_id: entry.id), class: "record-title" %>
|
|
30
|
-
<% else %>
|
|
31
|
-
<span class="record-title">Message to <%= entry.recipient %></span>
|
|
32
|
-
<% end %>
|
|
33
|
-
<p class="muted small"><time datetime="<%= entry.created_at.iso8601 %>"><%= entry.created_at.utc.strftime("%b %-d, %Y · %H:%M UTC") %></time></p>
|
|
34
|
-
</div>
|
|
35
|
-
<div class="status-list">
|
|
36
|
-
<span class="badge"><%= entry.read_at ? "Read" : "Unread" %></span>
|
|
37
|
-
<% if entry.archived_at %><span class="badge">Archived</span><% end %>
|
|
38
|
-
</div>
|
|
39
|
-
</li>
|
|
40
|
-
<% end %>
|
|
41
|
-
</ul>
|
|
42
|
-
<% end %>
|
|
43
|
-
<% if @next_cursor.present? %>
|
|
44
|
-
<nav class="pagination" aria-label="Message pages"><%= link_to "Next messages →", mailbox_path(@mailbox.id, after: @next_cursor), class: "button button-secondary" %></nav>
|
|
45
|
-
<% end %>
|
|
46
|
-
</section>
|
|
47
|
-
|
|
48
|
-
<div class="stack">
|
|
49
|
-
<section class="panel" aria-labelledby="addresses-heading">
|
|
50
|
-
<div class="panel-heading"><h2 id="addresses-heading">Addresses</h2></div>
|
|
51
|
-
<ul class="record-list">
|
|
52
|
-
<% @addresses.each do |address| %>
|
|
53
|
-
<% receiving = @mailbox.state == "active" && address.state == "active" && address.receiving_domain&.state == "active" %>
|
|
54
|
-
<li class="record-row address-row">
|
|
55
|
-
<span class="record-title"><%= address.address %></span>
|
|
56
|
-
<% if address.has_attribute?(:catch_all) && address[:catch_all] %>
|
|
57
|
-
<span class="badge">Domain catch-all configured</span>
|
|
58
|
-
<% end %>
|
|
59
|
-
<span class="badge"><%= receiving ? "Accepting mail" : address.state == "pending" ? "Pending setup" : "Receiving paused" %></span>
|
|
60
|
-
</li>
|
|
61
|
-
<% end %>
|
|
62
|
-
</ul>
|
|
63
|
-
<div class="panel-body"><p class="field-help">Pending addresses need routing confirmation from your administrator. Resuming a mailbox preserves each address’s existing setup state.</p></div>
|
|
64
|
-
</section>
|
|
65
|
-
|
|
66
|
-
<% if allowed?(:add_address, @mailbox) %>
|
|
67
|
-
<section class="panel" aria-labelledby="alias-heading">
|
|
68
|
-
<div class="panel-heading"><h2 id="alias-heading">Add an alias</h2></div>
|
|
69
|
-
<div class="panel-body">
|
|
70
|
-
<% if @domains.empty? %>
|
|
71
|
-
<p class="muted">No active receiving domains are available. Contact your administrator.</p>
|
|
72
|
-
<% else %>
|
|
73
|
-
<%= form_with url: aliases_mailbox_path(@mailbox.id), local: true, class: "stack" do |form| %>
|
|
74
|
-
<div class="field">
|
|
75
|
-
<%= form.label :address, "Alias email address" %>
|
|
76
|
-
<%= form.email_field :address, required: true, maxlength: 254, aria: { describedby: "alias-domains" } %>
|
|
77
|
-
<p id="alias-domains" class="field-help">Available domains: <%= @domains.join(", ") %>. Messages to this alias will appear in this mailbox.</p>
|
|
78
|
-
</div>
|
|
79
|
-
<%= form.submit "Add alias", class: "button" %>
|
|
80
|
-
<% end %>
|
|
81
|
-
<% end %>
|
|
82
|
-
</div>
|
|
83
|
-
</section>
|
|
84
|
-
<% end %>
|
|
85
|
-
</div>
|
|
86
|
-
</div>
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<title>Mailbox management</title>
|
|
7
|
-
<%= csrf_meta_tags %>
|
|
8
|
-
<%= stylesheet_link_tag style_path %>
|
|
9
|
-
</head>
|
|
10
|
-
<body>
|
|
11
|
-
<a class="skip-link" href="#main">Skip to content</a>
|
|
12
|
-
<header class="site-header">
|
|
13
|
-
<div class="shell header-content">
|
|
14
|
-
<%= link_to "Mailbox management", root_path, class: "brand" %>
|
|
15
|
-
<span class="header-caption">Cloudflare Email</span>
|
|
16
|
-
<% if host_back_path %><%= link_to "Back to app", host_back_path %><% end %>
|
|
17
|
-
</div>
|
|
18
|
-
</header>
|
|
19
|
-
<main id="main" class="shell" tabindex="-1">
|
|
20
|
-
<% if flash[:notice].present? %>
|
|
21
|
-
<div class="notice" role="status"><%= flash[:notice] %></div>
|
|
22
|
-
<% end %>
|
|
23
|
-
<% if flash[:alert].present? %>
|
|
24
|
-
<div class="notice notice-error" role="alert"><%= flash[:alert] %></div>
|
|
25
|
-
<% end %>
|
|
26
|
-
<%= yield %>
|
|
27
|
-
</main>
|
|
28
|
-
</body>
|
|
29
|
-
</html>
|