Skip to content

Commit 3d8f848

Browse files
Brooooooklynclaude
andauthored
feat(email): permanent short alias on the shared mail domain (#553)
Fixes #511 ## What this adds A project owner can give the project one short, permanent name on the platform's shared mail domain: ```sh void email alias acme # asks to confirm; --yes skips the prompt void email alias # shows the address and inbound status ``` ``` before after (slug acme-store, alias acme) ────── ───── acme-store@mail.example.com acme-store@mail.example.com ← still works acme-store+support@… acme-store+support@… ← still works acme@mail.example.com ← new acme+support@… ← new ``` Both names send and receive. Handlers route the same way either way: `acme+support@…` and `acme-store+support@…` both reach `email/support.ts`. ## Answers to the three questions ### 1. Alias field, or another way? A field: `projects.email_alias` (nullable, migration 0062). ``` projects slug acme-store (unique, as before) email_alias acme (unique when set) one namespace: a trigger refuses alias = any slug slug = any alias alias = own slug ``` - The DB trigger (`void_project_mailbox_name_guard`, SQLSTATE 23505) keeps slugs and aliases apart in both directions, including on older runtimes after a rollback. - The alias follows the slug rules: shape, 56 characters, reserved names, no `preview-`. Names of five characters or fewer need the same plan permission as short slugs. - Only the owner can set it (`PUT /projects/:id/email/alias`). ### 2. Permanent, or changeable? Permanent. Set once, never changed or removed, and freed only when the project is deleted. - Reply addresses people have saved (`acme+ticket-42@…`) keep working. - No swap logic, so no window where mail bounces. - Because it is permanent, the route checks Cloudflare **before** saving: it refuses a name that an existing rule already routes (including plus addresses), and refuses when the domain has no room for the rules this project will need. ### 3. Does the old slug keep working? Yes, for sending and receiving. An alias project uses two Cloudflare literal rules: ``` Cloudflare Email Routing (shared domain) mail to acme-store+x@ ─▶ rule: acme-store@ ─┐ mail to acme+x@ ─▶ rule: acme@ ─┴─▶ email gateway ─▶ resolve-ingress │ finds project by slug OR alias │ checks readiness of THAT name ▼ proxy router ─▶ project worker (prefix must equal the name used) ``` - Default `from` stays `<slug>+noreply@…`. Apps that want the alias as sender set `from`. - Admission accepts `From` with either name. Replies and forwards can use either name, whatever address the mail came in on. - Deploys only wait for the slug rule. A failing alias rule shows in `void email alias` but never blocks a deploy. ## Rollback ``` upgrade ──▶ alias rules created rollback ─▶ older runtime ignores email_alias; its reconcile removes alias rules; slug mail keeps working upgrade ──▶ next reconcile restores the alias rows and rules (including a removal that stopped part way) ``` - `compatibility.json` needs no change: no release contains 0061, and the existing 1.1.11–1.2.1 entries now cover 0062 (covered by a test). - Known rollback gap, written down in `platform/meta/operations.md`: on an older runtime, creating a project whose slug equals an existing alias returns 500 (the trigger refuses it), not `slug already taken`. ## Rule limit Each receiving alias project uses one more rule out of Cloudflare's default 200 per domain. The admin docs say so. ## Tests - Migration: upgrade from the 1.1.11–1.2.1 schema, trigger refusals, older-runtime inserts. - Shared recipients: second rule, removed / half-removed row restore, per-name readiness, cron pickup, room and conflict checks, deploy not blocked by the alias. - Route: owner only, validation, plan check, taken, Cloudflare conflict, settings. - Ingress, admission, proxy router (mailbox compare, reply pins), gateway validation, generated `handleEmail` prefix strip. - CLI: args, help, the command. Local: typecheck, lint, fmt, `vp run test` (affected) pass. Browser tests can't run on this machine (TLS proxy blocks the Playwright download). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 02e7818 commit 3d8f848

43 files changed

Lines changed: 9158 additions & 134 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/guide/email/receiving.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ deployed `email/` handlers without per-project DNS setup once the platform has v
9494
handlers once its inbound readiness is `ready`. Use `void email domain status`
9595
to check current provider routing and any remaining setup steps.
9696

97+
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`.
98+
9799
For direct deployments, follow [Your own Cloudflare account](./domains.md#your-own-cloudflare-account) to configure inbound addresses.
98100

99101
The local dev server provides the outbound email inbox but has no inbound email trigger. Test inbound handlers with `createInboundTestHarness` below.

‎docs/guide/email/sending.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ You can send to at most 50 recipients across `to`, `cc`, and `bcc`. Use standard
8484

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

87-
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.
87+
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.
8888

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

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ These commands apply to a platform that enables email. Every project can send fr
1010

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

13+
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.
14+
1315
If recipient creation is interrupted, inspect its retained intent:
1416

1517
```sh

‎docs/reference/cli.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ Use this page as a command reference. If you are setting up a project for the fi
7373
| `void email destinations` | List verified recipient addresses |
7474
| `void email allow <address>` | Add a recipient and send a verification email |
7575
| `void email disallow <address>` | Remove a recipient from the allowlist |
76+
| `void email alias [name]` | Show the shared mail address, or give it a shorter permanent name |
7677
| `void email domain` | Send and receive at your own domain on a Cloudflare zone |
7778
| `void init` | Set up a new or existing project, including coding agents |
7879
| `void migrate` | Convert legacy `void.json` and root Wrangler JSON/JSONC into `void.config.ts` |

‎docs/reference/cli/email.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,19 @@ void email disallow <address> [--project <name>]
5050

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

53+
## `void email alias` {#void-email-alias}
54+
55+
```
56+
void email alias [--project <name>]
57+
void email alias <name> [--yes] [--project <name>]
58+
```
59+
60+
With no name, show the project's address on the platform's shared mail domain and whether inbound mail reaches it.
61+
62+
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.
63+
64+
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@…`).
65+
5366
## `void email domain` {#void-email-domain}
5467

5568
```

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ if (helpPath) {
321321
},
322322
{
323323
name: 'email',
324-
hint: 'usage | logs | destinations | allow | disallow | domain | status | setup',
324+
hint: 'usage | logs | destinations | allow | disallow | alias | domain | status | setup',
325325
description: 'Inspect email activity and manage verified recipients',
326326
},
327327
{

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -438,12 +438,24 @@ export type EmailSettingsResponse = {
438438
projectSlug?: string;
439439
};
440440
recipientPolicy?: { mode: 'verified' | 'domains' | 'any'; domains: Array<string> };
441-
sharedInbound?: {
442-
recipient: string;
443-
status: string;
444-
reason: string | null;
445-
observedAt: string | null;
446-
} | null;
441+
/** A second, permanent name on the shared mail domain; the slug keeps working. */
442+
emailAlias?: string | null;
443+
/** The platform's shared mail domain, or null when it has none. */
444+
sharedDomain?: string | null;
445+
sharedInbound?: EmailSharedInbound | null;
446+
};
447+
448+
export type EmailSharedInbound = {
449+
recipient: string;
450+
status: string;
451+
reason: string | null;
452+
observedAt: string | null;
453+
};
454+
455+
export type EmailAliasResponse = {
456+
emailAlias: string;
457+
address: string;
458+
sharedInbound: EmailSharedInbound | null;
447459
};
448460

449461
export type EmailDomainResponse = {
@@ -1923,6 +1935,14 @@ export class PlatformClient {
19231935
);
19241936
}
19251937

1938+
async setEmailAlias(projectId: string, alias: string): Promise<EmailAliasResponse> {
1939+
return this.json(
1940+
`${this.baseUrl}/projects/${projectId}/email/alias`,
1941+
'Failed to set the email alias',
1942+
{ method: 'PUT', body: { alias }, permission: 'settings' },
1943+
);
1944+
}
1945+
19261946
async listEmailDomains(projectId: string): Promise<Array<EmailDomainResponse>> {
19271947
return this.json(
19281948
`${this.baseUrl}/projects/${projectId}/email/domains`,

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ export const emailSubcommands: Array<SubcommandDef> = [
1212
{ name: 'destinations', description: 'List verified recipient addresses for outbound mail' },
1313
{ name: 'allow', arg: '<address>', description: 'Add a recipient and trigger CF verification' },
1414
{ name: 'disallow', arg: '<address>', description: 'Remove a verified recipient' },
15+
{
16+
name: 'alias',
17+
arg: '[name]',
18+
description: 'Show the shared mail address, or set a shorter permanent name for it',
19+
},
1520
// The verb is collected as the arg so `domain list` completes in the interactive prompt;
1621
// `domain add` then errors naming the missing <domain>, like a typed invocation.
1722
{
@@ -38,6 +43,7 @@ const EMAIL_SUBCOMMANDS = [
3843
'destinations',
3944
'allow',
4045
'disallow',
46+
'alias',
4147
'domain',
4248
'status',
4349
'setup',
@@ -57,6 +63,8 @@ export interface EmailArgs {
5763
address?: string;
5864
/** `cloudflare` is the developer's own account; `void` (the default) is the Void platform. */
5965
platform?: 'cloudflare' | 'void';
66+
/** `void email alias <name> --yes` sets the permanent alias without a prompt. */
67+
yes?: boolean;
6068
}
6169

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

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

99108
const rest = args.slice(1);
100109
for (let i = 0; i < rest.length; i++) {
@@ -127,6 +136,8 @@ export function parseEmailArgs(args: Array<string>): EmailArgs {
127136
}
128137
platformValues.push(value);
129138
i = next;
139+
} else if (rest[i] === '--yes' && subcommand === 'alias') {
140+
yes = true;
130141
} else if (rest[i] === '--backend' || rest[i]?.startsWith('--backend=')) {
131142
throw new Error("email: Unknown option '--backend'.");
132143
} else if (rest[i] === '--subdomain' || rest[i]?.startsWith('--subdomain=')) {
@@ -197,7 +208,7 @@ export function parseEmailArgs(args: Array<string>): EmailArgs {
197208
}
198209
}
199210

200-
return { subcommand, projectSlug, limit, address, platform };
211+
return { subcommand, projectSlug, limit, address, platform, ...(yes ? { yes } : {}) };
201212
}
202213

203214
/**

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

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
PlatformClient,
1515
type EmailDomainResponse,
1616
type EmailOperationResponse,
17+
type EmailSharedInbound,
1718
} from './client.ts';
1819
import {
1920
createDeployWranglerRunner,
@@ -49,7 +50,7 @@ export async function runEmailCommand(root: string, args: EmailArgs): Promise<vo
4950
}
5051
if (args.subcommand === 'setup') {
5152
log.error(
52-
'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.',
53+
'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.',
5354
);
5455
process.exit(1);
5556
}
@@ -81,6 +82,8 @@ export async function runEmailCommand(root: string, args: EmailArgs): Promise<vo
8182
return emailAllow(client, projectId, args.address!);
8283
case 'disallow':
8384
return emailDisallow(client, projectId, args.address!);
85+
case 'alias':
86+
return emailAlias(client, projectId, args.address, { yes: args.yes === true });
8487
case 'domain':
8588
return runEmailDomainCommand(client, projectId, args);
8689
}
@@ -98,6 +101,74 @@ async function emailUsage(client: PlatformClient, projectId: string): Promise<vo
98101
note(lines.join('\n'), 'Email Usage');
99102
}
100103

104+
export async function emailAlias(
105+
client: Pick<PlatformClient, 'getEmailSettings' | 'setEmailAlias'>,
106+
projectId: string,
107+
name: string | undefined,
108+
opts: { yes: boolean; isInteractive?: boolean } = { yes: false },
109+
): Promise<void> {
110+
if (!name) {
111+
const settings = await client.getEmailSettings(projectId);
112+
const shared = settings.sharedInbound;
113+
const domain =
114+
settings.sharedDomain ??
115+
(shared ? shared.recipient.slice(shared.recipient.lastIndexOf('@') + 1) : null);
116+
const name = settings.emailAlias ?? settings.domains.projectSlug;
117+
if (!domain || !name) {
118+
log.info('This platform has no shared mail domain.');
119+
return;
120+
}
121+
const address = `${name}@${domain}`;
122+
const lines = [`Address: ${address}`, `Inbound: ${inboundLine(shared, address)}`];
123+
if (settings.emailAlias && settings.domains.projectSlug) {
124+
lines.push(`Also: ${settings.domains.projectSlug}@${domain} (the project slug)`);
125+
}
126+
note(lines.join('\n'), 'Shared mail address');
127+
if (!settings.emailAlias) {
128+
log.info('Run `void email alias <name>` to give this address a shorter, permanent name.');
129+
}
130+
return;
131+
}
132+
if (!opts.yes) {
133+
if (!(opts.isInteractive ?? (process.stdin.isTTY && process.stdout.isTTY))) {
134+
log.error('email: the alias is permanent. Pass --yes to set it without a prompt.');
135+
process.exit(1);
136+
}
137+
const ok = await confirm({
138+
message: `Set the permanent email alias "${name}"? It cannot be changed or removed later; the slug address keeps working.`,
139+
initialValue: false,
140+
});
141+
if (isCancel(ok) || !ok) {
142+
log.info('Cancelled. No alias was set.');
143+
return;
144+
}
145+
}
146+
const result = await client.setEmailAlias(projectId, name);
147+
printSuccess(`Email alias set: ${result.address}`);
148+
const status = result.sharedInbound?.status;
149+
if (status && status !== 'ready') {
150+
const other =
151+
result.sharedInbound!.recipient === result.address
152+
? ''
153+
: ` at ${result.sharedInbound!.recipient}`;
154+
log.info(
155+
`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\`.`,
156+
);
157+
}
158+
}
159+
160+
/**
161+
* The inbound status covers every name of the project, so name the address
162+
* it belongs to when that is not the one shown (the slug row can be the one
163+
* not ready yet).
164+
*/
165+
function inboundLine(shared: EmailSharedInbound | null | undefined, address: string): string {
166+
if (!shared) {
167+
return 'not set up on this platform';
168+
}
169+
return shared.recipient === address ? shared.status : `${shared.status} (${shared.recipient})`;
170+
}
171+
101172
async function emailDestinations(client: PlatformClient, projectId: string): Promise<void> {
102173
const rows = await client.listEmailDestinations(projectId);
103174
if (rows.length === 0) {

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,10 @@ const HELP_TREE: HelpTree = {
871871
{ command: 'destinations', description: 'List verified recipient addresses' },
872872
{ command: 'allow <address>', description: 'Add a recipient and trigger CF verification' },
873873
{ command: 'disallow <address>', description: 'Remove a recipient from the allowlist' },
874+
{
875+
command: 'alias [name]',
876+
description: 'Show the shared mail address, or set a shorter permanent name for it',
877+
},
874878
{
875879
command: 'domain <add|status|list|sync|rotate-secret|remove>',
876880
description: 'Send and receive at your own domain on a Cloudflare zone',
@@ -915,6 +919,15 @@ const HELP_TREE: HelpTree = {
915919
'Remove a recipient from the verified allowlist.',
916920
'void email disallow <address> [options]',
917921
),
922+
alias: {
923+
summary:
924+
'Show the project address on the shared mail domain, or give it a shorter name. The alias is permanent; the slug address keeps working.',
925+
usage: ['void email alias [options]', 'void email alias <name> [options]'],
926+
options: [
927+
{ command: '--project <name>', description: 'Project name override' },
928+
{ command: '--yes', description: 'Set the alias without asking (it cannot be changed)' },
929+
],
930+
},
918931
// The one nested group: verbs parse in email-args.ts (`domain <add|status|list|sync|rotate-secret|remove>`).
919932
// Void-platform-only; custom inbound requires a zone apex.
920933
domain: {

0 commit comments

Comments
 (0)