Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/email/domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ void email domain add acme.com

`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.

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.
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.

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

Expand Down
16 changes: 16 additions & 0 deletions docs/guide/platform/administration/email.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,19 @@ Run recovery with the same platform version that created the persisted intent.
`applied` continues without replaying the write; `not-applied` permits that exact
step to retry. Secret rotation keeps its staged generation, and removal resumes
from the unresolved cleanup step. Age alone never authorizes a retry.

### Sending from a domain that already receives mail

If an apex already receives mail with another provider, such as Microsoft 365 or Google Workspace, register it for sending only:

```sh
void platform email domain-add brand.example --project hr-portal --outbound-only
```

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.

- 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`.
- 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`.
- 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.
- `domain-rotate-secret` does not apply. `domain-remove` stops sending through the platform and leaves the MX records alone.
- To switch an outbound-only domain to platform-managed inbound mail, or back, remove it first and add it again.
4 changes: 2 additions & 2 deletions docs/reference/cli/platform-email.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,14 @@ Register and maintain email domains for projects whose owners hold no Cloudflare

```sh
void platform email domains [--project <id|slug>]
void platform email domain-add <domain> --project <id|slug> [--token-stdin]
void platform email domain-add <domain> --project <id|slug> [--token-stdin] [--outbound-only]
void platform email domain-status <domain>
void platform email domain-sync <domain>
void platform email domain-rotate-secret <domain>
void platform email domain-remove <domain> [--token-stdin]
```

`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).
`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).

`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`.

Expand Down
2 changes: 2 additions & 0 deletions packages/void/src/cli/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,8 @@ export type EmailDomainResponse = {
domain: string;
projectId: string;
managed_by: 'owner' | 'admin';
/** `none` sends only; the domain's MX provider keeps inbound mail. */
inboundMode?: 'managed' | 'none';
status: EmailReadiness['inbound']['status'];
connectionId: string;
account_id: string;
Expand Down
10 changes: 7 additions & 3 deletions packages/void/src/cli/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,15 +470,19 @@ export async function emailDomainAdd(
// A foreign MX on the apex stops here; an unreadable one is left to the platform, which
// re-checks over public DNS.
const dns = deps.dns ?? (await createNodeEmailDns());
const checkApexMx = async (): Promise<void> => {
const checkApexMx = async (adminProject?: string): Promise<void> => {
const mx = await probeMx(dns, domain);
if (mx.state === 'unknown') {
log.info(
`MX could not read ${domain}'s MX from here — the platform decides over public DNS.`,
);
} else if (mx.state === 'foreign') {
fail(
`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.`,
`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.${
adminProject
? ` To only send from ${domain}, ask your administrator to run \`void platform email domain-add ${domain} --project ${adminProject} --outbound-only\`.`
: ''
}`,
);
} else {
log.info(`MX using the requested apex ${domain}`);
Expand All @@ -493,8 +497,8 @@ export async function emailDomainAdd(
const rows = await client.listEmailDomains(projectId);
renewal = rows.some((row) => row.domain === domain && row.managed_by !== 'admin');
if (!renewal) {
await checkApexMx();
const project = settings.domains.projectSlug ?? projectId;
await checkApexMx(project);
log.info(
[
'Email domains on this platform are managed by its administrator.',
Expand Down
8 changes: 7 additions & 1 deletion packages/void/src/cli/operator-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,10 @@ export const operatorCommands: Array<OperatorCommand> = [
path: '/email/domains',
positional: ['domain'],
values: { project: 'Project id or slug' },
booleans: { 'token-stdin': 'Read a scoped Cloudflare API token from stdin' },
booleans: {
'token-stdin': 'Read a scoped Cloudflare API token from stdin',
'outbound-only': 'Send only; leave the MX records and inbound mail with the current provider',
},
method: 'POST',
},
{
Expand Down Expand Up @@ -847,6 +850,9 @@ export function parseOperatorArgs(args: Array<string>): OperatorArgs {
parsed.body.protected = true;
} else if (group === 'zero-trust' && action === 'project-public') {
parsed.body.protected = false;
} else if (group === 'email' && action === 'domain-add' && flags.has('outbound-only')) {
delete parsed.body['outbound-only'];
parsed.body.outboundOnly = true;
}
return parsed;
}
12 changes: 12 additions & 0 deletions packages/void/src/cli/operator-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,18 @@ export async function runOperatorCommand(args: OperatorArgs): Promise<void> {
}
recoveryFence = { recoveryFence: target.recoveryFence };
}
// An older platform ignores outboundOnly and would take over the domain's MX.
if (
args.group === 'email' &&
args.action === 'domain-add' &&
args.body.outboundOnly === true &&
(preview.target as Record<string, unknown> | null)?.inboundMode !== 'none'
) {
throw new OperatorClientError(
'This platform does not support outbound-only email domains. Upgrade it first. No change was requested.',
'unsupported',
);
}
if (!(await confirmOperatorMutation(origin, preview, args))) {
return;
}
Expand Down
17 changes: 16 additions & 1 deletion packages/void/test/unit/email-cli-domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const LEGACY_TOKEN = `Lgcy-${'a1'.repeat(17)}B`; // 40 chars, [A-Za-z0-9_-]
const CFUT_TOKEN = `cfut_${'c0'.repeat(20)}${'deed'.repeat(2)}`; // 40-char core + checksum tail
/** The default platform: owners register their own domains. */
const selfServeSettings = async (): Promise<{
domains: { management: 'self-serve' | 'admin' };
domains: { management: 'self-serve' | 'admin'; projectSlug?: string };
}> => ({ domains: { management: 'self-serve' } });
const noEmailDomains = async (): Promise<Array<EmailDomainResponse>> => [];

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

it('points an administrator-managed apex with foreign mail at outbound-only setup', async () => {
const api = client();
const deps = addDeps({
dns: { resolveMx: async () => [{ exchange: 'aspmx.l.google.com', priority: 1 }] },
});
api.getEmailSettings.mockResolvedValue({
domains: { management: 'admin', projectSlug: 'hr-portal' },
});
await expect(emailDomainAdd(api, 'prj_1', 'acme.com', deps)).rejects.toThrow('exit 1');
expect(printed(errorSpy)).toContain(
'void platform email domain-add acme.com --project hr-portal --outbound-only',
);
expect(api.addEmailDomain).not.toHaveBeenCalled();
});

it('lets an owner renew their earlier assignment after the administrator mode switch', async () => {
const api = client();
const deps = addDeps();
Expand Down
46 changes: 46 additions & 0 deletions packages/void/test/unit/operator-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,19 @@ describe('operator argument and help coverage', () => {
tokenStdin: true,
yes: true,
});
const outboundOnly = parseOperatorArgs([
'email',
'domain-add',
'brand.example.com',
'--project',
'hr-portal',
'--outbound-only',
]);
expect(outboundOnly.body).toEqual({
domain: 'brand.example.com',
project: 'hr-portal',
outboundOnly: true,
});
expect(parseOperatorArgs(['email', 'domain-status', 'mail.example.com'])).toMatchObject({
method: 'GET',
path: '/email/domains/mail.example.com',
Expand Down Expand Up @@ -1260,6 +1273,39 @@ describe('operator transport and execution', () => {
await expect(runOperatorCommand(args)).rejects.toMatchObject({ code: 'invalid_preview' });
expect(fetcher).toHaveBeenCalledTimes(1);
});
it('stops an outbound-only add when the platform preview ignores the mode', async () => {
vi.stubEnv('VOID_API_URL', 'https://one.example');
vi.stubEnv('VOID_OPERATOR_TOKEN', 'operator');
vi.spyOn(console, 'log').mockImplementation(() => {});
const preview = (target: Record<string, unknown>) =>
Response.json({ dryRun: true, action: 'email.domain.add', target });
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(preview({ domain: 'brand.example.com' }));
vi.stubGlobal('fetch', fetcher);
const args = parseOperatorArgs([
'email',
'domain-add',
'brand.example.com',
'--project',
'hr-portal',
'--outbound-only',
'--yes',
]);
await expect(runOperatorCommand(args)).rejects.toMatchObject({ code: 'unsupported' });
expect(fetcher).toHaveBeenCalledTimes(1);

fetcher
.mockReset()
.mockResolvedValueOnce(preview({ domain: 'brand.example.com', inboundMode: 'none' }))
.mockResolvedValueOnce(Response.json({ domain: { operationStatus: 'completed' } }));
await runOperatorCommand(args);
expect(JSON.parse(String(fetcher.mock.calls[1]![1]!.body))).toEqual({
domain: 'brand.example.com',
project: 'hr-portal',
outboundOnly: true,
});
});
it('drains runtime cursors and rejects repeated cursors instead of spinning', async () => {
vi.spyOn(console, 'log').mockImplementation(() => {});
const fetcher = vi
Expand Down
2 changes: 2 additions & 0 deletions platform/meta/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ the exact stored mutation. Record `applied` to continue without replaying it, or
`not-applied` to retry only that fenced intent. Keep the same platform version and
preserve staged secret state until the operation completes.

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.

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.

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.
Expand Down
2 changes: 2 additions & 0 deletions platform/packages/api/drizzle/0061_email_inbound_mode.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "email_domain_routes" ADD COLUMN "inbound_mode" text DEFAULT 'managed' NOT NULL;--> statement-breakpoint
ALTER TABLE "email_domain_routes" ADD CONSTRAINT "email_domain_routes_inbound_mode_check" CHECK ("email_domain_routes"."inbound_mode" = 'managed' OR ("email_domain_routes"."inbound_mode" = 'none' AND "email_domain_routes"."addressing" = 'custom'));
16 changes: 16 additions & 0 deletions platform/packages/api/drizzle/compatibility.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@
{
"platformVersion": "1.1.0",
"schemaDigest": "bd1c4f124ee52cde71a9e2e974e3af2f1431cc7c92796be233784d5790b06eb4"
},
{
"platformVersion": "1.1.11",
"schemaDigest": "d8e007d2bbe02637f6e77a33c1db152e0c4f21b30439ad512e16bce6726d6b9b"
},
{
"platformVersion": "1.1.12",
"schemaDigest": "d8e007d2bbe02637f6e77a33c1db152e0c4f21b30439ad512e16bce6726d6b9b"
},
{
"platformVersion": "1.2.0",
"schemaDigest": "d8e007d2bbe02637f6e77a33c1db152e0c4f21b30439ad512e16bce6726d6b9b"
},
{
"platformVersion": "1.2.1",
"schemaDigest": "d8e007d2bbe02637f6e77a33c1db152e0c4f21b30439ad512e16bce6726d6b9b"
}
]
}
Loading
Loading