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: 2 additions & 0 deletions docs/guide/email/receiving.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ deployed `email/` handlers without per-project DNS setup once the platform has v
handlers once its inbound readiness is `ready`. Use `void email domain status`
to check current provider routing and any remaining setup steps.

A project with an [email alias](../../reference/cli/email.md#void-email-alias) also receives at `<alias>+anything@<mail domain>`. Routing ignores which prefix was used: with the slug `acme-store` and the alias `acme`, both `acme-store+support@…` and `acme+support@…` reach `email/support.ts`.

For direct deployments, follow [Your own Cloudflare account](./domains.md#your-own-cloudflare-account) to configure inbound addresses.

The local dev server provides the outbound email inbox but has no inbound email trigger. Test inbound handlers with `createInboundTestHarness` below.
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/email/sending.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ You can send to at most 50 recipients across `to`, `cc`, and `bcc`. Use standard

`Address` accepts either a string (`"hello@acme.dev"` or `"Name <hello@acme.dev>"`) or an object (`{ email, name? }`). Unicode display names are supported.

On the platform the sender is pinned to your project. `from` must be your project's own platform address — `<project-slug>@<mail-domain>` or `<project-slug>+<tag>@<mail-domain>`, optionally with a display name — or any address on a domain registered with `void email domain add` (see [Your own domain on the platform](./domains.md#your-own-domain-on-the-platform)). For example, if your platform's mail domain is `mail.example.com`, you can use `Acme <acme+noreply@mail.example.com>`. Anything else is rejected with `INVALID_FROM`. Omit `from` and Void fills in `<project-slug>+noreply@<mail-domain>` for you.
On the platform the sender is pinned to your project. `from` must be your project's own platform address — `<project-slug>@<mail-domain>` or `<project-slug>+<tag>@<mail-domain>`, optionally with a display name — or any address on a domain registered with `void email domain add` (see [Your own domain on the platform](./domains.md#your-own-domain-on-the-platform)). For example, if your platform's mail domain is `mail.example.com`, you can use `Acme <acme+noreply@mail.example.com>`. Anything else is rejected with `INVALID_FROM`. Omit `from` and Void fills in `<project-slug>+noreply@<mail-domain>` for you. If the project owner set an [email alias](../../reference/cli/email.md#void-email-alias), `<alias>@<mail-domain>` and `<alias>+<tag>@<mail-domain>` are accepted too; the default stays the slug address.

On your own Cloudflare account, `from` defaults to `email.from` from `void.config.ts` and must be on a domain your account can send from; Cloudflare rejects any other sender and `sendEmail` reports it as `INVALID_FROM`.

Expand Down
2 changes: 2 additions & 0 deletions docs/guide/platform/administration/email.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ These commands apply to a platform that enables email. Every project can send fr

Shared inbound mail uses one exact routing rule per receiving project and enables Cloudflare’s per-zone plus addressing. Creating a project or deploying an app that only sends mail consumes no inbound rule. Existing rules and the apex catch-all remain in place. Cloudflare normally allows [200 routing rules per domain](https://developers.cloudflare.com/email-service/platform/limits/); remove unused rules or request a higher limit if the shared domain reaches capacity. An unrelated rule for a project’s base address or a plus address blocks its setup until that conflict is removed.

A project owner can give the shared address one permanent, shorter name with `void email alias <name>`. A receiving project with an alias uses two rules, one for the slug and one for the alias, because the slug address keeps working. Setting an alias is refused while a conflicting rule exists or the domain is at its rule limit, and the alias is released only when the project is deleted.

If recipient creation is interrupted, inspect its retained intent:

```sh
Expand Down
1 change: 1 addition & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Use this page as a command reference. If you are setting up a project for the fi
| `void email destinations` | List verified recipient addresses |
| `void email allow <address>` | Add a recipient and send a verification email |
| `void email disallow <address>` | Remove a recipient from the allowlist |
| `void email alias [name]` | Show the shared mail address, or give it a shorter permanent name |
| `void email domain` | Send and receive at your own domain on a Cloudflare zone |
| `void init` | Set up a new or existing project, including coding agents |
| `void migrate` | Convert legacy `void.json` and root Wrangler JSON/JSONC into `void.config.ts` |
Expand Down
13 changes: 13 additions & 0 deletions docs/reference/cli/email.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ void email disallow <address> [--project <name>]

Remove one recipient from the project's destination list. New sends to that address are refused immediately; previously admitted attempts may finish. No deploy is involved.

## `void email alias` {#void-email-alias}

```
void email alias [--project <name>]
void email alias <name> [--yes] [--project <name>]
```

With no name, show the project's address on the platform's shared mail domain and whether inbound mail reaches it.

With a name, give that address a shorter name: `void email alias acme` makes `acme@<mail domain>` and `acme+<tag>@<mail domain>` reach the project and lets it send from them. Only the project owner can set it. The name follows the same rules as a project slug, must not be used by any other project's slug or alias, and names of five characters or fewer need a plan that allows short names.

The alias is permanent: it cannot be changed or removed, and it is freed only when the project is deleted. The slug address keeps working for sending and receiving, so nothing that already uses it breaks. The CLI asks for confirmation; pass `--yes` to skip the prompt, which is required outside an interactive terminal. Inbound mail at the alias starts once the project has `email/` handlers deployed and its recipient rule is ready; run `void email alias` to check. Redeploy with the current `void` before you share the alias: an app built with an older version sees `message.to` with the alias still in front (`acme+support@…` instead of `support@…`).

## `void email domain` {#void-email-domain}

```
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 @@ -321,7 +321,7 @@ if (helpPath) {
},
{
name: 'email',
hint: 'usage | logs | destinations | allow | disallow | domain | status | setup',
hint: 'usage | logs | destinations | allow | disallow | alias | domain | status | setup',
description: 'Inspect email activity and manage verified recipients',
},
{
Expand Down
32 changes: 26 additions & 6 deletions packages/void/src/cli/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,12 +438,24 @@ export type EmailSettingsResponse = {
projectSlug?: string;
};
recipientPolicy?: { mode: 'verified' | 'domains' | 'any'; domains: Array<string> };
sharedInbound?: {
recipient: string;
status: string;
reason: string | null;
observedAt: string | null;
} | null;
/** A second, permanent name on the shared mail domain; the slug keeps working. */
emailAlias?: string | null;
/** The platform's shared mail domain, or null when it has none. */
sharedDomain?: string | null;
sharedInbound?: EmailSharedInbound | null;
};

export type EmailSharedInbound = {
recipient: string;
status: string;
reason: string | null;
observedAt: string | null;
};

export type EmailAliasResponse = {
emailAlias: string;
address: string;
sharedInbound: EmailSharedInbound | null;
};

export type EmailDomainResponse = {
Expand Down Expand Up @@ -1923,6 +1935,14 @@ export class PlatformClient {
);
}

async setEmailAlias(projectId: string, alias: string): Promise<EmailAliasResponse> {
return this.json(
`${this.baseUrl}/projects/${projectId}/email/alias`,
'Failed to set the email alias',
{ method: 'PUT', body: { alias }, permission: 'settings' },
);
}

async listEmailDomains(projectId: string): Promise<Array<EmailDomainResponse>> {
return this.json(
`${this.baseUrl}/projects/${projectId}/email/domains`,
Expand Down
15 changes: 13 additions & 2 deletions packages/void/src/cli/email-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export const emailSubcommands: Array<SubcommandDef> = [
{ name: 'destinations', description: 'List verified recipient addresses for outbound mail' },
{ name: 'allow', arg: '<address>', description: 'Add a recipient and trigger CF verification' },
{ name: 'disallow', arg: '<address>', description: 'Remove a verified recipient' },
{
name: 'alias',
arg: '[name]',
description: 'Show the shared mail address, or set a shorter permanent name for it',
},
// The verb is collected as the arg so `domain list` completes in the interactive prompt;
// `domain add` then errors naming the missing <domain>, like a typed invocation.
{
Expand All @@ -38,6 +43,7 @@ const EMAIL_SUBCOMMANDS = [
'destinations',
'allow',
'disallow',
'alias',
'domain',
'status',
'setup',
Expand All @@ -57,6 +63,8 @@ export interface EmailArgs {
address?: string;
/** `cloudflare` is the developer's own account; `void` (the default) is the Void platform. */
platform?: 'cloudflare' | 'void';
/** `void email alias <name> --yes` sets the permanent alias without a prompt. */
yes?: boolean;
}

/**
Expand Down Expand Up @@ -85,7 +93,7 @@ export function parseEmailArgs(args: Array<string>): EmailArgs {
const subcommand = args[0] as EmailArgs['subcommand'];
if (!subcommand || !(EMAIL_SUBCOMMANDS as ReadonlyArray<string>).includes(subcommand)) {
throw new Error(
'email: Usage: void email <usage|logs|destinations|allow|disallow|domain|status|setup> [args] [--project <name>] [--limit <n>] [--platform cloudflare].',
'email: Usage: void email <usage|logs|destinations|allow|disallow|alias|domain|status|setup> [args] [--project <name>] [--limit <n>] [--platform cloudflare].',
);
}

Expand All @@ -95,6 +103,7 @@ export function parseEmailArgs(args: Array<string>): EmailArgs {
const projectValues: Array<string> = [];
const platformValues: Array<string> = [];
const positional: Array<string> = [];
let yes = false;

const rest = args.slice(1);
for (let i = 0; i < rest.length; i++) {
Expand Down Expand Up @@ -127,6 +136,8 @@ export function parseEmailArgs(args: Array<string>): EmailArgs {
}
platformValues.push(value);
i = next;
} else if (rest[i] === '--yes' && subcommand === 'alias') {
yes = true;
} else if (rest[i] === '--backend' || rest[i]?.startsWith('--backend=')) {
throw new Error("email: Unknown option '--backend'.");
} else if (rest[i] === '--subdomain' || rest[i]?.startsWith('--subdomain=')) {
Expand Down Expand Up @@ -197,7 +208,7 @@ export function parseEmailArgs(args: Array<string>): EmailArgs {
}
}

return { subcommand, projectSlug, limit, address, platform };
return { subcommand, projectSlug, limit, address, platform, ...(yes ? { yes } : {}) };
}

/**
Expand Down
73 changes: 72 additions & 1 deletion packages/void/src/cli/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
PlatformClient,
type EmailDomainResponse,
type EmailOperationResponse,
type EmailSharedInbound,
} from './client.ts';
import {
createDeployWranglerRunner,
Expand Down Expand Up @@ -49,7 +50,7 @@ export async function runEmailCommand(root: string, args: EmailArgs): Promise<vo
}
if (args.subcommand === 'setup') {
log.error(
'email: `void email setup` sets email up on your OWN Cloudflare account — run it with `--platform cloudflare`. The Void platform needs no setup: deploy, and `void email usage` shows your address.',
'email: `void email setup` sets email up on your OWN Cloudflare account — run it with `--platform cloudflare`. The Void platform needs no setup: deploy, and `void email alias` shows your address.',
);
process.exit(1);
}
Expand Down Expand Up @@ -81,6 +82,8 @@ export async function runEmailCommand(root: string, args: EmailArgs): Promise<vo
return emailAllow(client, projectId, args.address!);
case 'disallow':
return emailDisallow(client, projectId, args.address!);
case 'alias':
return emailAlias(client, projectId, args.address, { yes: args.yes === true });
case 'domain':
return runEmailDomainCommand(client, projectId, args);
}
Expand All @@ -98,6 +101,74 @@ async function emailUsage(client: PlatformClient, projectId: string): Promise<vo
note(lines.join('\n'), 'Email Usage');
}

export async function emailAlias(
client: Pick<PlatformClient, 'getEmailSettings' | 'setEmailAlias'>,
projectId: string,
name: string | undefined,
opts: { yes: boolean; isInteractive?: boolean } = { yes: false },
): Promise<void> {
if (!name) {
const settings = await client.getEmailSettings(projectId);
const shared = settings.sharedInbound;
const domain =
settings.sharedDomain ??
(shared ? shared.recipient.slice(shared.recipient.lastIndexOf('@') + 1) : null);
const name = settings.emailAlias ?? settings.domains.projectSlug;
if (!domain || !name) {
log.info('This platform has no shared mail domain.');
return;
}
const address = `${name}@${domain}`;
const lines = [`Address: ${address}`, `Inbound: ${inboundLine(shared, address)}`];
if (settings.emailAlias && settings.domains.projectSlug) {
lines.push(`Also: ${settings.domains.projectSlug}@${domain} (the project slug)`);
}
note(lines.join('\n'), 'Shared mail address');
if (!settings.emailAlias) {
log.info('Run `void email alias <name>` to give this address a shorter, permanent name.');
}
return;
}
if (!opts.yes) {
if (!(opts.isInteractive ?? (process.stdin.isTTY && process.stdout.isTTY))) {
log.error('email: the alias is permanent. Pass --yes to set it without a prompt.');
process.exit(1);
}
const ok = await confirm({
message: `Set the permanent email alias "${name}"? It cannot be changed or removed later; the slug address keeps working.`,
initialValue: false,
});
if (isCancel(ok) || !ok) {
log.info('Cancelled. No alias was set.');
return;
}
}
const result = await client.setEmailAlias(projectId, name);
printSuccess(`Email alias set: ${result.address}`);
const status = result.sharedInbound?.status;
if (status && status !== 'ready') {
const other =
result.sharedInbound!.recipient === result.address
? ''
: ` at ${result.sharedInbound!.recipient}`;
log.info(
`Inbound mail${other} is ${status}${result.sharedInbound?.reason ? `: ${result.sharedInbound.reason}` : ''}. It starts working once the project has an email handler deployed and routing is ready; check with \`void email alias\`.`,
);
}
}

/**
* The inbound status covers every name of the project, so name the address
* it belongs to when that is not the one shown (the slug row can be the one
* not ready yet).
*/
function inboundLine(shared: EmailSharedInbound | null | undefined, address: string): string {
if (!shared) {
return 'not set up on this platform';
}
return shared.recipient === address ? shared.status : `${shared.status} (${shared.recipient})`;
}

async function emailDestinations(client: PlatformClient, projectId: string): Promise<void> {
const rows = await client.listEmailDestinations(projectId);
if (rows.length === 0) {
Expand Down
13 changes: 13 additions & 0 deletions packages/void/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,10 @@ const HELP_TREE: HelpTree = {
{ command: 'destinations', description: 'List verified recipient addresses' },
{ command: 'allow <address>', description: 'Add a recipient and trigger CF verification' },
{ command: 'disallow <address>', description: 'Remove a recipient from the allowlist' },
{
command: 'alias [name]',
description: 'Show the shared mail address, or set a shorter permanent name for it',
},
{
command: 'domain <add|status|list|sync|rotate-secret|remove>',
description: 'Send and receive at your own domain on a Cloudflare zone',
Expand Down Expand Up @@ -915,6 +919,15 @@ const HELP_TREE: HelpTree = {
'Remove a recipient from the verified allowlist.',
'void email disallow <address> [options]',
),
alias: {
summary:
'Show the project address on the shared mail domain, or give it a shorter name. The alias is permanent; the slug address keeps working.',
usage: ['void email alias [options]', 'void email alias <name> [options]'],
options: [
{ command: '--project <name>', description: 'Project name override' },
{ command: '--yes', description: 'Set the alias without asking (it cannot be changed)' },
],
},
// The one nested group: verbs parse in email-args.ts (`domain <add|status|list|sync|rotate-secret|remove>`).
// Void-platform-only; custom inbound requires a zone apex.
domain: {
Expand Down
5 changes: 4 additions & 1 deletion packages/void/src/router/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1216,11 +1216,14 @@ ${envValidateCall}${remoteEnvSwap}${isrDeploymentStamp} const userEnv = __filte
? `import { createMessageProxy as __createMessageProxy, enactEmailActions as __enactEmailActions, isForwardableEmailMessage as __isForwardableEmailMessage } from ${JSON.stringify(runtimeEmailPath)};\nimport { EMAIL_PROTOCOL_VERSION as __EMAIL_PROTOCOL_VERSION, encodeEmailActions as __encodeEmailActions, readEmailEnvelope as __readEmailEnvelope, verifyEmailReceiptThroughProxy as __verifyEmailReceiptThroughProxy } from "void/_email-protocol";\n`
: '';
// Custom-domain receipts (`viaDomain`) carry no `<slug>+` prefix, so they get no slug.
// On the shared domain the prefix is the slug or the project's email alias. The
// platform router already matched it to this project, so strip the name the
// recipient actually used.
const emailHandler = hasEmail
? `async function handleEmail(envelope, env, ctx) {
${envValidateCall}${remoteEnvSwap} const userEnv = __filterInternalBindings(env);
const __relayed = typeof envelope.viaDomain === "string" && envelope.viaDomain.length > 0;
const __slug = __relayed ? undefined : (typeof env.__VOID_PROJECT_SLUG === "string" && env.__VOID_PROJECT_SLUG.length > 0 ? env.__VOID_PROJECT_SLUG : undefined);
const __slug = __relayed || typeof env.__VOID_PROJECT_SLUG !== "string" || env.__VOID_PROJECT_SLUG.length === 0 ? undefined : (String(envelope.to || "").split("@")[0].split("+")[0] || env.__VOID_PROJECT_SLUG);
const { message, getActions } = __createMessageProxy(envelope, { slug: __slug });
await __withRawEnv(env, () => withRuntimeEnv(env, () => __emailHandler(message, userEnv, ctx, { slug: __slug })));
const __actions = getActions();
Expand Down
15 changes: 15 additions & 0 deletions packages/void/test/unit/cli-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,21 @@ describe('rendered help advertises the flags the parser honours', () => {
}
});

it('lists `email alias [name]` and shows both usages with --project and --yes', () => {
expect(render(['email'])).toContain('alias [name]');
expect(hasHelpPage(['email', 'alias'])).toBe(true);
expect(getHelpPath(['email', 'alias', 'short', '--yes', '--help'])).toEqual(['email', 'alias']);

const page = render(['email', 'alias']);
expect(page).toContain('void email alias [options]');
expect(page).toContain('void email alias <name> [options]');
expect(page).toContain('--project <name>');
expect(page).toContain('--yes');
expect(page).toContain('permanent');
expect(page).not.toContain('--limit');
expect(page).not.toContain('--platform');
});

// `status` / `setup` read the local project + wrangler session, never a Void project,
// so their pages carry `--platform cloudflare` and no `--project`.
it('shows --platform cloudflare, and no --project, on `email status` and `email setup`', () => {
Expand Down
Loading
Loading