Skip to content

Commit 36a3697

Browse files
Brooooooklynclaude
andauthored
feat(email): let administrators add outbound-only custom domains (#551)
Fixes #510 ## What it does An admin can now register an apex whose mail lives with Microsoft 365 or Google Workspace, for **sending only**: ```sh void platform email domain-add brand.example --project hr-portal --outbound-only ``` ``` managed (today) outbound-only (new) ─────────────── ─────────────────── apex MX → Cloudflare Email Routing untouched (M365 / Workspace) catch-all → Void ingress Worker untouched ingress Worker deployed never deployed steps routing_enable … publish sending_onboard → publish receive mail yes no (inbound_status = disabled) send mail yes yes Sending refused → verified_only fallback → blocked, with the reason ``` ## Answers to the 4 questions 1. **Shape.** A column, `email_domain_routes.inbound_mode` (`managed` | `none`, default `managed`), plus a check that `none` is only allowed with `addressing = 'custom'`. `addressing` already plays the "route kind" role, so a second kind would duplicate it. 2. **Who.** Admin only. Owners get a 403 from the service. The owner CLI points them at the admin command when their apex already has foreign MX. 3. **DMARC/SPF.** They live together; Void never edits DMARC or SPF. Before onboarding, the step calls Cloudflare's read-only `POST /email/sending/subdomains/preview`. If it reports any real conflict (not just `*.missing`), the operation is **blocked** with a list (`dmarc.multiple at _dmarc.brand.example; …`). Once the admin fixes those records, `domain-sync` resumes. A domain that is already onboarded skips the preview. 4. **Subdomains.** Not in this PR; apex only. Subdomain senders need several routes per connection, so that is a follow-up. ``` domain-add --outbound-only │ ├─ admin? apex? not the shared zone? ── no ──▶ 4xx, no Cloudflare writes │ ▼ sending_onboard ├─ already onboarded? ─ yes ─▶ onboard (idempotent) ├─ preview conflicts? ─ yes ─▶ blocked + checklist ├─ onboard 4xx ─────────▶ blocked ("needs Workers Paid") ├─ onboard 5xx/408 ─────────▶ unknown (operator recovery, as today) ▼ publish ─▶ outbound ready, inbound disabled ``` ## Safety details - **Removal** keeps the usual step list, but `catchall_detach` and `ingress_delete` finish locally for outbound-only routes, so no Email Routing or Workers calls are made. The stored credential is still cleared, so a Sending-only token can finish removal. - **Mode switch** on an active route returns 409 ("remove it first"). The add dry-run says so (`plan: 'refuse'`). - **Race guard.** If the MX preflight was skipped because the route was active, and the route is no longer active by the time the transaction runs, the add returns 409 "retry" instead of reactivating it without an MX check. - **Status refresh** no longer overwrites a newer Sending result recorded by a setup step. Otherwise, on an outbound-only route, a stale `blocked` would stop all sending. - **Older servers.** The CLI aborts unless the dry-run echoes `target.inboundMode === 'none'`, so an old platform that ignores the flag cannot run full managed setup. - **Rollback.** `compatibility.json` lists 1.1.11 through 1.2.1 as rollback-safe (the column is additive and defaults to `managed`). `platform/meta/operations.md` warns not to run `domain-add` or `domain-sync` on outbound-only domains after a rollback below this release, because an older runtime would treat them as managed and take over the MX. - `domain-rotate-secret` returns 409 for these domains, in both the preview and the real request. ## Not verified live - That Cloudflare onboarding keeps a single existing `_dmarc` record instead of replacing it. The preview is Cloudflare's own check, but this still needs one run against a real test zone. - Removal still does not offboard Email Sending in Cloudflare. That was already true for managed domains and is unchanged here. ## Tests - `admin-upgrade-compatibility.test.ts`: 0061 upgrade from the 0060 schema, defaults, and the check constraint. - `email-connections.test.ts`: foreign-MX apex runs 2 steps with no inbound calls; preview conflict blocks, then resumes; 403 blocks (not `verified_only`), then resumes; managed still falls back; 504 stays unknown; refusals; removal makes no inbound calls. - `operator-email-domains.test.ts`: HTTP flag validation, dry-run text, blocked → sync → ready, status, previews, rotate refused, audit, remove; status-refresh race (confirmed to fail without the fix). - `email-admission.test.ts`: sending admitted, receipt refused. - `operator-cli.test.ts`, `email-cli-domain.test.ts`: flag parsing, old-server guard, owner hint. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent cd480dd commit 36a3697

26 files changed

Lines changed: 7951 additions & 75 deletions

‎docs/guide/email/domains.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ void email domain add acme.com
1616

1717
`add` opens a Cloudflare API-token template. Restrict the token to the selected account and mail zone before creating it; Workers Scripts permission applies across that account. The CLI accepts a masked paste or a newly copied token and asks you to confirm those restrictions. The platform encrypts the credential for the zone connection, so projects sharing that zone do not need separate ingress Workers.
1818

19-
Platform-managed custom inbound email requires the apex of a Cloudflare zone. Cloudflare’s catch-all only covers that apex, so a subdomain of the selected zone cannot receive arbitrary addresses. `add` refuses to register a new apex that already has MX records, or whose catch-all already routes elsewhere, so it never takes over mail hosted with another provider such as Microsoft 365 or Google Workspace. Use an apex that has no mail yet, or the platform’s shared mail address. Running `add` again for a registered apex, for example to renew its token, keeps working. Each custom apex belongs to one project; a project can register more than one apex.
19+
Platform-managed custom inbound email requires the apex of a Cloudflare zone. Cloudflare’s catch-all only covers that apex, so a subdomain of the selected zone cannot receive arbitrary addresses. `add` refuses to register a new apex that already has MX records, or whose catch-all already routes elsewhere, so it never takes over mail hosted with another provider such as Microsoft 365 or Google Workspace. Use an apex that has no mail yet, or the platform’s shared mail address. To only send from an apex that keeps its current mail provider, ask your platform administrator to register it as [outbound-only](../platform/administration/email.md#sending-from-a-domain-that-already-receives-mail). Running `add` again for a registered apex, for example to renew its token, keeps working. Each custom apex belongs to one project; a project can register more than one apex.
2020

2121
If setup is interrupted, use `void email domain status <domain>` to inspect the saved operation before retrying.
2222

‎docs/guide/platform/administration/email.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,19 @@ Run recovery with the same platform version that created the persisted intent.
150150
`applied` continues without replaying the write; `not-applied` permits that exact
151151
step to retry. Secret rotation keeps its staged generation, and removal resumes
152152
from the unresolved cleanup step. Age alone never authorizes a retry.
153+
154+
### Sending from a domain that already receives mail
155+
156+
If an apex already receives mail with another provider, such as Microsoft 365 or Google Workspace, register it for sending only:
157+
158+
```sh
159+
void platform email domain-add brand.example --project hr-portal --outbound-only
160+
```
161+
162+
The project can then send from any address on the apex, such as `support@brand.example`. It cannot receive mail there: the MX records, the apex SPF record, and incoming mail stay with the current provider.
163+
164+
- Cloudflare Email Sending must be available, which needs Workers Paid. Without it, setup is blocked instead of falling back to verified destinations only. Upgrade, then run `domain-sync`.
165+
- Email Sending adds its own records on `cf-bounce.<domain>` and `_dmarc.<domain>`. If Cloudflare reports that existing records conflict with them, for example several DMARC records, setup is blocked and the error lists each record. Fix them, then run `domain-sync`.
166+
- Only administrators can register an outbound-only domain, and it must be a zone apex. The platform's shared mail zone cannot be outbound-only.
167+
- `domain-rotate-secret` does not apply. `domain-remove` stops sending through the platform and leaves the MX records alone.
168+
- To switch an outbound-only domain to platform-managed inbound mail, or back, remove it first and add it again.

‎docs/reference/cli/platform-email.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,14 @@ Register and maintain email domains for projects whose owners hold no Cloudflare
6666

6767
```sh
6868
void platform email domains [--project <id|slug>]
69-
void platform email domain-add <domain> --project <id|slug> [--token-stdin]
69+
void platform email domain-add <domain> --project <id|slug> [--token-stdin] [--outbound-only]
7070
void platform email domain-status <domain>
7171
void platform email domain-sync <domain>
7272
void platform email domain-rotate-secret <domain>
7373
void platform email domain-remove <domain> [--token-stdin]
7474
```
7575

76-
`domain-add` uses the platform's Cloudflare credential for zones in its account. For another account, pipe a scoped Cloudflare API token on standard input with `--token-stdin --yes`. Managed custom inbound email requires the Cloudflare zone apex (`example.com`). If its MX records already belong to another mail provider, use the project's shared email address or choose another unused zone. Native Cloudflare deployments also support literal recipient addresses on subdomains; see [Your own Cloudflare account](../../guide/email/domains.md#your-own-cloudflare-account).
76+
`domain-add` uses the platform's Cloudflare credential for zones in its account. For another account, pipe a scoped Cloudflare API token on standard input with `--token-stdin --yes`. Managed custom inbound email requires the Cloudflare zone apex (`example.com`). If its MX records already belong to another mail provider, such as Microsoft 365 or Google Workspace, add `--outbound-only` to send from the apex while that provider keeps receiving its mail; see [Sending from a domain that already receives mail](../../guide/platform/administration/email.md#sending-from-a-domain-that-already-receives-mail). Otherwise use the project's shared email address or choose another unused zone. Native Cloudflare deployments also support literal recipient addresses on subdomains; see [Your own Cloudflare account](../../guide/email/domains.md#your-own-cloudflare-account).
7777

7878
`domain-status` shows inbound, outbound, and credential-management readiness with the latest operation. A blocked operation resumes through `domain-sync`; a blocked rotation resumes through `domain-rotate-secret`, preserving already confirmed steps and its staged credential. An uncertain operation remains stopped until read-back proves the result or an administrator uses `operation-resolve`. Domains an administrator adds show `managed_by: admin`; their owners can list and inspect them but use these commands for `sync`, `domain-rotate-secret`, and `remove`.
7979

‎packages/void/src/cli/client.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,8 @@ export type EmailDomainResponse = {
451451
domain: string;
452452
projectId: string;
453453
managed_by: 'owner' | 'admin';
454+
/** `none` sends only; the domain's MX provider keeps inbound mail. */
455+
inboundMode?: 'managed' | 'none';
454456
status: EmailReadiness['inbound']['status'];
455457
connectionId: string;
456458
account_id: string;

‎packages/void/src/cli/email.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -470,15 +470,19 @@ export async function emailDomainAdd(
470470
// A foreign MX on the apex stops here; an unreadable one is left to the platform, which
471471
// re-checks over public DNS.
472472
const dns = deps.dns ?? (await createNodeEmailDns());
473-
const checkApexMx = async (): Promise<void> => {
473+
const checkApexMx = async (adminProject?: string): Promise<void> => {
474474
const mx = await probeMx(dns, domain);
475475
if (mx.state === 'unknown') {
476476
log.info(
477477
`MX could not read ${domain}'s MX from here — the platform decides over public DNS.`,
478478
);
479479
} else if (mx.state === 'foreign') {
480480
fail(
481-
`email: ${domain} already receives mail from another provider. Platform custom inbound email requires an unused Cloudflare zone apex; choose another apex or use the platform shared mail address.`,
481+
`email: ${domain} already receives mail from another provider. Platform custom inbound email requires an unused Cloudflare zone apex; choose another apex or use the platform shared mail address.${
482+
adminProject
483+
? ` To only send from ${domain}, ask your administrator to run \`void platform email domain-add ${domain} --project ${adminProject} --outbound-only\`.`
484+
: ''
485+
}`,
482486
);
483487
} else {
484488
log.info(`MX using the requested apex ${domain}`);
@@ -493,8 +497,8 @@ export async function emailDomainAdd(
493497
const rows = await client.listEmailDomains(projectId);
494498
renewal = rows.some((row) => row.domain === domain && row.managed_by !== 'admin');
495499
if (!renewal) {
496-
await checkApexMx();
497500
const project = settings.domains.projectSlug ?? projectId;
501+
await checkApexMx(project);
498502
log.info(
499503
[
500504
'Email domains on this platform are managed by its administrator.',

‎packages/void/src/cli/operator-args.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,10 @@ export const operatorCommands: Array<OperatorCommand> = [
496496
path: '/email/domains',
497497
positional: ['domain'],
498498
values: { project: 'Project id or slug' },
499-
booleans: { 'token-stdin': 'Read a scoped Cloudflare API token from stdin' },
499+
booleans: {
500+
'token-stdin': 'Read a scoped Cloudflare API token from stdin',
501+
'outbound-only': 'Send only; leave the MX records and inbound mail with the current provider',
502+
},
500503
method: 'POST',
501504
},
502505
{
@@ -847,6 +850,9 @@ export function parseOperatorArgs(args: Array<string>): OperatorArgs {
847850
parsed.body.protected = true;
848851
} else if (group === 'zero-trust' && action === 'project-public') {
849852
parsed.body.protected = false;
853+
} else if (group === 'email' && action === 'domain-add' && flags.has('outbound-only')) {
854+
delete parsed.body['outbound-only'];
855+
parsed.body.outboundOnly = true;
850856
}
851857
return parsed;
852858
}

‎packages/void/src/cli/operator-cmd.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,18 @@ export async function runOperatorCommand(args: OperatorArgs): Promise<void> {
689689
}
690690
recoveryFence = { recoveryFence: target.recoveryFence };
691691
}
692+
// An older platform ignores outboundOnly and would take over the domain's MX.
693+
if (
694+
args.group === 'email' &&
695+
args.action === 'domain-add' &&
696+
args.body.outboundOnly === true &&
697+
(preview.target as Record<string, unknown> | null)?.inboundMode !== 'none'
698+
) {
699+
throw new OperatorClientError(
700+
'This platform does not support outbound-only email domains. Upgrade it first. No change was requested.',
701+
'unsupported',
702+
);
703+
}
692704
if (!(await confirmOperatorMutation(origin, preview, args))) {
693705
return;
694706
}

‎packages/void/test/unit/email-cli-domain.test.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const LEGACY_TOKEN = `Lgcy-${'a1'.repeat(17)}B`; // 40 chars, [A-Za-z0-9_-]
3737
const CFUT_TOKEN = `cfut_${'c0'.repeat(20)}${'deed'.repeat(2)}`; // 40-char core + checksum tail
3838
/** The default platform: owners register their own domains. */
3939
const selfServeSettings = async (): Promise<{
40-
domains: { management: 'self-serve' | 'admin' };
40+
domains: { management: 'self-serve' | 'admin'; projectSlug?: string };
4141
}> => ({ domains: { management: 'self-serve' } });
4242
const noEmailDomains = async (): Promise<Array<EmailDomainResponse>> => [];
4343

@@ -303,6 +303,21 @@ describe('emailDomainAdd', () => {
303303
expect(printed(infoSpy)).toContain('void platform email domain-add acme.com --project prj_1');
304304
});
305305

306+
it('points an administrator-managed apex with foreign mail at outbound-only setup', async () => {
307+
const api = client();
308+
const deps = addDeps({
309+
dns: { resolveMx: async () => [{ exchange: 'aspmx.l.google.com', priority: 1 }] },
310+
});
311+
api.getEmailSettings.mockResolvedValue({
312+
domains: { management: 'admin', projectSlug: 'hr-portal' },
313+
});
314+
await expect(emailDomainAdd(api, 'prj_1', 'acme.com', deps)).rejects.toThrow('exit 1');
315+
expect(printed(errorSpy)).toContain(
316+
'void platform email domain-add acme.com --project hr-portal --outbound-only',
317+
);
318+
expect(api.addEmailDomain).not.toHaveBeenCalled();
319+
});
320+
306321
it('lets an owner renew their earlier assignment after the administrator mode switch', async () => {
307322
const api = client();
308323
const deps = addDeps();

‎packages/void/test/unit/operator-cli.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,19 @@ describe('operator argument and help coverage', () => {
521521
tokenStdin: true,
522522
yes: true,
523523
});
524+
const outboundOnly = parseOperatorArgs([
525+
'email',
526+
'domain-add',
527+
'brand.example.com',
528+
'--project',
529+
'hr-portal',
530+
'--outbound-only',
531+
]);
532+
expect(outboundOnly.body).toEqual({
533+
domain: 'brand.example.com',
534+
project: 'hr-portal',
535+
outboundOnly: true,
536+
});
524537
expect(parseOperatorArgs(['email', 'domain-status', 'mail.example.com'])).toMatchObject({
525538
method: 'GET',
526539
path: '/email/domains/mail.example.com',
@@ -1260,6 +1273,39 @@ describe('operator transport and execution', () => {
12601273
await expect(runOperatorCommand(args)).rejects.toMatchObject({ code: 'invalid_preview' });
12611274
expect(fetcher).toHaveBeenCalledTimes(1);
12621275
});
1276+
it('stops an outbound-only add when the platform preview ignores the mode', async () => {
1277+
vi.stubEnv('VOID_API_URL', 'https://one.example');
1278+
vi.stubEnv('VOID_OPERATOR_TOKEN', 'operator');
1279+
vi.spyOn(console, 'log').mockImplementation(() => {});
1280+
const preview = (target: Record<string, unknown>) =>
1281+
Response.json({ dryRun: true, action: 'email.domain.add', target });
1282+
const fetcher = vi
1283+
.fn<typeof fetch>()
1284+
.mockResolvedValueOnce(preview({ domain: 'brand.example.com' }));
1285+
vi.stubGlobal('fetch', fetcher);
1286+
const args = parseOperatorArgs([
1287+
'email',
1288+
'domain-add',
1289+
'brand.example.com',
1290+
'--project',
1291+
'hr-portal',
1292+
'--outbound-only',
1293+
'--yes',
1294+
]);
1295+
await expect(runOperatorCommand(args)).rejects.toMatchObject({ code: 'unsupported' });
1296+
expect(fetcher).toHaveBeenCalledTimes(1);
1297+
1298+
fetcher
1299+
.mockReset()
1300+
.mockResolvedValueOnce(preview({ domain: 'brand.example.com', inboundMode: 'none' }))
1301+
.mockResolvedValueOnce(Response.json({ domain: { operationStatus: 'completed' } }));
1302+
await runOperatorCommand(args);
1303+
expect(JSON.parse(String(fetcher.mock.calls[1]![1]!.body))).toEqual({
1304+
domain: 'brand.example.com',
1305+
project: 'hr-portal',
1306+
outboundOnly: true,
1307+
});
1308+
});
12631309
it('drains runtime cursors and rejects repeated cursors instead of spinning', async () => {
12641310
vi.spyOn(console, 'log').mockImplementation(() => {});
12651311
const fetcher = vi

‎platform/meta/operations.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ the exact stored mutation. Record `applied` to continue without replaying it, or
184184
`not-applied` to retry only that fenced intent. Keep the same platform version and
185185
preserve staged secret state until the operation completes.
186186

187+
Outbound-only domains (`domain-add --outbound-only`) need a release that knows the `inbound_mode` column. After a platform rollback to an earlier release, do not run `domain-add`, `domain-sync`, or `sync` for them: the older release treats them as normal domains and would enable Email Routing, which replaces the apex MX records and takes over the provider's mail. Sending keeps working without these commands. Upgrade again first.
188+
187189
PostgreSQL is authoritative for consent, policy, suspension, and recipient-attempt quotas. Cache publication retries independently. `void email logs` exposes receipt and action metadata: provider acceptance is not proof of delivery, and an unknown result must not be retried with a new idempotency key. Removing one domain or project must preserve sibling routes and unresolved cleanup records.
188190

189191
Before production enablement, complete the [email acceptance checks](./release-acceptance.md#email-acceptance), including live Cloudflare routing, forwarding/reply, scoped-token, rotation, and failure-recovery checks.

0 commit comments

Comments
 (0)