cloudflare-email 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -1
- data/README.md +63 -369
- data/SECURITY.md +1 -9
- data/app/controllers/cloudflare/email/ingress_controller.rb +1 -2
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +2 -166
- data/app/controllers/cloudflare/email/management/styles_controller.rb +2 -6
- data/docs/activerecord-tenanted.md +0 -11
- data/docs/custom-ingress.md +6 -16
- data/docs/delivery-events.md +2 -5
- data/docs/features.md +49 -123
- data/docs/getting-started.md +91 -172
- data/docs/mailboxes.md +27 -56
- data/docs/management-engine.md +1 -3
- data/docs/outbox.md +5 -6
- data/docs/reference.md +190 -0
- data/docs/routing-diagnostics.md +7 -2
- data/docs/thread-correlation.md +2 -4
- data/docs/troubleshooting.md +4 -4
- data/lib/cloudflare/email/active_record/base.rb +3 -53
- data/lib/cloudflare/email/client.rb +23 -5
- data/lib/cloudflare/email/engine.rb +1 -13
- data/lib/cloudflare/email/envelope.rb +2 -12
- data/lib/cloudflare/email/error.rb +5 -12
- data/lib/cloudflare/email/ingress.rb +19 -8
- data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
- data/lib/cloudflare/email/mailboxes/models.rb +8 -147
- data/lib/cloudflare/email/mailboxes/service.rb +7 -258
- data/lib/cloudflare/email/mailboxes.rb +1 -0
- data/lib/cloudflare/email/management/adapter.rb +3 -25
- data/lib/cloudflare/email/management/configuration.rb +4 -11
- data/lib/cloudflare/email/management/engine.rb +2 -0
- data/lib/cloudflare/email/tenancy.rb +2 -73
- data/lib/cloudflare/email/tenant_job_context.rb +2 -85
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare-email.rb +6 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +1 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
- data/templates/deploy-to-cloudflare/README.md +25 -7
- data/templates/deploy-to-cloudflare/docs/domain-setup.md +118 -0
- data/templates/deploy-to-cloudflare/package.json +1 -0
- data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
- data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
- data/templates/worker/README.md +11 -7
- data/templates/worker/docs/domain-setup.md +118 -0
- data/templates/worker/docs/durable-inbound.md +4 -16
- data/templates/worker/package.json +1 -0
- data/templates/worker/scripts/check-subdomains.mjs +54 -0
- data/templates/worker/test/subdomains.test.ts +51 -0
- metadata +22 -21
- data/app/views/cloudflare/email/management/mailboxes/index.html.erb +0 -65
- data/app/views/cloudflare/email/management/mailboxes/message.html.erb +0 -34
- data/app/views/cloudflare/email/management/mailboxes/show.html.erb +0 -86
- data/app/views/layouts/cloudflare/email/management.html.erb +0 -29
- data/docs/architecture.md +0 -105
- data/docs/tenant-mailboxes-plan.md +0 -149
- data/docs/upgrading-0.2.md +0 -101
- data/docs/upgrading-0.3.md +0 -55
- data/docs/verification/2026-09-10-followup.md +0 -49
- data/docs/verification/2026-09-10-install-upgrade.md +0 -86
- data/docs/verification/2026-09-10-live.md +0 -102
- data/docs/verification/2026-09-10.md +0 -175
- data/docs/verification/2026-09-11-custom-ingress.md +0 -49
- data/docs/verification/2026-09-11-extraction.md +0 -32
- data/docs/verification/2026-09-11-outbound-ledger.md +0 -60
- data/docs/verification/2026-09-11-security.md +0 -76
- data/docs/verification/2026-09-11-tenant-mailboxes.md +0 -75
- data/docs/verification/2026-09-12-destination-context.md +0 -46
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +0 -71
- data/lib/cloudflare/email/management/management.css +0 -68
|
@@ -5,12 +5,6 @@ The bundled Worker uses R2 storage, Queue delivery and scheduled recovery by
|
|
|
5
5
|
default. No enable flag, Rails model, database, or tenancy change is needed.
|
|
6
6
|
Missing infrastructure fails visibly; it never silently downgrades to direct delivery.
|
|
7
7
|
|
|
8
|
-
Upgrade the Rails gem before deploying this Worker. New messages without a parseable
|
|
9
|
-
Message-ID use a deterministic fallback for deduplication across Rails hosts.
|
|
10
|
-
Older records created before this change use ActionMailbox's hostname-dependent
|
|
11
|
-
fallback; replaying those historical messages after upgrading can create a new
|
|
12
|
-
record. Reconcile any existing archive backlog before replaying it automatically.
|
|
13
|
-
|
|
14
8
|
## Set it up
|
|
15
9
|
|
|
16
10
|
Create a private R2 bucket and a Queue for each environment:
|
|
@@ -23,8 +17,7 @@ npx wrangler queues create cloudflare-email-inbound-production
|
|
|
23
17
|
The bundled `wrangler.toml` already configures these resources for each environment.
|
|
24
18
|
Customize their names if needed. Configure **all three** pieces: the `INBOUND_EMAIL_STORE` R2
|
|
25
19
|
binding, `INBOUND_EMAIL_QUEUE` producer and consumer, and the once-per-minute
|
|
26
|
-
scheduled trigger. Keep `RAILS_INGRESS_URL` and `INGRESS_SECRET` configured
|
|
27
|
-
before. Deploy with `npm run deploy -- --env production`.
|
|
20
|
+
scheduled trigger. Keep `RAILS_INGRESS_URL` and `INGRESS_SECRET` configured. Deploy with `npm run deploy -- --env production`.
|
|
28
21
|
|
|
29
22
|
Use separate buckets, queues and secrets for staging and production. Do not add
|
|
30
23
|
an R2 lifecycle rule that expires `cloudflare-email/pending/` objects. Pending
|
|
@@ -38,7 +31,7 @@ binding, and minute schedule before changing code or secrets. This check does no
|
|
|
38
31
|
validate the bucket lifecycle or queue consumer: verify those in your infrastructure
|
|
39
32
|
configuration and live drill. The Ruby task does not provision resources.
|
|
40
33
|
|
|
41
|
-
## Direct fallback
|
|
34
|
+
## Direct fallback
|
|
42
35
|
|
|
43
36
|
Set `INBOUND_DELIVERY_MODE = "direct"` in the environment's Wrangler vars only when
|
|
44
37
|
you deliberately want a single HTTP handoff. With the Ruby task, pass
|
|
@@ -49,15 +42,10 @@ recovery continue draining previously retained mail. To return to durable delive
|
|
|
49
42
|
remove the Wrangler override or pass `INBOUND_DELIVERY_MODE=durable` to the Ruby task.
|
|
50
43
|
Unknown values fail rather than choosing a transport implicitly.
|
|
51
44
|
|
|
52
|
-
The old `DURABLE_INBOUND_ENABLED` rollout flag has been removed. Existing direct
|
|
53
|
-
installations must provision the resources before upgrading the Worker, or explicitly
|
|
54
|
-
select `INBOUND_DELIVERY_MODE=direct` before deploying. Upgrading the Ruby gem alone
|
|
55
|
-
does not redeploy a Worker or change live routing.
|
|
56
|
-
|
|
57
45
|
## What happens to a message
|
|
58
46
|
|
|
59
47
|
1. The Worker validates the envelope and actual MIME size. Invalid or oversized
|
|
60
|
-
mail is permanently rejected
|
|
48
|
+
mail is permanently rejected.
|
|
61
49
|
2. It writes one R2 object containing the exact raw bytes, SMTP envelope, original
|
|
62
50
|
receive time, and optional provider metadata. It waits for the write before
|
|
63
51
|
returning from the Email Worker handler.
|
|
@@ -129,7 +117,7 @@ callback is not canceled. Set `archiveTimeoutMs` to an integer from 1 to 120,000
|
|
|
129
117
|
milliseconds to fit your host's runtime budget. Treat bytes as read-only and never parse the private
|
|
130
118
|
pending frame to build an archive. The primary R2 write is always mandatory.
|
|
131
119
|
Trusted provider metadata is captured once at receipt, never recomputed on replay.
|
|
132
|
-
The
|
|
120
|
+
The `relayEmail` archive callback is best effort and does not enable this
|
|
133
121
|
durable protocol automatically.
|
|
134
122
|
|
|
135
123
|
## Guarantee boundary
|
|
@@ -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.1
|
|
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,12 +155,7 @@ 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
|
-
- docs/architecture.md
|
|
150
159
|
- docs/custom-ingress.md
|
|
151
160
|
- docs/delivery-events.md
|
|
152
161
|
- docs/features.md
|
|
@@ -154,24 +163,11 @@ files:
|
|
|
154
163
|
- docs/mailboxes.md
|
|
155
164
|
- docs/management-engine.md
|
|
156
165
|
- docs/outbox.md
|
|
166
|
+
- docs/reference.md
|
|
157
167
|
- docs/routing-deliveries.md
|
|
158
168
|
- docs/routing-diagnostics.md
|
|
159
|
-
- docs/tenant-mailboxes-plan.md
|
|
160
169
|
- docs/thread-correlation.md
|
|
161
170
|
- docs/troubleshooting.md
|
|
162
|
-
- docs/upgrading-0.2.md
|
|
163
|
-
- docs/upgrading-0.3.md
|
|
164
|
-
- docs/verification/2026-09-10-followup.md
|
|
165
|
-
- docs/verification/2026-09-10-install-upgrade.md
|
|
166
|
-
- docs/verification/2026-09-10-live.md
|
|
167
|
-
- docs/verification/2026-09-10.md
|
|
168
|
-
- docs/verification/2026-09-11-custom-ingress.md
|
|
169
|
-
- docs/verification/2026-09-11-extraction.md
|
|
170
|
-
- docs/verification/2026-09-11-outbound-ledger.md
|
|
171
|
-
- docs/verification/2026-09-11-security.md
|
|
172
|
-
- docs/verification/2026-09-11-tenant-mailboxes.md
|
|
173
|
-
- docs/verification/2026-09-12-destination-context.md
|
|
174
|
-
- docs/verification/2026-09-13-durable-inbound-and-routing.md
|
|
175
171
|
- examples/plain_ruby.rb
|
|
176
172
|
- lib/cloudflare-email.rb
|
|
177
173
|
- lib/cloudflare/email/active_record.rb
|
|
@@ -215,7 +211,6 @@ files:
|
|
|
215
211
|
- lib/cloudflare/email/management/adapter.rb
|
|
216
212
|
- lib/cloudflare/email/management/configuration.rb
|
|
217
213
|
- lib/cloudflare/email/management/engine.rb
|
|
218
|
-
- lib/cloudflare/email/management/management.css
|
|
219
214
|
- lib/cloudflare/email/management/routes.rb
|
|
220
215
|
- lib/cloudflare/email/message_id.rb
|
|
221
216
|
- lib/cloudflare/email/provider_metadata.rb
|
|
@@ -256,23 +251,29 @@ files:
|
|
|
256
251
|
- templates/deploy-to-cloudflare/.gitignore
|
|
257
252
|
- templates/deploy-to-cloudflare/LICENSE.txt
|
|
258
253
|
- templates/deploy-to-cloudflare/README.md
|
|
254
|
+
- templates/deploy-to-cloudflare/docs/domain-setup.md
|
|
259
255
|
- templates/deploy-to-cloudflare/package-lock.json
|
|
260
256
|
- templates/deploy-to-cloudflare/package.json
|
|
257
|
+
- templates/deploy-to-cloudflare/scripts/check-subdomains.mjs
|
|
261
258
|
- templates/deploy-to-cloudflare/src/index.js
|
|
262
259
|
- templates/deploy-to-cloudflare/test/durable.test.ts
|
|
263
260
|
- templates/deploy-to-cloudflare/test/index.test.ts
|
|
264
261
|
- templates/deploy-to-cloudflare/test/relay.test.ts
|
|
262
|
+
- templates/deploy-to-cloudflare/test/subdomains.test.ts
|
|
265
263
|
- templates/deploy-to-cloudflare/vitest.config.ts
|
|
266
264
|
- templates/deploy-to-cloudflare/wrangler.toml
|
|
267
265
|
- templates/worker/README.md
|
|
266
|
+
- templates/worker/docs/domain-setup.md
|
|
268
267
|
- templates/worker/docs/durable-inbound.md
|
|
269
268
|
- templates/worker/package-lock.json
|
|
270
269
|
- templates/worker/package.json
|
|
270
|
+
- templates/worker/scripts/check-subdomains.mjs
|
|
271
271
|
- templates/worker/scripts/wrangler.mjs
|
|
272
272
|
- templates/worker/src/index.js
|
|
273
273
|
- templates/worker/test/durable.test.ts
|
|
274
274
|
- templates/worker/test/index.test.ts
|
|
275
275
|
- templates/worker/test/relay.test.ts
|
|
276
|
+
- templates/worker/test/subdomains.test.ts
|
|
276
277
|
- templates/worker/vitest.config.ts
|
|
277
278
|
- templates/worker/wrangler.toml
|
|
278
279
|
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>
|
data/docs/architecture.md
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
# Gem and inbox responsibilities
|
|
2
|
-
|
|
3
|
-
The Cloudflare gem supplies email transport and reusable delivery infrastructure.
|
|
4
|
-
The inbox application supplies a product built on that infrastructure. Plain Ruby
|
|
5
|
-
users should not need Rails or a database. Rails users should be able to opt into
|
|
6
|
-
durable infrastructure without copying the reference inbox's models and services.
|
|
7
|
-
|
|
8
|
-
## Implemented in this extraction
|
|
9
|
-
|
|
10
|
-
| Gem capability | Application responsibility |
|
|
11
|
-
| --- | --- |
|
|
12
|
-
| Optional mailbox/address directory and tenant connection adapter | Authorize domain ownership, provision tenant databases and grant user access |
|
|
13
|
-
| Mailbox memberships, read/archive state and retained raw mail | Inbox UI, retention schedule and explicit purge policy |
|
|
14
|
-
| Shared event intake, tenant correlation and tenant-aware jobs | Durable queue workers, scheduled recovery and schema rollout to every tenant |
|
|
15
|
-
| Structured/raw sending and ActionMailer transport | Compose UI, recipients and send authorization |
|
|
16
|
-
| `Response#accepted?`, plus individual recipient outcomes | Handle partial acceptance without resending accepted recipients |
|
|
17
|
-
| Authenticated ingress (default v2; opt-in v3 [metadata](custom-ingress.md)) and context-scoped deduplication | Map trusted recipient to an authorized mailbox; apply sender and document policy |
|
|
18
|
-
| `MessageId.normalize` and returned provider IDs | Store conversation membership and scope reply lookups |
|
|
19
|
-
| Queue decoding, validation and ACK | Queue/subscription setup and recurring execution |
|
|
20
|
-
| Optional ActiveRecord event receipts, deduplication and indexed replay | Configure queue polling and receipt retention |
|
|
21
|
-
| Immutable outbox snapshots, send claims, per-recipient outcomes | Authorize sending and choose a stable operation key |
|
|
22
|
-
| `DeliveryEvents` account/message/recipient correlation and ordering | Transactional callback to update product records |
|
|
23
|
-
| Send/replay jobs, recovery tasks and audited reconciliation | Authorize operators and provide evidence of provider outcome |
|
|
24
|
-
|
|
25
|
-
The ActiveRecord adapter is explicit opt-in. Its generator installs a receipt table
|
|
26
|
-
and initializer; the default plain Ruby client does not load ActiveRecord. Its
|
|
27
|
-
handler applies application writes on the same database connection as the receipt.
|
|
28
|
-
It does not wrap HTTP calls or other external effects in a database transaction.
|
|
29
|
-
|
|
30
|
-
Version 0.2 is a preproduction break: v1 ingress and signed Message-ID tokens are
|
|
31
|
-
removed. Cloudflare's documented queue encodings and recipient-response forms are
|
|
32
|
-
provider contracts, so accepting them is not obsolete application compatibility.
|
|
33
|
-
Historical verification reports describe their dated runs, not the current API.
|
|
34
|
-
|
|
35
|
-
## Implemented: durable outbound delivery
|
|
36
|
-
|
|
37
|
-
The inbox now uses the gem's ledger tables and orchestration. Its model subclasses
|
|
38
|
-
add application message associations and display snapshots; they do not maintain
|
|
39
|
-
a second sending ledger. See the [outbox guide](outbox.md) for installation and API.
|
|
40
|
-
|
|
41
|
-
1. Claim an application-supplied operation key scoped to an account using a unique
|
|
42
|
-
index before sending; repeated or concurrent calls cannot silently resend.
|
|
43
|
-
2. Persist immutable rendered MIME and envelope recipients before the network
|
|
44
|
-
request. Track each recipient separately, including partial acceptance.
|
|
45
|
-
3. Separate preparation failures from ambiguous outcomes. A timeout, process death
|
|
46
|
-
or persistence failure after acceptance keeps the operation blocked. An elapsed
|
|
47
|
-
timeout alone never proves the message was not sent.
|
|
48
|
-
4. Persist validated provider IDs and recipient acceptance outcomes separately
|
|
49
|
-
from later lifecycle events. Replay correlates account, message and recipient;
|
|
50
|
-
conflicting IDs remain uncertain. The network and database are not atomic.
|
|
51
|
-
5. Expose audited reconciliation with caller-supplied actor/reason and evidence.
|
|
52
|
-
The application authorizes the operator; the gem enforces legal transitions.
|
|
53
|
-
6. Provide jobs/tasks and notifications that preserve the same claim on retries.
|
|
54
|
-
Include migration/import support for the reference inbox's existing attempts.
|
|
55
|
-
|
|
56
|
-
The suite exercises concurrent senders, actual process termination, acceptance
|
|
57
|
-
followed by persistence failure, partial recipients, event-before-response,
|
|
58
|
-
out-of-order events, callbacks that roll back, and recovery without a duplicate
|
|
59
|
-
send. PostgreSQL and SQLite run separate concurrency checks. Inbox HTTP and browser
|
|
60
|
-
workflows use the shared implementation, with controlled local provider responses.
|
|
61
|
-
|
|
62
|
-
Historic attempts did not preserve rendered MIME. Their import retains available
|
|
63
|
-
bodies, IDs, outcomes and operator evidence and marks reconstructed snapshots.
|
|
64
|
-
Unknown historical outcomes remain blocked. No migration claims to recreate the
|
|
65
|
-
exact bytes of a previously sent message.
|
|
66
|
-
|
|
67
|
-
## Operational completion
|
|
68
|
-
|
|
69
|
-
The reference inbox supplies a functional mailbox product over these APIs. Before
|
|
70
|
-
production use, configure sending DNS and receiving routes, queue subscriptions
|
|
71
|
-
and dead-letter handling, durable Rails jobs, receipt/MIME retention and backups,
|
|
72
|
-
and monitoring for prepared/sending/unknown/partial operations. Exercise live
|
|
73
|
-
external mailbox delivery and recovery with the deployed revision. Installation
|
|
74
|
-
does not automatically provision domains or deploy infrastructure.
|
|
75
|
-
|
|
76
|
-
Current limitations are deliberate and visible: no provider exactly-once API,
|
|
77
|
-
no automatic resend of uncertain or rejected operations, no multi-provider-ID
|
|
78
|
-
operation (conflicting IDs require review). Direct inbound forwarding can reject
|
|
79
|
-
mail during a Rails outage. The default [durable Worker path](../templates/worker/README.md#durable-inbound-delivery)
|
|
80
|
-
commits raw mail and stable envelope context to R2 before returning successfully.
|
|
81
|
-
Queue handoffs and a scheduled recovery pass retry pending mail; a Rails 2xx
|
|
82
|
-
allows removal from the pending store. Queue expiration does not remove R2 mail.
|
|
83
|
-
This boundary requires durable Rails persistence before 2xx, idempotent replay,
|
|
84
|
-
private R2 storage without expiration of pending objects, and operational
|
|
85
|
-
monitoring. It cannot guarantee acceptance when Cloudflare itself cannot store
|
|
86
|
-
the incoming message.
|
|
87
|
-
A prepared operation can be dispatched using its existing identity after a job
|
|
88
|
-
enqueue failure; sending/unknown operations require evidence-based reconciliation.
|
|
89
|
-
Notifications indicate method execution and may run inside an outer transaction;
|
|
90
|
-
the durable database record is authoritative.
|
|
91
|
-
|
|
92
|
-
## Keep in the inbox product
|
|
93
|
-
|
|
94
|
-
Users, tenancy, mailbox ownership, conversations, folders, search and UI remain in
|
|
95
|
-
the inbox. AI context, review flags, approval thresholds, auto-send policy and
|
|
96
|
-
operator permissions also stay there. Suppression/unsubscribe requirements depend
|
|
97
|
-
on message purpose and application policy; expose provider outcomes, but do not
|
|
98
|
-
silently impose a marketing subscription model on transactional mail.
|
|
99
|
-
|
|
100
|
-
The optional [mailbox module](mailboxes.md) now packages mailbox persistence and
|
|
101
|
-
address management over the existing transport/receipt APIs. It includes a
|
|
102
|
-
[tenant adapter](activerecord-tenanted.md), without adding a tenant library to
|
|
103
|
-
the plain Ruby client. The optional [management engine](management-engine.md)
|
|
104
|
-
consumes these same APIs with host-supplied authentication and authorization.
|
|
105
|
-
Full inbox products can continue using them through their own frontends.
|