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
8 changes: 7 additions & 1 deletion docs/guide/email.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ Two rules bound registrations: one live email domain per Cloudflare zone (a seco

Sends from a registered domain skip the verified-recipient allowlist — the domain's own Email Sending onboarding replaces it — while platform quota and abuse controls still apply. Inbound needs nothing further: once the domain is `active`, mail to any address on it reaches your `email/` handlers.

**On a platform your administrator runs.** Some platforms register email domains for their projects centrally — typically a company installation whose users have no Cloudflare account. There, `void email domain add` opens no consent page; it prints the command to ask your administrator for and stops — unless your project registered that domain itself before the switch, which `add` still renews as before. `void email domain list` and `status` work as usual on such a domain; `status` names the administrator's command for any step you cannot take, and `sync` and `remove` point you at the administrator too. The administrator may also have widened who the shared sender reaches, so `void email allow` can be unnecessary for colleagues; `void email destinations` still lists what your project verified itself.

## Options

| Option | Type | Notes |
Expand Down Expand Up @@ -406,7 +408,11 @@ So the ticket example above answers only senders you have allowlisted. An
auto-responder to arbitrary senders needs
[your own Cloudflare account](#your-own-cloudflare-account)
(`--platform cloudflare`), where `reply()` is Cloudflare's own reply-to-sender
and Void adds no allowlist.
and Void adds no allowlist — or a platform whose administrator has widened the
recipient policy for the whole installation (see
[On a platform your administrator runs](#your-own-domain-on-the-platform)),
which lifts this gate for the listed domains; addresses on the platform's own
mail domain always stay verified-recipient-only.
:::

Note the asymmetry with `sendEmail`: `sendEmail` returns an error result and
Expand Down
53 changes: 53 additions & 0 deletions docs/guide/platform-administration.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,59 @@ before changing application traffic or deleting resources. If cleanup fails,
access stays revoked and the error describes the partial result. A remaining
administrator can inspect it and retry cleanup.

## Managing Email

These commands apply to a platform that enables email. Every project can send from and receive at `<slug>+tag@<mail domain>` on the platform's shared mail domain; the [email guide](./email.md) describes what applications do with that.

### Deciding who mail may reach

By default, mail from the shared sender reaches only the recipients each project has verified. For a team whose applications mainly mail colleagues, widen that once for the whole installation:

```sh
void platform email policy
void platform email policy-set domains --domains example.com,corp.example.net
void platform email policy-set any
void platform email policy-set verified
```

`domains` lets every project mail any address on the listed domains in addition to its verified recipients; `any` lifts the check. Cloudflare still refuses a recipient it has not verified until your mail domain is onboarded for Email Sending, which needs Workers Paid on the platform's account. Until then such sends come back as `UNVERIFIED_DESTINATION` for that recipient, whatever the policy says. Two things the policy never changes: sends from a project's own custom domain, and mail to any address on the platform's own mail domain — those stay verified-recipient-only, so no project reaches another project's inbox without its consent. A tightening takes effect everywhere within a minute. The same setting is on the admin UI's **Email** page.

### Project caps

Each project may send 200 messages a month and 10 a minute by default. Raise or lower a project's caps by id or slug:

```sh
void platform email limit hr-portal --monthly 2000 --burst 30
```

The project's page in the admin UI shows the current values and clears an override.

### Registering email domains for projects

Your users may hold no Cloudflare account. Tell the platform that administrators register email domains, so `void email domain add` prints the command to ask you for instead of opening a Cloudflare consent:

```sh
void platform email settings-set --domains admin
```

Then register a domain for a project. The zone must be in the platform's Cloudflare account; the platform's own credential does the setup, and no Cloudflare credential is stored per domain:

```sh
void platform email domain-add mail.example.com --project hr-portal
void platform email domain-status mail.example.com
void platform email domains
```

Name the exact mail domain: a subdomain such as `mail.example.com` when the apex already receives mail, otherwise the apex itself. Enabling Email Routing on a subdomain is a one-time step in the Cloudflare dashboard; `domain-status` names it and reports the domain active once the MX records appear. Email Sending onboarding needs Workers Paid; after upgrading, `domain-sync` re-attempts it. For a zone in another Cloudflare account, pipe an API token for that account on standard input:

```sh
void platform email domain-add mail.other.example --project hr-portal --token-stdin --yes < token.txt
```

Project owners see administrator-registered domains in `void email domain list` and `status`; `status` names the `void platform email` command for any step they cannot take themselves, and `sync` and `remove` point them at `domain-sync` and `domain-remove`. A domain an owner registered before you switched to `admin` stays theirs to renew, sync and remove. The runtime token needs the email permissions listed in the [self-hosting guide](./self-hosted-platform.md#runtime-token-permissions) for this to work.

Register administrator domains only after a platform upgrade has completed, and remove them before rolling the platform back to a version that predates this feature: an earlier runtime cannot use the platform credential for them and does not distinguish administrator-registered domains from owner-registered ones.

## Previewing Changes

Commands that change the platform show the affected objects before asking for confirmation. To inspect a change without applying it, add `--plan`:
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/self-hosted-platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ Use the following permissions for the core platform. Cloudflare may label write

The management token also needs **Zone Edit** with authority to create zones if you ask Void to create the zone. If it already exists, use the selected zone with Zone Read and DNS Edit. Nested application domains additionally need **SSL and Certificates: Read** on the management token. A custom runtime that enables custom project domains needs **SSL and Certificates: Edit** on the runtime token; the core runtime does not enable that feature.

A custom runtime that enables email lets administrators [register email domains for projects](./platform-administration.md#registering-email-domains-for-projects) on the runtime token. That needs **Zone: Read**, **Zone Settings: Edit** and **Email Routing Rules: Edit** on the zones that will carry mail, and **Email Sending: Edit** on the account; the token link preselects them when the runtime enables email. Email Sending onboarding itself needs Workers Paid on the account.

Void checks access before provisioning. If it reports a missing permission, update the token's permissions for the selected account or zone and retry.

:::
Expand Down
28 changes: 28 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,34 @@ void platform invitation revoke <id>

Send accepts up to 100 comma-separated addresses. Invitations grant signup access even if email delivery is unavailable or fails; delivery is reported separately. Revoking a pending invitation removes its exact email grant. A broader domain entry can still allow that person to sign up.

#### Email {#operator-email}

Decide who mail from the shared sender may reach, who registers email domains, and a project's outbound caps:

```sh
void platform email policy
void platform email policy-set <verified|domains|any> [--domains <domain[,domain...]>]
void platform email settings
void platform email settings-set --domains <self-serve|admin>
void platform email limit <project-id|slug> [--monthly <n>] [--burst <n>]
```

`policy` decides which recipients a project's `<slug>+tag@<mail domain>` sender reaches: `verified` (the default) means only that project's verified destinations; `domains` adds every address on the listed domains; `any` lifts the check. Neither widens delivery to addresses on the platform's own mail domain: those stay verified-destination-only, so no project reaches another project's inbox without its consent. Cloudflare still refuses a destination it has not verified until the platform mail domain is onboarded for Email Sending, so under `domains` or `any` such refusals arrive as per-recipient `UNVERIFIED_DESTINATION` results. Custom-domain sends are not affected.

`settings-set --domains admin` tells `void email domain add` to print the administrator's command instead of starting a Cloudflare grant. `limit` overrides the project's monthly and per-minute caps (defaults 200 and 10); a project page in the admin UI shows and clears them. When both caps are given and only one could be written, the result names the applied and failed cap and exits nonzero.

Register and maintain email domains for projects whose owners hold no Cloudflare credential:

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

`domain-add` runs the same setup as `void email domain add` on the platform's own Cloudflare credential, so the zone must be in the platform's account; for a zone elsewhere, pipe a Cloudflare API token for that account on standard input with `--token-stdin --yes`. Name the exact mail domain (`mail.example.com`, or the apex when it receives no mail yet). Enabling Email Routing on a subdomain stays a one-time Cloudflare dashboard step, which `domain-status` reports and self-clears once the MX records appear; after a Workers Paid upgrade, `domain-sync` re-attempts the Email Sending onboarding. A setup or sync that ends with the domain `failed` or its credential revoked or expired is reported as a partial failure and exits nonzero; the result names the failing step, and running `domain-add` again retries it. Rows an administrator adds show `managed_by: admin`; their owners can list and inspect them but are pointed at these commands for `sync` and `remove`.

#### System {#operator-system}

Inspect activity, check service health, or review administrative changes:
Expand Down
2 changes: 1 addition & 1 deletion packages/void/src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ if (helpPath) {
},
{
name: 'platform',
hint: 'auth | user | project | deployment | signup | system | install',
hint: 'auth | user | project | deployment | signup | email | system | install',
description: 'Install platforms and administer users, projects, access, and operations',
},
{
Expand Down
43 changes: 42 additions & 1 deletion packages/void/src/cli/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,16 @@ export type EmailDestinationResponse = {
verificationSent?: boolean;
};

/** GET /projects/:id/email/settings — installation settings the CLI reads before acting. */
export type EmailSettingsResponse = {
domains: {
/** `admin`: administrators register email domains; `void email domain add` prints their command. */
management: 'self-serve' | 'admin';
projectSlug?: string;
};
recipientPolicy?: { mode: 'verified' | 'domains' | 'any'; domains: Array<string> };
};

/**
* The public shape of an `email_domains` row (design doc 0078, contract 1) —
* metadata only. The api's serializer never carries the credential, the
Expand All @@ -281,7 +291,10 @@ export type EmailDomainResponse = {
domain: string;
mode: 'apex' | 'subdomain' | null;
status: 'pending' | 'active' | 'token_revoked' | 'token_expired' | 'failed';
credential_kind: 'oauth_grant' | 'api_token';
/** `platform` rows run on the platform's own credential — an administrator's row. */
credential_kind: 'oauth_grant' | 'api_token' | 'platform';
/** Who registered the row; owners may read an `admin` row but not sync or remove it. */
managed_by?: 'owner' | 'admin';
relay_worker_name: string | null;
relay_version: number | null;
/** Metadata from GET /user/tokens/verify (api_token rows) — safe to print. */
Expand Down Expand Up @@ -1649,6 +1662,34 @@ export class PlatformClient {
return res.json();
}

/**
* Installation email settings the CLI reads before acting. A platform
* predating the route answers a bare 404, which reads as the defaults —
* owners register their own domains there. The route's own 404 (the
* project is gone) is a real error: acting on it would open a browser and
* mint a grant for nothing.
*/
async getEmailSettings(projectId: string): Promise<EmailSettingsResponse> {
const res = await this.request(`${this.baseUrl}/projects/${projectId}/email/settings`, {
headers: {
...this.authHeaders,
},
});

if (res.status === 404) {
const body = await res.text();
if (parsePlatformErrorBody(body)?.error === 'project not found') {
throw new PlatformApiError('Failed to read email settings', res.status, body);
}
return { domains: { management: 'self-serve' } };
}
if (!res.ok) {
await throwPlatformApiError('Failed to read email settings', res);
}

return res.json();
}

async listEmailDomains(projectId: string): Promise<Array<EmailDomainResponse>> {
const res = await this.request(`${this.baseUrl}/projects/${projectId}/email/domains`, {
headers: {
Expand Down
Loading
Loading