diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index da7972956..63e16f9e9 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -227,6 +227,7 @@ export default extendConfig( collapsed: false, items: [ { text: 'AI', link: '/guide/ai' }, + { text: 'Email', link: '/guide/email' }, { text: 'Sandboxes', link: '/guide/sandboxes' }, { text: 'Authentication', link: '/guide/auth' }, { text: 'WebSockets', link: '/guide/websockets' }, diff --git a/docs/guide/email.md b/docs/guide/email.md new file mode 100644 index 000000000..d3a726086 --- /dev/null +++ b/docs/guide/email.md @@ -0,0 +1,609 @@ +--- +outline: deep +--- + +# Email + +Send transactional email from your app via [Cloudflare's `send_email` binding](https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/). Import `sendEmail` from `void/email` and Void handles MIME construction, binding inference, and a local-dev inbox. + +```ts +import { sendEmail } from 'void/email'; + +const result = await sendEmail({ + from: 'Acme ', + to: 'user@example.com', + subject: 'Welcome', + text: 'Thanks for signing up!', + // html: '

Thanks for signing up!

', // optional — sent as multipart/alternative when paired with text +}); + +if (!result.ok) { + if ('error' in result) { + // Nothing was sent — the whole call failed before delivery. + console.error(result.error.code, result.error.message); + } else { + // Some recipients failed. `deliveries` says which. + for (const d of result.deliveries.filter((d) => !d.ok)) { + console.error(d.recipient, d.error.code, d.error.message); + } + } +} +``` + +Both branches are real. `result.error` exists only on the first, so reading it +unconditionally throws on the second — and the second is the one a new project +hits first, because a recipient you have not verified yet fails per-recipient. + +## Setup + +Zero config on the Void platform (`void deploy`). Every Void project ships with: + +- **Sender** — `+noreply@mail.void.cloud`. Used as the default `from` if you omit it. The platform owns the zone with Email Routing + DKIM + SPF + DMARC set up; you do nothing. Project slugs are capped at 56 characters so this local part fits RFC 5321's 64 octets; a project created before the cap with a longer slug must pass `from` explicitly. +- **No worker binding to add** — outbound mail is sent by the Void proxy, which holds the + platform `send_email` binding. Your worker never gets one, so there is nothing to configure. +- **Your own address as a recipient** — the email on your Void account is registered as a recipient when the project is created. It is verified at once when Cloudflare already holds it verified for the platform (you clicked its link for an earlier project of yours); otherwise Cloudflare mails it a verification link, and until you click that link and run `void email destinations` — the listing is what records the click — a send to yourself comes back `ok: false` with a per-recipient `UNVERIFIED_DESTINATION` in `result.deliveries`. + +That's it for configuration. Delivery is gated separately: outbound mail only reaches verified recipients, so `sendEmail({ to: '...', subject: '...', text: '...' })` works on first deploy for your own address once it is verified, and for anyone else after `void email allow` — see [Adding recipients](#adding-recipients). + +Deploying to your own Cloudflare account instead (`void deploy --platform cloudflare`) takes one line of `void.json` and one Enter on the first deploy — see [Your own Cloudflare account](#your-own-cloudflare-account). + +## Adding recipients + +Cloudflare's `send_email` binding only delivers to addresses you've registered as recipients. Add them with the CLI: + +```sh +void email allow user@acme.com +``` + +Cloudflare emails the recipient with a verification link. Once they click it and `void email destinations` has picked the click up, you can send to that address from your project. If the link did not arrive or has expired, run `void email allow
` again while the address is still pending — the CLI re-sends the link, or tells you how to get a fresh one. List the project's recipients: + +```sh +void email destinations +``` + +The project owner's email (the GitHub address you signed up with) is added automatically when the project is created, so it skips `void email allow` — not the verification. See [Setup](#setup) for when it is verified at once and when there is a link to click. + +::: warning When this is the right fit +The shared sender is great for: ops alerts to the team, notifications to the project owner, reply-by-email flows on top of inbound, internal/app-internal mail. + +For SaaS sending to arbitrary end-users (every signup gets a welcome email), the per-recipient verification model doesn't fit. On [your own Cloudflare account](#your-own-cloudflare-account) with Workers Paid, Void onboards your mail domain for Email Sending, which lifts the verified-recipient gate. Otherwise use [Resend](https://resend.com), [Postmark](https://postmarkapp.com), or [SES](https://aws.amazon.com/ses/) directly — install their SDK and call it from your handler. We may formalize this with a provider abstraction later if there is demand; until then, calling the SDK directly is simple enough that the wrapper would not earn its keep. +::: + +## Options + +| Option | Type | Notes | +| ------------- | ---------------------------- | ---------------------------------------------------------------- | +| `from` | `string \| { email, name? }` | Optional. Pinned to the project sender — see below. | +| `to` | `Address \| Address[]` | Required. One or more recipients. | +| `subject` | `string` | Required. UTF-8 supported (encoded as RFC 2047). | +| `text` | `string` | At least one of `text` / `html` is required. | +| `html` | `string` | Sent as `multipart/alternative` if both are provided. | +| `replyTo` | `Address` | Optional `Reply-To` header. | +| `cc`, `bcc` | `Address \| Address[]` | Optional. Each recipient is sent its own message envelope. | +| `headers` | `Record` | Custom headers; reserved headers (From, Date, etc.) are ignored. | +| `attachments` | `Attachment[]` | See [Attachments](#attachments). | + +At most 100 recipients across `to`, `cc` and `bcc` per call. Each address is checked with [`email-validator`](https://www.npmjs.com/package/email-validator): an ASCII dot-atom local part (before the `@`) of at most 64 characters — no whitespace, control character or RFC 5322 special, no leading, trailing or doubled dot, and no quoted local part — and a dotted domain of ASCII labels (at most 63 characters each) whose TLD starts with a letter and is at least 2 characters, so `user@localhost` is refused and an IDN domain must be given as punycode (`xn--…`); at most 254 characters in total (RFC 5321: a 256-octet forward-path `` and a 64-octet local part are the longest every receiver must accept). The address itself carries no display-name syntax; a display name goes around it (`"Name "` or `{ email, name }`). All of these are checked before anything is built and return `INVALID_TO` (`INVALID_FROM` for `from`; `MIME_ERROR` for `cc`, `bcc` and `replyTo`). The platform's inbound router applies the same package to `forward()` and `reply()` addresses, so nothing a handler records is dropped there for its shape. Custom header names must be RFC 5322 field names (printable ASCII, no colon); a name too long to fit a 998-octet line — a field name cannot be folded — is rejected with `MIME_ERROR` before anything is built. + +`Address` accepts either a string (`"hello@acme.dev"` or `"Name "`) or an object (`{ email, name? }`). Display names with non-ASCII characters are RFC 2047 encoded automatically. + +On the platform the sender is pinned to your project. `from` must be your project's own platform address — `@mail.void.cloud` or `+@mail.void.cloud`, optionally with a display name (`Acme `). Anything else is rejected with `INVALID_FROM`. Omit `from` and Void fills in `+noreply@mail.void.cloud` for you. Sending from your own domain on the platform is not supported yet. + +On your own Cloudflare account, `from` defaults to `email.from` from `void.json` and must be on a domain your account can send from; Cloudflare rejects any other sender and `sendEmail` reports it as `INVALID_FROM`. + +## Attachments + +```ts +await sendEmail({ + from: 'Acme ', + to: 'user@example.com', + subject: 'Your receipt', + text: 'Receipt attached.', + attachments: [ + { + filename: 'receipt.pdf', + content: pdfBytes, // string | Uint8Array | ArrayBuffer | Blob + contentType: 'application/pdf', + }, + ], +}); +``` + +For inline images (e.g. logos referenced from HTML), set `disposition: 'inline'` and `contentId`: + +```ts +await sendEmail({ + from: 'Acme ', + to: 'user@example.com', + subject: 'Hello', + html: 'Acme', + attachments: [ + { + filename: 'logo.png', + content: logoBytes, + contentType: 'image/png', + contentId: 'logo', + disposition: 'inline', + }, + ], +}); +``` + +`contentType` is inferred from the filename extension when omitted; when given, it must be a valid media type (`type/subtype`, optionally followed by `; attribute=value` parameters — no `name`, which is set from `filename`), or `sendEmail` returns `MIME_ERROR`. `contentId` is the identifier the HTML references as `cid:`: letters, digits, the RFC 5322 `atext` symbols and dots, optionally with an `@domain` part and optionally in one pair of angle brackets (`logo`, `logo@acme.dev` and `` all render as `Content-ID: <…>`); anything else — whitespace, quotes, parentheses, a stray `<` or `>`, or an empty string — returns `MIME_ERROR`. Total message size (after base64 expansion) is capped at 10 MB to match Cloudflare's limit; oversize payloads return `MIME_ERROR` instead of failing upstream. + +## Result and errors + +`sendEmail` returns a discriminated union — there are no thrown errors: + +```ts +interface SendEmailDelivery { + recipient: string; // the envelope `To` used for this CF send + messageId: string; +} + +type SendEmailRecipientResult = + | { recipient: string; ok: true; messageId: string } + | { recipient: string; ok: false; error: SendEmailError }; + +type SendEmailResult = + | { ok: true; ids: SendEmailDelivery[] } + | { ok: false; error: SendEmailError } // pre-flight failure + | { ok: false; deliveries: SendEmailRecipientResult[] }; // mid-batch failure +``` + +`ids` carries one entry per envelope send. Because the CF `EmailMessage` envelope is single-recipient, multi-recipient calls (`to`/`cc`/`bcc`) fan out into one CF send per unique address, each with its own messageId. Addresses are matched case-insensitively across the three fields and `recipient` is the lowercased address; a capture under `void dev` or the test harness reports the same list. + +There are three discriminated cases: + +- **All success** (`ok: true`) — every recipient delivered. +- **Pre-flight failure** (`ok: false`, has `error`) — validation / MIME / missing binding rejected the call before any sends were attempted. Retrying the whole call is safe. +- **Mid-batch failure** (`ok: false`, has `deliveries`) — sends were attempted and some failed. Each recipient has its own per-recipient outcome (`ok: true` with `messageId`, or `ok: false` with `error`). Retry only recipients with `ok: false` — re-sending to ones with `ok: true` will deliver duplicates. The list always names every recipient of the call; an incomplete or malformed list from the platform proxy is reported as a top-level `UPSTREAM_ERROR` (`result.error`), never as a partial `deliveries`. + +```ts +const result = await sendEmail({ to: ['a@x.dev', 'b@x.dev', 'c@x.dev'], ... }); +if (result.ok) { + // every recipient delivered +} else if ('error' in result) { + // pre-flight failure — retry the whole call +} else { + // mid-batch failure — retry only the failed recipients + const toRetry = result.deliveries.filter((d) => !d.ok).map((d) => d.recipient); +} +``` + +Error codes: + +| Code | Meaning | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `BINDING_MISSING` | No email transport: neither the worker's own `SEND_EMAIL` binding (an own-account deploy without `email.from`) nor the platform proxy is available, or a Class B/C dev server. | +| `INVALID_FROM` | `from` was missing, malformed, not the project sender (platform), or a sender Cloudflare would not accept (own account). | +| `INVALID_TO` | `to` was empty, contained an invalid or over-long address, or the call had more than 100 recipients. | +| `UNVERIFIED_DESTINATION` | Cloudflare rejected the recipient as unverified. | +| `MIME_ERROR` | Failed to build the MIME message (oversize attachments, malformed input). | +| `QUOTA_EXCEEDED` | Platform quota for outbound email reached (retry next billing period), or Cloudflare's own rate or daily limit on the sending account — the platform's, or your own. | +| `UPSTREAM_ERROR` | Other binding failure; the original error is attached as `error.cause`. | + +Which branch carries the code depends on when the send failed. +`BINDING_MISSING`, `INVALID_FROM`, `INVALID_TO` and `MIME_ERROR` are pre-flight, +so they arrive as a top-level `result.error`. `UNVERIFIED_DESTINATION` is always +per-recipient and therefore only ever appears inside `result.deliveries` — +never as `result.error`. `QUOTA_EXCEEDED` and `UPSTREAM_ERROR` can arrive either +way. Narrow with `'error' in result` rather than assuming. + +## Local development + +During `void dev`, sends are captured to an in-memory inbox instead of going to Cloudflare. The dev server prints the inbox URL when it starts: + +``` +[void] Email inbox: http://localhost:5173/__void/inbox?token= +``` + +The inbox shows a list view with subject, sender, recipient, and timestamp. Click a message to see headers, attachments, and an HTML preview rendered in a sandboxed iframe. Each message can be downloaded as a `.eml` file. + +Every inbox route requires the local dev trigger token — the same token `/__void/scheduled` and `/__void/queue` use, stored in `.void/dev-trigger-token` (created owner-only, `0600`; an older file with wider permissions is tightened on the next `void dev`). The browser carries it as `?token=` (the printed URL and every link inside the inbox include it); curl sends it as the `x-void-dev-trigger` header. A request without it gets a `401`. Vite's host check only blocks DNS-rebinding hostnames, so without the token anyone who can reach the port — every peer on the network under `--host` — could read raw MIME (including Bcc recipients and attachments), clear the buffer, or plant messages. + +```bash +# download a message as .eml +curl http://localhost:5173/__void/inbox//raw \ + -H "x-void-dev-trigger: " -o message.eml + +# clear the inbox +curl -X DELETE http://localhost:5173/__void/inbox \ + -H "x-void-dev-trigger: " +``` + +The buffer holds the most recent 100 messages and survives HMR but not a full server restart. No disk persistence. + +Under the hood, your code runs in workerd (a separate process from the Vite dev server), so `sendEmail` hands each captured message to the dev server over the worker's `assets` binding, which Vite wires back into its own middleware. Void apps always have that binding, and so do apps built on a Class A framework (TanStack Start, React Router) once their wrangler config declares one. + +The dev inbox is **not** available under a Class B or C framework — SvelteKit, Nuxt, Analog, Astro. Those adapters own their own dev server and worker build, so Void never installs the Cloudflare Vite plugin for them and has nowhere to register the inbox. Declaring an `assets` binding does not help: SvelteKit, Nuxt and Analog do not run your code in a workerd instance fronted by Vite at all, so there is no loopback to bind to. Sends from those apps return `BINDING_MISSING`. Use `createEmailTestHarness` from `void/email/testing` instead, which captures in-process and works everywhere. When the inbox is configured but unreachable, `sendEmail` returns `UPSTREAM_ERROR` — nothing is captured and nothing is sent. + +`sendInDev: true` bypasses the dev inbox for a single send. `void dev` binds no send transport at all — the platform's `__VOID_PROXY` service binding is added only on a deployed worker, and the own-account `SEND_EMAIL` binding is stripped under `serve` so miniflare cannot write stray `.eml` files or send real mail (only that entry: a `send_email` binding of your own under another name is left exactly as `wrangler.jsonc` declares it). With the inbox skipped there is nothing left to fall through to, so the call returns `BINDING_MISSING`: it proves the inbox was bypassed, it does not deliver. To verify real delivery, deploy and send from the deployed worker. + +```ts +const result = await sendEmail({ + from: 'acme+noreply@mail.void.cloud', + to: 'verified@acme.dev', + subject: 'Skips the dev inbox', + text: 'Not captured — and not delivered either.', + sendInDev: true, +}); +// Under `void dev`: result.error.code === 'BINDING_MISSING' +``` + +## Testing + +Use the test harness to assert on outgoing messages without mocking the binding: + +```ts +import { describe, it, expect } from 'vitest'; +import { sendEmail } from 'void/email'; +import { createEmailTestHarness } from 'void/email/testing'; + +describe('signup flow', () => { + it('sends a welcome email', async () => { + const inbox = createEmailTestHarness(); + + await sendEmail({ + from: 'acme+noreply@mail.void.cloud', + to: 'user@example.com', + subject: 'Welcome', + text: 'Hi!', + }); + + expect(inbox.messages).toHaveLength(1); + expect(inbox.messages[0].subject).toBe('Welcome'); + inbox.dispose(); + }); +}); +``` + +The harness intercepts every `sendEmail` call until `dispose()` is called. `clear()` empties the captured list without releasing the sink. + +## Inbound + +Inbound mail is a **Void-app-only** feature. Receive it by dropping handlers in the top-level `email/` directory — alongside `crons/` and `queues/`. Void detects them, generates the worker's `email()` export, and dispatches incoming messages by recipient address. + +Under a third-party framework — TanStack Start, React Router, SvelteKit, Nuxt, Analog, Astro — Void wires only crons and queues into the framework's own worker entry, so there is nowhere to attach an `email()` export. An `email/` directory in those projects is a build error, not silently dead code. Outbound is unaffected: `sendEmail` is a binding, and it works in every framework. + +The same holds for `target: "node" | "bun" | "deno"`: those builds expose HTTP only and never invoke an `email()` export, so an `email/` directory fails the build with guidance. + +```ts +// email/_default.ts — fallback for any unmatched recipient +import { defineEmail, parseEmail } from 'void/email'; + +export default defineEmail(async (message, env, ctx) => { + const parsed = await parseEmail(message); + + if (parsed.subject?.startsWith('STOP')) { + message.setReject('Use the unsubscribe link.'); + return; + } + + await message.forward('archive@example.com'); +}); +``` + +The handler receives Cloudflare's native `ForwardableEmailMessage` plus an optional fourth `info` arg with `params` populated for dynamic / tagged route segments: + +| Method | Purpose | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `message.setReject(reason)` | Reject the message — the sender sees the SMTP error. The reason is cut to 1000 characters and control characters become spaces; an empty one reads `rejected by handler`. | +| `message.forward(to, hdrs?)` | Forward to a verified destination address. `to` is a bare address; one `sendEmail` would refuse throws `INVALID_TO` at the call. `hdrs` holds at most 32 headers, each name and value at most 1024 characters; more throws `MIME_ERROR` at the call. | +| `replyEmail(message, opts)` | Reply with a threaded `In-Reply-To` / `References`. | +| `message.reply({ from, to, raw })` | Bring your own MIME bytes (`Uint8Array` or stream). `from` and `to` are bare addresses, checked at the call like `forward`. A native `EmailMessage` cannot be replayed — its body has no public accessor. | +| Returning without action | Accept the message silently. | + +`parseEmail(message)` wraps [`postal-mime`](https://www.npmjs.com/package/postal-mime) and returns a structured object with `from`, `to`, `subject`, `text`, `html`, `attachments`, and full `headers`. Call it at most once per message — the underlying stream can only be read once. + +### Per-recipient routing + +``` +email/ + support.ts — handles support@ + billing.ts — handles billing@ + support+[ticket].ts — handles support+ticket-123@... → info.params.ticket = "ticket-123" + support+vip.ts — handles exactly support+vip@..., ahead of support+[ticket].ts + [user].ts — dynamic local-part → info.params.user + [user]+[tag].ts — dynamic + subaddress → info.params.user, info.params.tag + _default.ts — fallback when no per-address pattern matches +``` + +Files are matched against the local-part of the `To` address (the portion before `@`), case-insensitive. **Match precedence (most specific wins):** + +1. `support+vip.ts` (static + literal tag) +2. `support+[ticket].ts` (static + captured tag) +3. `support.ts` (static) +4. `[user]+vip.ts` (dynamic + literal tag) +5. `[user]+[tag].ts` (dynamic + captured tag) +6. `[user].ts` (dynamic) +7. `_default.ts` (fallback) + +The first pattern that matches wins. Within a shape, a literal tag beats a captured tag — `support+vip@` reaches `support+vip.ts`, and every other `support+…@` reaches `support+[ticket].ts`. Handlers of the same shape are tried alphabetically. + +If nothing matches and there's no `_default.ts`, control returns to Cloudflare and the sender receives a non-delivery report. Nested folders under `email/` are ignored — the recipient is a flat string, not a path. + +### Threaded replies + +```ts +// email/support+[ticket].ts +import { defineEmail, parseEmail, replyEmail } from 'void/email'; + +export default defineEmail(async (message, env, ctx, info) => { + const parsed = await parseEmail(message); + await ticketStore.append(info!.params.ticket, parsed.text ?? ''); + + await replyEmail(message, { + text: `Got your message on ticket ${info!.params.ticket}.`, + }); +}); +``` + +`replyEmail` builds the reply MIME with `Subject: Re: ` (no double-prefix), `In-Reply-To: `, and a continued `References` chain, then dispatches through the message's `reply()`. Defaults: `to = message.from`, `subject = "Re: "`. + +Everything taken from the inbound message is best-effort, because the remote sender controls it. A `Message-ID` or `References` value too long for a header line is dropped rather than threaded, and a `References` chain over 8 KiB keeps only its newest ids — the parent's `Message-ID` always ends it. Control characters in the subject become a space, and a subject over 4096 characters, or an ASCII one too long to fold, falls back to a bare `Re:`. None of these fallbacks suppresses the reply; pass `subject` to control it exactly. + +On the platform the `from` default is **not** `message.to`. A reply is sent from +your project's own slug on the platform mail zone — `+noreply@` — +built from the `__VOID_PROJECT_SLUG` and `__VOID_EMAIL_DOMAIN` bindings the +platform injects. `replyEmail` throws when the slug is set but the domain is +absent and you omitted `from`, rather than guessing a sender. With no slug at +all — a worker on your own Cloudflare account — the reply is sent from +`message.to`, the address on your own zone the message was delivered to. + +::: warning The platform pins the reply sender +You may override `from`, but only with an address on your own slug. The proxy +accepts a reply sender whose local-part before the first `+` equals the slug the +message was delivered to, on the zone it arrived on. Anything else is **dropped +silently** — `replyEmail` still resolves, and the only trace is in the platform's +log stream, not your project's. + +This is what stops one project replying as another project's address. Note that +`message.to` is the _stripped_ address (`@`), so `from: message.to` +is exactly the shape the pin rejects. +::: + +::: warning The platform gates the reply recipient +A reply's `to` goes through the same gate as `sendEmail()` and `forward()`: it +must be one of your project's verified destinations — +`void email allow
`, then the recipient's verification click. A reply +to any other address is **dropped silently**: the inbound message is still +accepted, `replyEmail` still resolves, nothing reaches your worker, and the +only trace is in the platform's log stream, not your project's. + +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. +::: + +Note the asymmetry with `sendEmail`: `sendEmail` returns an error result and +never throws, while `replyEmail` throws when it cannot determine a sender. + +### Configuring inbound delivery + +In production, inbound runs on one shared mail facility. The platform routes `+anything@` to your worker's `email()` export — **there is nothing to configure in the Cloudflare dashboard and no per-project DNS work**. Your handlers are live as soon as the deploy lands. + +On your own Cloudflare account there is no shared facility: the deploy derives one Email Routing rule per handler and writes it into `wrangler.jsonc` for you — see [Your own Cloudflare account](#your-own-cloudflare-account). + +There is no local inbound trigger yet — `void dev` serves the outbound dev inbox only, so test inbound handlers with `createInboundTestHarness` below. + +### Testing inbound handlers + +Use `createInboundTestHarness` to run handlers through the real dispatcher with a synthesized message and capture replies / forwards / rejects. + +`deliver({ to })` takes the address **as it arrives on the wire**, which is +`+@` — the same form the platform delivers to your worker. +The harness strips the `+` prefix before matching, exactly as the +generated dispatcher does on the platform, so `acme+support+abc-123@acme.dev` +is what matches `email/support+[ticket].ts`. Pass the user-facing +`support+abc-123@acme.dev` and the harness reads `support` as the slug, leaving +`abc-123` to match — which falls through to `_default`. A worker on your own +Cloudflare account receives no slug and matches the full local part; pass +`slug: null` to test that lane, and `support@mail.acme.com` reaches +`email/support.ts` while `support+abc-123@mail.acme.com` reaches +`email/support+[ticket].ts` with `abc-123`: + +```ts +const harness = createInboundTestHarness({ + slug: null, + routes: { support, 'support+[ticket]': ticket }, +}); +await harness.deliver({ from: 'a@x.dev', to: 'support+abc-123@mail.acme.com' }); +``` + +```ts +import { describe, it, expect } from 'vitest'; +import { createInboundTestHarness } from 'void/email/testing'; +import support from './email/support+[ticket]'; +import defaultHandler from './email/_default'; + +describe('email handlers', () => { + it('routes to support+[ticket] and extracts the tag', async () => { + const harness = createInboundTestHarness({ + routes: { 'support+[ticket]': support, _default: defaultHandler }, + }); + const result = await harness.deliver({ + from: 'user@example.com', + to: 'acme+support+abc-123@acme.dev', + subject: 'Help', + text: 'I have a problem', + }); + expect(result.handler).toBe('support+[ticket]'); + expect(result.params).toEqual({ ticket: 'abc-123' }); + }); + + it('rejects STOP messages via _default', async () => { + const harness = createInboundTestHarness({ routes: { _default: defaultHandler } }); + await harness.deliver({ + from: 'user@example.com', + to: 'acme+unknown@acme.dev', + subject: 'STOP', + text: 'bye', + }); + expect(harness.rejects).toEqual(['Use the unsubscribe link.']); + }); +}); +``` + +The harness uses the same precedence rules as the production dispatcher. Reserved key `_default` mirrors `email/_default.ts`. It also applies the platform's inbound admission limits before your handler runs: a message over 10 MiB, or carrying more than 1024 distinct headers, is recorded in `rejects` and the handler is not called — exactly what the platform does before dispatch. With `slug: null` there is no platform router in front of the worker, so neither limit applies. + +## Your own Cloudflare account + +`void deploy --platform cloudflare` sets email up on a zone **you** own, through your Cloudflare sign-in (`void cloudflare login`). You never handle a Cloudflare object — no zone id, no routing rule, no token scope. You type one address, read one checklist, and press Enter once. + +### Two things to type, once + +1. **`email.from` in `void.json`** — the default sender, and the domain Void sets up: + + ```json + { + "email": { + "from": "Acme " + } + } + ``` + + The host (`mail.acme.com`) must be a zone in the Cloudflare account your deploy is pinned to, or sit under one. Without `email.from`, a deploy that uses email prints `add "email": { "from": "you@mail.acme.com" } to void.json` and deploys without it. Void does not pick a zone for you — wrangler cannot list them — and it never writes `void.json`. + +2. **`void cloudflare login`**, or **`CLOUDFLARE_API_TOKEN`** set to a token with **Email Routing Edit** and **Email Sending Edit** (zone and account) alongside the deploy permissions. A browser session created by older Cloudflare tooling lacks the two email scopes; the deploy tells you to run `void cloudflare logout`, then `void cloudflare login` (one browser Allow) and skips the email step. A token's permissions cannot be listed up front, so a token that lacks one shows up as a row Void could not read (`unknown`), with the permission named. A Global API Key pair (`CLOUDFLARE_API_KEY`) is refused: it has no single bearer for the two calls wrangler has no command for. + +### The first deploy + +Before any of your project code runs, the deploy **reads** — the session, the zone, public DNS (MX on the mail domain and on the apex, `_dmarc`, `cf-bounce`), Email Routing status, the zone's subaddressing setting, the routing rules on your addresses, Email Sending, and what `wrangler.jsonc` already holds — and prints what it found and what it would change: + +``` + Email in use email/support+[ticket].ts · email/_default.ts · sendEmail() in 2 files + domain mail.acme.com (void.json email.from) + zone acme.com account Acme (f721b8e5…) · session dev@acme.com + apex MX aspmx.l.google.com left alone — mail lives on the subdomain + routing mail.acme.com not enabled · subaddressing off + sending mail.acme.com not onboarded + rules support@mail.acme.com → acme-support (absent) + binding SEND_EMAIL (absent) + sender noreply@mail.acme.com (vars.__VOID_EMAIL_FROM absent) + + This changes YOUR Cloudflare account. acme.com itself is not touched. + + enable Email Routing on mail.acme.com Cloudflare writes and locks 3 MX + 1 SPF record there + + turn on subaddressing for acme.com support+anything@ reaches support@ + + onboard mail.acme.com for Email Sending MX/SPF/DKIM on cf-bounce.mail.acme.com, _dmarc.mail.acme.com (p=reject) + + wrangler.jsonc send_email: [{ name: "SEND_EMAIL" }], addresses: ["support@mail.acme.com"], vars.__VOID_EMAIL_FROM: "noreply@mail.acme.com" + + routing rule (created on deploy) support@mail.acme.com → acme-support + ! email/_default.ts a catch-all exists only on an apex; other @mail.acme.com mail bounces + +◆ Set up email on mail.acme.com? ● Yes / ○ No +``` + +Enter (Yes is the default) applies only the `+` rows that are not ready, then the deploy continues as usual: the build, the version upload and activation, wrangler's trigger synchronization — which creates the routing rules from the `addresses` array wrangler now finds in your config and prints its own `Email Routing plan:` — and finally the address map: + +``` + ✔ deployed acme-support + inbound support@mail.acme.com → email/support+[ticket].ts + outbound sendEmail() from support@mail.acme.com +``` + +On the second deploy every row reads ready: no prompt, no account call, and wrangler reports `Email Routing rules are up to date.` Answer No, and the deploy continues without email. + +Two rows live in `wrangler.jsonc` rather than in your account, and a deploy that finds every account row ready reconciles them with a plain file write, no prompt: the `addresses` array is rewritten whenever it is not the current derivation (an entry pruned since, a worker rename, a new handler), and `vars.__VOID_EMAIL_FROM` follows a changed `email.from`. Routing rules are `wrangler deploy`'s own work, so a committed `addresses` entry whose rule does not exist yet — the state right after `void email setup` — still reads ready; the address map marks it `(rule created by this deploy)`. + +A subdomain is added to a zone that already routes. When Email Routing is **off** on the apex (`Enabled: false`), the subdomain step is not attempted at all — Void never enables routing on the apex from the subdomain path, since that would lock MX records over the apex's live mail — and the checklist prints the dashboard step (`Email → Settings → Subdomains → add mail.acme.com`) instead; `addresses` is withheld until routing on the subdomain reads ready. + +### Subdomain or apex + +Put mail on a **subdomain** (`mail.acme.com`) unless you have a reason not to. Cloudflare writes its MX and SPF records there and locks them; `acme.com` itself is not touched, so mail you already receive on the apex keeps flowing. The one rule Void enforces: **it never enables routing over live mail.** If the mail domain already has MX records that are not Cloudflare's, the email step stops: + +``` +acme.com already receives mail (aspmx.l.google.com). Void never enables routing over live mail. Use something@mail.acme.com. +``` + +The apex path (`email.from` on `acme.com` itself) is allowed with the same one Enter when the apex receives no mail yet (no MX records, or only Cloudflare's own). It is the only path with a catch-all — see the derivation table below. + +**Subaddressing** is a per-zone Cloudflare setting, and it is off by default. Until it is on, a rule for `support@mail.acme.com` does not match `support+T-42@mail.acme.com`. The checklist's `turn on subaddressing for acme.com` row flips it for the whole zone — on the apex and every subdomain — so `email/support+[ticket].ts` works the way it does on the platform. + +### What Void writes into `wrangler.jsonc` + +```jsonc +{ + "send_email": [{ "name": "SEND_EMAIL" }], + "vars": { "__VOID_EMAIL_FROM": "Acme " }, + "addresses": ["support@mail.acme.com"], +} +``` + +- **`send_email`** — the binding `sendEmail()` delivers through, one `EmailMessage` per recipient. It is written once routing is enabled or sending is onboarded, never on a bare account. An existing `send_email` entry under another name is refused with the rename — a second binding would be silent. +- **`__VOID_EMAIL_FROM`** — your `email.from`, so the worker knows its default sender. +- **`addresses`** — derived from your `email/` directory. `wrangler deploy` turns each entry into an Email Routing rule pointing at this worker (a literal address → this worker; `*@acme.com` → the zone's catch-all). Rules and the catch-all are wrangler's job; Void only derives the list. + +**Void owns the `addresses` array.** It is rewritten on every deploy from the current `email/` scan while every existing entry is still one Void derives, and deleted — never emptied to `[]`, which would remove every rule wrangler owns — when a later preflight finds routing not ready, with the reason printed (on every branch, including CI and a declined prompt; a stale array left in place would make wrangler's plan fail after the upload, on every deploy). Two consequences: + +- An address already routed to another worker or to a forwarding rule is **pruned** from the array and reported (`! support@mail.acme.com already routed to …; left alone, not in addresses`). wrangler's plan is never destructive on your account because of something Void derived. +- If the array holds entries Void did not derive, the deploy prints which ones and **skips the email step for that deploy** — the deploy itself continues and the array is left untouched. Remove them, or manage `addresses` by hand and leave `email.from` unset. + +Deleting a handler leaves its address in `addresses`: the next deploy names it in a `✘` row as an entry Void no longer derives, and skips the email step until you remove that entry from `wrangler.jsonc` by hand (the row says which). Once removed, wrangler's plan drops the rule with its own y/n (default No) in a terminal, an error in CI. Removing the last handler and every `sendEmail()` call leaves the whole setup in `wrangler.jsonc`; the deploy warns which addresses are still routed to a worker with no `email()` export and how to detach them, and deploys as-is. + +How handlers become addresses, with `email.from` on `mail.acme.com` under the zone `acme.com`: + +| Handler | Address on a subdomain | Address on the apex | +| --------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------- | +| `email/support.ts`, `email/support+[ticket].ts` | `support@mail.acme.com` | `support@acme.com` | +| `email/_default.ts` | **none** — a catch-all exists only on an apex | `*@acme.com` (the catch-all) | +| `email/[user].ts`, `email/[user]+[tag].ts` (dynamic local part) | **refused** — a dynamic local part needs a catch-all | `*@acme.com` (the catch-all) | + +On a subdomain, `email/_default.ts` gets no rule and never runs: mail to any other `@mail.acme.com` address bounces at Cloudflare. The checklist says so in a `!` row, and the handler is absent from the address map. + +Inside the worker, a message reaches your handlers exactly as addressed — `support+T-42@mail.acme.com` matches `email/support+[ticket].ts` with `info.params.ticket === "T-42"` — and `setReject`, `forward` and `replyEmail` act on the real message. `replyEmail` defaults `from` to `message.to`, the address on your zone the mail was delivered to. + +### Sending + +`sendEmail()` uses the worker's own `SEND_EMAIL` binding; there is no proxy hop and no platform quota — Cloudflare's own limits apply, reported as `QUOTA_EXCEEDED`. The `void email usage`, `logs`, `destinations`, `allow` and `disallow` commands are platform commands and do not apply here. + +Setup is per mail domain, not per direction: any use of email — an `email/` handler or a `void/email` import — sets the domain up for routing and sending together, so an app that only receives is still onboarded for Email Sending and still gets the binding; Cloudflare meters outbound per message, so a domain that never sends costs nothing, and `replyEmail` goes through Email Routing's own `message.reply()`, which needs no onboarding either way. + +Who you can send to depends on your Workers plan. Onboarding the mail domain for Email Sending is what allows **arbitrary recipients**, and it needs **Workers Paid** — billing is dashboard-only, so Void cannot do that for you. On Workers Free the onboarding row fails and the deploy prints: + +``` +Workers Paid needed for arbitrary recipients. Inbound works; sendEmail() to verified destinations only. +``` + +Everything else — routing, rules, the binding — still goes through, and the address map ends with `outbound sendEmail() from support@mail.acme.com (verified destinations only)`. Verified destinations are the addresses under **Email Routing → Destination addresses** in your Cloudflare dashboard; a handler that `forward()`s to a new address needs the same verification click. + +The refusal is remembered by the `send_email` binding that same run writes into `wrangler.jsonc`: Void writes the binding only after it has attempted the onboarding, so a committed binding next to a domain that is still not onboarded means "tried, refused". Later deploys ask nothing about it, `--require-email` passes, `void email status` reads the domain as set up (its sending row says `not onboarded — verified destinations only` and names the retry), and the address map keeps ending with `(verified destinations only)`. The deploy never retries the onboarding on its own. After upgrading to Workers Paid, run `void email setup --platform cloudflare` once: it asks `Onboard mail.acme.com for Email Sending?` and, on Yes, onboards the domain — from then on the map ends without the marker. + +If `_dmarc.mail.acme.com` or `cf-bounce.mail.acme.com` already has a TXT record, sending onboarding is refused — it writes its own `_dmarc` (`p=reject`) and DKIM records and Cloudflare would answer with a conflict. Inbound is unaffected; remove the records or keep sending off. + +### CI + +The email prompt follows wrangler's own interactivity rule: a CI environment as wrangler detects it, or stdin or stdout not a terminal, means non-interactive. A non-interactive deploy that finds something not ready prints the checklist and + +``` +deploy: email on mail.acme.com is not set up, and this shell cannot ask. +Run `void email setup --platform cloudflare` once locally, commit wrangler.jsonc, then redeploy — deploying without email. +``` + +then deploys **without** email. Pass `--require-email` to fail instead. Automatic resource provisioning is not an escape hatch: it creates D1/KV/R2/Queue/Hyperdrive resources, never a mail setup. To read the rows without deploying or being asked anything, run `void email status --platform cloudflare`. + +So the CI story is: run `void email setup --platform cloudflare` once on your machine (it runs the same preflight, checklist and prompt as the first deploy, then writes `wrangler.jsonc`, without deploying), commit `wrangler.jsonc`, and let CI run `void deploy --platform cloudflare --require-email`. With the binding and `addresses` committed, every row reads ready and nothing is asked — on Workers Free too, where the committed binding is what remembers the refused sending onboarding (see [Sending](#sending)). + +### If the subdomain step is refused + +Enabling routing on a subdomain and switching subaddressing on have no wrangler command, and neither does reading the subaddressing flag or telling a zone in another account from one not on Cloudflare. For those calls Void borrows your session's bearer through `wrangler auth token --json`, uses it inside one function, and drops it — nothing is stored, refreshed or written to disk (the child runs with `WRANGLER_WRITE_LOGS=false`, because wrangler would otherwise log the token). Every other read and write goes through Void's own pinned wrangler. + +When either call is refused, or the token cannot be read, the deploy does not fail. It prints what Cloudflare answered and the one-time dashboard step: + +``` +✘ routing mail.acme.com: Cloudflare answered 403 … + Cloudflare dashboard → your zone → Email → Settings → Subdomains → add the subdomain, then redeploy +``` + +and **withholds `addresses` for that run** — no routing rule is created, so nothing points at a worker on a subdomain that does not yet receive mail. Do the dashboard step once and redeploy; the routing row then reads ready and `addresses` is written. + +### What stays manual + +1. One Enter on the first deploy — it changes your account and locks DNS records. +2. `void cloudflare login` once — or a `CLOUDFLARE_API_TOKEN` with Email Routing Edit + Email Sending Edit (what a CI runner needs). +3. `email.from` typed once. +4. Workers Paid, if you need `sendEmail()` to arbitrary recipients. +5. A verification click when a handler `forward()`s to a new destination. +6. The Subdomains form in the dashboard, only if the subdomain step above is refused. +7. A y/n when a deploy would delete a routing rule — wrangler's own semantics. diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index 36f7491df..116208458 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -258,11 +258,12 @@ Direct deployment uses the top-level JSON/JSONC configuration; convert a `wrangl Void reads your app's configuration and source, then: 1. Checks the app type, selected account, and Cloudflare credentials. -2. Creates missing resources or reuses resources already in your account. -3. Builds the app and checks the generated Worker config, secrets, and migration history. -4. Preserves or creates the auth secret, applies pending migrations, and uploads keys marked `.secret()` as encrypted Worker secrets. -5. Uploads a Worker Version and checks that it is ready. -6. Sends traffic to the new version and applies routes, custom domains, cron schedules, and queue consumers. +2. If the app uses email (`sendEmail()` or `email/` handlers), reads your zone's email state and, before any project code runs, asks once to set it up. See [Your own Cloudflare account](../guide/email.md#your-own-cloudflare-account) in the email guide. +3. Creates missing resources or reuses resources already in your account. +4. Builds the app and checks the generated Worker config, secrets, and migration history. +5. Preserves or creates the auth secret, applies pending migrations, and uploads keys marked `.secret()` as encrypted Worker secrets. +6. Uploads a Worker Version and checks that it is ready. +7. Sends traffic to the new version and applies routes, custom domains, cron schedules, queue consumers, and Email Routing rules. The secret and migration checks need the built Worker, so they happen after provisioning and building. If a check fails, resources may already exist, but Void has not applied remote D1 migrations or uploaded the application Worker. @@ -274,6 +275,8 @@ Run the first deploy from one machine at a time. The provisioning lock protects Set `CLOUDFLARE_API_TOKEN` in your CI secrets. The token needs Workers Scripts: Edit and read access to bound resources, plus edit permission for each product Void needs to provision. Set `CLOUDFLARE_ACCOUNT_ID` if the token can access more than one account. +Email setup needs a browser session from `void cloudflare login`, which carries the Email Routing and Email Sending scopes, or a `CLOUDFLARE_API_TOKEN` that also has Email Routing Edit and Email Sending Edit. A Global API Key pair is refused. In CI the email step never prompts: run `void email setup --platform cloudflare` once locally, commit `wrangler.jsonc`, then deploy with `--require-email`. + When a Worker has no version preview URL, such as a Worker with Durable Objects, also set `CLOUDFLARE_WORKERS_SUBDOMAIN`. Use the account subdomain, for example `my-team` or `my-team.workers.dev`. Local deploys cache this value in ignored `.void/cloudflare.json` after Cloudflare reports a deployment URL; a fresh CI checkout has no such cache. #### Cloudflare Access @@ -335,6 +338,7 @@ Worker apps support D1, KV, R2, Queues, typed state, PostgreSQL and MySQL throug - **Worker apps need a fresh build.** `--skip-build` works for existing static, SPA, and SSG output. Worker validation needs the current build's vars and auth schema. - **Named Cloudflare environments aren't supported.** Direct commands use the top-level root config and reject `CLOUDFLARE_ENV`, `CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH`, and project-local name overrides. Use a separate root config per deployment target. - **Node.js, Bun, and Deno use a different deployment path.** See their [integration guide](./nodejs-bun-deno.md). +- **Email is set up on the first deploy, on a zone you own.** Put `email.from` in `void.json`; the deploy reads your account, prints a checklist of what it would change, and asks once. See [Your own Cloudflare account](../guide/email.md#your-own-cloudflare-account). Outside Void's reach: `email/_default.ts` and dynamic local parts (`email/[user].ts`) need a catch-all, which exists only on a zone apex, so on a mail subdomain they get no rule; a mail domain that already has non-Cloudflare MX records is refused, never routed over; `sendEmail()` to arbitrary recipients needs Workers Paid (Email Sending onboarding), otherwise verified destinations only; Void owns the top-level `addresses` array in `wrangler.jsonc`, so hand-written entries there skip the email step; and `void email usage`, `logs`, `allow`, and `destinations` are platform-only. WebSocket routes and typed state use SQLite-backed Durable Objects. Commit the bindings and `new_sqlite_classes` migration history in `wrangler.jsonc`; don't delete or reorder deployed migration steps. Older hosted classes using `new_classes` keep their existing storage. diff --git a/docs/integrations/nodejs-bun-deno.md b/docs/integrations/nodejs-bun-deno.md index 9eee93c9e..1f984d83a 100644 --- a/docs/integrations/nodejs-bun-deno.md +++ b/docs/integrations/nodejs-bun-deno.md @@ -123,6 +123,10 @@ Void-managed WebSocket route files (`*.ws.ts`) are still Cloudflare-only because You can still define [cron jobs](../guide/jobs.md) in `crons/` and they will compile into the bundle, but there is no built-in scheduler to invoke them. On Cloudflare, Workers Cron Triggers call the `scheduled` handler automatically. On Node.js, you'll need an external scheduler (e.g. `node-cron`, systemd timers, or your hosting platform's cron) to trigger the exported handler. +### No inbound email + +Inbound [email handlers](../guide/email.md#inbound) in `email/` need Cloudflare Email Routing to invoke the worker's `email()` export, and the Node.js entry exposes HTTP only. An `email/` directory fails the build with guidance. Outbound `sendEmail` has no send transport on these targets either: it returns a `BINDING_MISSING` error. + ### No prerendering [Prerendering](../guide/edge/prerendering.md) is a platform feature that relies on Cloudflare's edge cache and the Void deploy pipeline. It is not available for non-CF targets. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9b9f533fb..d0f68160b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -10,37 +10,44 @@ Use this page as a command reference. If you are setting up a project for the fi ## Cheat Sheet -| Command | Purpose | -| --------------------------------- | --------------------------------------------------------------- | -| `void deploy` | Build and deploy to the configured platform | -| `void prepare` | Generate `.void` artifacts without starting Vite | -| `void gen model [cols...]` | Scaffold migration + CRUD routes | -| `void gen route ` | Create an API route | -| `void db push` | Apply schema directly without migration files | -| `void db generate` | Generate SQL migrations from schema changes | -| `void db status` | Show local/remote migration status | -| `void db reset` | Drop and re-apply all migrations | -| `void db seed` | Reset + seed local database | -| `void db execute ` | Run SQL against the database (--remote for deployed) | -| `void db studio` | Open Drizzle Studio (--remote for a deployed external database) | -| `void secret put ` | Set a production secret | -| `void secret list` | List production secrets | -| `void secret sync .env.local` | Bulk upload secrets from dotenv file | -| `void env check [--remote]` | Validate env.ts schema | -| `void env types` | Regenerate .void/env.d.ts from env.ts | -| `void env example` | Refresh the void-managed block in .env.example | -| `void auth login` | Authenticate with Void | -| `void cloudflare login` | Authenticate with Cloudflare through Void | -| `void platform install` | Install a company Void platform in Cloudflare | -| `void connect ` | Connect the CLI to a Void platform | -| `void project link` | Link directory to a project | -| `void project logs` | Show runtime logs from deployed project | -| `void project requests` | Show request-level traffic (status, method, timing) | -| `void project rollback` | Roll back to a previous deployment | -| `void project cancel` | Cancel an active deployment | -| `void project purge-cache` | Purge all cached pages | -| `void build logs` | Stream, tail, or download build logs | -| `void init` | Setup wizard for new or existing projects | +| Command | Purpose | +| --------------------------------- | ----------------------------------------------------------------------------- | +| `void deploy` | Build and deploy to the configured platform | +| `void prepare` | Generate `.void` artifacts without starting Vite | +| `void gen model [cols...]` | Scaffold migration + CRUD routes | +| `void gen route ` | Create an API route | +| `void db push` | Apply schema directly without migration files | +| `void db generate` | Generate SQL migrations from schema changes | +| `void db status` | Show local/remote migration status | +| `void db reset` | Drop and re-apply all migrations | +| `void db seed` | Reset + seed local database | +| `void db execute ` | Run SQL against the database (--remote for deployed) | +| `void db studio` | Open Drizzle Studio (--remote for a deployed external database) | +| `void secret put ` | Set a production secret | +| `void secret list` | List production secrets | +| `void secret sync .env.local` | Bulk upload secrets from dotenv file | +| `void env check [--remote]` | Validate env.ts schema | +| `void env types` | Regenerate .void/env.d.ts from env.ts | +| `void env example` | Refresh the void-managed block in .env.example | +| `void auth login` | Authenticate with Void | +| `void cloudflare login` | Authenticate with Cloudflare through Void | +| `void platform install` | Install a company Void platform in Cloudflare | +| `void connect ` | Connect the CLI to a Void platform | +| `void project link` | Link directory to a project | +| `void project logs` | Show runtime logs from deployed project | +| `void project requests` | Show request-level traffic (status, method, timing) | +| `void project rollback` | Roll back to a previous deployment | +| `void project cancel` | Cancel an active deployment | +| `void project purge-cache` | Purge all cached pages | +| `void build logs` | Stream, tail, or download build logs | +| `void email status` | Show email readiness on your own Cloudflare account (`--platform cloudflare`) | +| `void email setup` | Set email up on your own Cloudflare account, without deploying | +| `void email usage` | Show monthly email send/receive counts and quota | +| `void email logs` | Show recent email delivery activity | +| `void email destinations` | List verified recipient addresses | +| `void email allow
` | Add a recipient and send a verification email | +| `void email disallow
` | Remove a recipient from the allowlist | +| `void init` | Setup wizard for new or existing projects | ## Binary Invocation @@ -578,7 +585,7 @@ See [Disable and safely uninstall](../guide/self-hosted-platform.md#disable-and- ``` void deploy [--project ] [--dir ] [--spa] [--skip-build] [--debug] -void deploy [--platform ] +void deploy [--platform ] [--require-email] ``` Auto-detects your project type and chooses the right pipeline. See [Supported App Types](../guide/app-types.md) and [Deployment](../guide/deployment.md) for details. @@ -589,14 +596,15 @@ The first handoff preserves production bindings, variables, secrets, event handl For Drizzle projects, deploy performs a read-only schema drift check. If a new migration would be generated, deploy stops and tells you to run `void db generate`, review the migration, commit it yourself, and rerun `void deploy`. -| Flag | Purpose | -| ------------------------------- | ------------------------------------------------------------------------------------ | -| `--platform ` | Override the platform stored in `.void/project.json` | -| `--project ` | Target a specific Void project by slug; not supported with `--platform cloudflare` | -| `--dir ` | Deploy a pre-built static directory (skips build) | -| `--spa` | Use SPA mode instead of SSG for static deploys | -| `--skip-build` | Skip the build step; on Cloudflare this is supported for static/SPA/SSG deploys only | -| `--debug` | Mirror the structured deploy log to stderr (also written to `~/.void/logs/`) | +| Flag | Purpose | +| ------------------------------- | -------------------------------------------------------------------------------------------------- | +| `--platform ` | Override the platform stored in `.void/project.json` | +| `--project ` | Target a specific Void project by slug; not supported with `--platform cloudflare` | +| `--dir ` | Deploy a pre-built static directory (skips build) | +| `--spa` | Use SPA mode instead of SSG for static deploys | +| `--skip-build` | Skip the build step; on Cloudflare this is supported for static/SPA/SSG deploys only | +| `--require-email` | Fail when email cannot be set up instead of deploying without it; requires `--platform cloudflare` | +| `--debug` | Mirror the structured deploy log to stderr (also written to `~/.void/logs/`) | The older `--backend cloudflare` spelling remains available as a compatibility alias for `--platform cloudflare`. @@ -642,6 +650,8 @@ For the Void platform, project resolution precedence is: If no project is linked and no override is provided, CLI prompts to link or create one. In CI (non-TTY), `void deploy` errors out instead — set `VOID_PROJECT` or pass `--project `. +A new project's slug is lowercase alphanumeric with interior dashes, at most 56 characters — it is also the project's email sender, `+noreply@`, and that local part must fit RFC 5321's 64 octets. Slugs of 5 characters or fewer need a paid plan. Creating a project also registers the owner's own email address as a recipient (see `void email allow`); the CLI says so, and `void email destinations` shows whether it is verified yet. + That fallback is mainly for projects that skipped Void project setup during `void init`. ### `void deploy --platform cloudflare` @@ -650,6 +660,7 @@ Build and deploy to your Cloudflare account using the root `wrangler.jsonc`: ```sh void deploy --platform cloudflare +void deploy --platform cloudflare --require-email # fail instead of deploying without email ``` Void signs you in through your browser when needed and saves the selected account. In CI, set `CLOUDFLARE_API_TOKEN` and, if the token can access several accounts, `CLOUDFLARE_ACCOUNT_ID`. @@ -661,13 +672,16 @@ Void signs you in through your browser when needed and saves the selected accoun | `--project` | Unavailable; the Worker and account come from the Cloudflare config | | Named environments | Unavailable; use the top-level root config | | `CLOUDFLARE_WORKERS_SUBDOMAIN` | Needed in fresh CI when versions have no preview URL; cached locally after a deploy | +| `--require-email` | Fail instead of deploying without email when the email step cannot run, as in CI | | `DATABASE_URL` | Required in the deploy environment for PostgreSQL or MySQL provisioning and migrations | The token needs Workers Scripts: Edit, read access to bound resources, and edit permissions for products Void provisions. First-time Hyperdrive provisioning specifically needs `CLOUDFLARE_API_TOKEN` with Hyperdrive edit permission, or an existing config ID in `wrangler.jsonc`. +Email setup needs a browser session from `void cloudflare login`, which carries the Email Routing and Email Sending scopes (a session created by older Cloudflare tooling lacks them: `void cloudflare logout`, then sign in again), or a `CLOUDFLARE_API_TOKEN` that also has Email Routing Edit and Email Sending Edit. A Global API Key pair is refused. + Sandbox apps need Docker, [Workers Paid](https://dash.cloudflare.com/?to=/:account/workers/plans), and Containers access. API tokens need Account / Containers: Edit and Account / Cloudchamber: Edit. Void checks access before provisioning or building; apps without Sandbox skip that check. -Void provisions inferred resources, builds and validates the app, applies migrations, uploads encrypted secrets, and checks the uploaded Worker Version before sending it traffic. Static, hybrid, and SSR output from supported frameworks is also supported. A brand-new Worker may need one ordinary deployment before the Versions API can be used. +Void provisions inferred resources, builds and validates the app, applies migrations, uploads encrypted secrets, and checks the uploaded Worker Version before sending it traffic. After activation it synchronizes routes, custom domains, cron triggers, queue consumers, and the Email Routing rules derived from `addresses`. Static, hybrid, and SSR output from supported frameworks is also supported. A brand-new Worker may need one ordinary deployment before the Versions API can be used. If Cloudflare Access protects readiness URLs, supply an allowed `CF_ACCESS_CLIENT_ID` and `CF_ACCESS_CLIENT_SECRET` pair, or a short-lived local `CF_ACCESS_TOKEN`. These credentials are used only for matching HTTPS readiness requests. Versions without accessible previews can be checked at 0% traffic through the stable hostname. @@ -677,6 +691,8 @@ Provisioning reuses known resource IDs and writes newly resolved IDs into `wrang Keys marked `.secret()` become encrypted Worker secrets; existing remote secrets are preserved. Void also preserves or creates `BETTER_AUTH_SECRET` for auth apps. Custom D1 migration layouts are accepted only when Cloudflare's file set, content, and order match the validated migrations. +**Email.** When the app uses email (`sendEmail()` or `email/` handlers) and `void.json` has `email.from`, the deploy reads the state of that address's zone before the build — session scopes, zone, MX records, Email Routing, subaddressing, routing rules, Email Sending, and what `wrangler.jsonc` holds — prints a checklist of what it would change in your account, and asks once (default Yes). On Yes it enables what is missing, writes `send_email: [{ "name": "SEND_EMAIL" }]`, the `__VOID_EMAIL_FROM` var and the `addresses` array into `wrangler.jsonc`, and lets wrangler create the routing rules when the activated version's triggers are synchronized; the deploy ends with the address map. A deploy with nothing left to set up asks nothing. Without `email.from` the deploy prints `add "email": { "from": "you@mail.acme.com" } to void.json` and continues without email. Non-interactive runs (CI, or stdin/stdout not a terminal) never prompt: they print the checklist plus `Run void email setup --platform cloudflare once locally, commit wrangler.jsonc, then redeploy` and deploy without email (or with the setup `wrangler.jsonc` already carries, when the binding is committed; a committed `addresses` array whose routing is off is removed first, since wrangler's plan on it would fail after the upload) — unless `--require-email` is passed, which fails instead. A deploy whose account rows all read ready reconciles the two config rows — `addresses` against the current derivation and `vars.__VOID_EMAIL_FROM` against `email.from` — with a plain file write and no prompt. See [Your own Cloudflare account](../guide/email.md#your-own-cloudflare-account) for the whole flow, including the subdomain-vs-apex rule and what stays manual. + See the [Cloudflare guide](../integrations/cloudflare.md#deploy-to-your-own-cloudflare-account) for the complete deployment sequence, first-deploy exceptions, secret precedence, and recovery behavior. ## Database @@ -1250,6 +1266,79 @@ Project resolution for domain commands follows the same order as deploy (`--proj For direct Cloudflare projects, status reports whether the route is present in the root config. It does not claim to inspect remote certificate issuance; Cloudflare owns that state and exposes it in the dashboard. `--project` is hosted-only. +## Email + +Inspect email usage and manage the recipients a project is allowed to send to. See [Email](../guide/email.md) for the runtime API. + +Project resolution for email commands follows the same order as deploy (`--project`, `VOID_PROJECT`, linked project). `void email setup` and `void email status --platform cloudflare` are the exception: they act on your own Cloudflare account through your Cloudflare sign-in (`void cloudflare login`) and need no Void project. + +### `void email usage` + +``` +void email usage [--project ] +``` + +Show the current month's outbound and inbound counts, the monthly outbound limit, and how much of it is left. A suspended project is flagged in the output. + +### `void email logs` + +``` +void email logs [--limit ] [--project ] +``` + +Show recent email activity — timestamp, direction, sender, recipient, status, and subject. `--limit` takes a positive integer. Email activity logs are not available yet on the platform; the command says so. Console output from your email handler appears in `void project logs`, like any other invocation of your worker. + +### `void email destinations` + +``` +void email destinations [--project ] +``` + +List the project's recipient addresses and their state (`verified`, `pending`, or `failed`). Outbound mail is only delivered to verified addresses. + +### `void email allow` + +``` +void email allow
[--project ] +``` + +Add one recipient to the project's destination list. Cloudflare emails that address a verification link — the recipient clicks it, with no Void or Cloudflare account required. Then run `void email destinations`: the listing is what records the click, and until it has, a send to that address returns `UNVERIFIED_DESTINATION` for that recipient. If the link did not arrive or has expired, run `void email allow
` again while the address is still pending — the CLI re-sends the link, or tells you how to get a fresh one. + +The project owner's email is added automatically when the project is created, so it skips this step but not the verification: unless Cloudflare already had it verified for an earlier project of yours, click the link it mailed and run `void email destinations`; until then a send to yourself returns `UNVERIFIED_DESTINATION` for that recipient. + +### `void email disallow` + +``` +void email disallow
[--project ] +``` + +Remove one recipient from the project's destination list. Sends to that address are refused within about a minute: the platform updates the project's allowlist as part of the command, and the proxy re-reads it every 60 seconds. No deploy is involved. + +### `void email status` + +``` +void email status --platform cloudflare +``` + +Read-only. Checks the email setup on your own Cloudflare account for the domain of `email.from` in `void.json` — session scopes, zone, MX records, Email Routing (and its subaddressing setting), Email Sending, the routing rule for every `email/` handler, and the `send_email` binding — then prints the status rows and the address map (`inbound
→ email/`, `outbound sendEmail() from `). Exits 1 when anything is not ready. A domain still not onboarded for Email Sending reads as set up once the `send_email` binding is committed — the binding is written only after an onboarding attempt, so that pair is how a Workers Free refusal is remembered — and the sending row says so (`not onboarded — verified destinations only; after upgrading to Workers Paid run void email setup --platform cloudflare`). Takes no `--project`: it reads the local project and your Cloudflare session, never a Void project. + +Without `--platform cloudflare` (or with `--platform void`) the command is not available yet; on the Void platform use `void email usage` and `void email destinations`. The older `--backend cloudflare` spelling remains available as a compatibility alias on `void email status` and `void email setup`, with the same rules as `void deploy`: at most once, and never together with `--platform`. + +### `void email setup` + +``` +void email setup --platform cloudflare +``` + +The same setup `void deploy --platform cloudflare` offers on its first deploy, on its own — for CI, which cannot press Enter: run it locally once, commit `wrangler.jsonc`, then let CI run `void deploy --platform cloudflare --require-email`. Needs `email.from` in `void.json` and a `void cloudflare login` session (a session created by older Cloudflare tooling lacks the email scopes: `void cloudflare logout`, then sign in again) or a `CLOUDFLARE_API_TOKEN` with Email Routing Edit + Email Sending Edit — the CI credential. It runs the preflight above, prints the checklist of what will change on your account, asks once, then: + +1. enables Email Routing on the domain (on an apex through Void's bundled Cloudflare tooling; a subdomain through your session's bearer, borrowed for that one call and dropped), +2. turns on subaddressing for the zone, so `support+anything@` reaches `support@`, +3. onboards the domain for Email Sending (a Workers Free account keeps inbound and sends to verified destinations only), +4. writes `send_email: [{ "name": "SEND_EMAIL" }]`, the derived `addresses` array, and `vars.__VOID_EMAIL_FROM` into your root `wrangler.jsonc`, comments preserved. + +The routing rules themselves are created by the next `void deploy --platform cloudflare`: wrangler applies its Email Routing plan from `addresses` on deploy. `void email setup` never writes `addresses` unless routing is ready for the domain, prunes an address already routed to another worker or a forward (and says so), and skips the whole step when the existing `addresses` array holds entries it did not derive. A run that finds every row ready asks nothing and changes nothing on your account — with one exception: a domain still not onboarded for Email Sending while the `send_email` binding is committed (a remembered Workers Free refusal, see `void email status`) is offered as a retry on its own prompt, `Onboard for Email Sending? Inbound already works; onboarding needs Workers Paid.` — the step to run once after upgrading; answer No and nothing changes. The deploy never retries it. Needs an interactive terminal; exits 1 when the inbound rows are still not ready afterwards — routing not enabled, subaddressing still off, or `addresses` withheld — naming the row and saying to rerun. A Workers Free account's refused sending row is not a failure: inbound is complete, `addresses` is written, the plan hint is printed, and the binding written alongside is what makes the next deploy and `void email status` read the domain as set up. See [Your own Cloudflare account](../guide/email.md#your-own-cloudflare-account). + ## Agent ### `void init --agents` diff --git a/docs/reference/config.md b/docs/reference/config.md index 92748af89..fc06ce8d4 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -132,6 +132,28 @@ When using an external database: - `void/db` connects via Hyperdrive in production, direct connection locally - `void gen model` generates dialect-appropriate table and column builders +### `email` + +Configuration for the [email integration](../guide/email.md). + +```json +{ + "email": { + "from": "Acme " + } +} +``` + +| Field | Type | Description | +| ------ | -------- | -------------------------------------------------------------------------------------------------------------- | +| `from` | `string` | Default sender for `sendEmail()`: one address, optionally with a display name (`Acme `) | + +What `from` does depends on where the app runs: + +- **`void dev`** — becomes the default `from` of every `sendEmail()` call, captured in the dev inbox; no mail leaves the machine. +- **`void deploy`** (managed platform) — not used: the sender is pinned to your project's own platform address, which Void fills in for you. +- **`void deploy --platform cloudflare`** (your own Cloudflare account) — the address host (`mail.acme.com`) is the domain Void sets Email Routing and Email Sending up on, and the address becomes the default `from` (written into the worker as the `__VOID_EMAIL_FROM` var). The host must be a zone in the pinned Cloudflare account, or a subdomain of one; a subdomain is the usual choice, and the apex is refused when it already receives mail elsewhere. An app that uses email without `email.from` deploys without it and prints the line to add. Void never picks a zone or writes `void.json` for you. See [Your own Cloudflare account](../guide/email.md#your-own-cloudflare-account). + ### `head` Site-wide HTML `` defaults for pages mode. Sets a title template, default meta tags, links, scripts, and HTML/body attributes. Per-page `head()` exports and middleware defaults merge on top of these with clear precedence: **page > middleware > config**. @@ -432,9 +454,12 @@ Each binding accepts `true` (use default name), `false` (disable), or a string ( | `kv` | `KV` | `KVNamespace` | `"kv": "MY_KV"` | | `storage` | `STORAGE` | `R2Bucket` | `"storage": "MY_BUCKET"` | | `ai` | `AI` | `Ai` | Boolean only | +| `email` | — | — | Boolean only | Custom names are used in deploy-manifest and Cloudflare-config generation, remote binding proxying, internal migrations, and generated runtime helpers such as `void/db`, `void/kv`, and `void/storage`. +`email` is a feature switch rather than a Cloudflare binding, so it has no binding name or type. On the platform, outbound mail is sent through the Void proxy, which owns the `send_email` binding — none is written into your worker; on your own Cloudflare account, `void deploy --platform cloudflare` writes a `send_email` binding named `SEND_EMAIL` into `wrangler.jsonc` itself once email is set up (see [`email.from`](#email)). Set it to `true` to turn the [email integration](../guide/email.md) on — which registers the `void dev` inbox — even when no scanned file imports `void/email`, or `false` to turn it off. + #### `inference.build` Override the build command. Useful for frameworks with their own CLIs (Nuxt, Astro) or static apps with custom build scripts. If omitted, the CLI uses the detected default build command for the current app type. It applies to every app type, including `"void"` (where the default is `vite build`). diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index a93ac491b..6ba1b2200 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -37,6 +37,13 @@ export type CloudflareTriggerConfig = Readonly<{ crons: ReadonlyArray; queueConsumers: ReadonlyArray; workflows: ReadonlyArray; + /** + * Email Routing addresses (`addresses` in Wrangler config). Wrangler 4.131 applies its + * Email Routing plan inside `triggers deploy` — `undefined` skips the plan entirely, while + * `[]` is a destructive plan that deletes every rule this Worker owns — so the two must stay + * distinct through the snapshot. Absent from annotations written before this field existed. + */ + addresses?: ReadonlyArray; }>; export type CloudflareTrafficConfig = Pick< @@ -142,7 +149,10 @@ export function parseCloudflareVersionMessage( } } -/** Capture every configuration surface mutated by `wrangler triggers deploy`. */ +/** + * Capture every configuration surface mutated by `wrangler triggers deploy` — routes, + * workers.dev / preview flags, crons, queue consumers, workflows, and Email Routing addresses. + */ export function readCloudflareTriggerConfig( configPath: string, sourceConfigPath?: string, @@ -368,6 +378,14 @@ function mergeTriggerCleanupBaselines( return true; }); }; + // The baseline is only ever the `previousTriggerConfig` of `deployCloudflareVersionTriggers`, + // which mines it for state the active version no longer wants (queue consumers, workflow + // schedules, custom domains); it is never replayed as a version's own config. Email Routing + // needs no such subtraction — Wrangler plans deletions from rule ownership, not from a previous + // list — so `addresses` here is inert today. Carry it the same way as the other lists (the union + // of every published plan) so the baseline stays a faithful record, and keep the key absent when + // no version carried a plan: an absent key and `[]` are different plans. + const withAddresses = present.filter((config) => config.addresses !== undefined); return { workers_dev: present.some((config) => config.workers_dev), preview_urls: present.some((config) => config.preview_urls), @@ -375,6 +393,9 @@ function mergeTriggerCleanupBaselines( crons: unique(present.flatMap((config) => config.crons)), queueConsumers: unique(present.flatMap((config) => config.queueConsumers)), workflows: unique(present.flatMap((config) => config.workflows)), + ...(withAddresses.length === 0 + ? {} + : { addresses: [...new Set(withAddresses.flatMap((config) => config.addresses ?? []))] }), }; } @@ -409,18 +430,27 @@ function triggerConfigWithQueueConsumerCleanup( if (queues.length === 0) { return previous; } + // This copy becomes the previous version's known trigger config, which + // `stabilizeCloudflareVersionTriggers` replays verbatim when a concurrent rollback returns + // traffic to that version. Spread `previous` so every snapshot field survives — including an + // optional one such as `addresses`, where an absent key and `[]` are different Email Routing + // plans — rather than re-listing fields and silently dropping a newer one. + const base: CloudflareTriggerConfig = previous ?? { + workers_dev: false, + preview_urls: false, + routes: [], + crons: [], + queueConsumers: [], + workflows: [], + }; return { - workers_dev: previous?.workers_dev ?? false, - preview_urls: previous?.preview_urls ?? false, - routes: previous?.routes ?? [], - crons: previous?.crons ?? [], + ...base, queueConsumers: [ - ...(previous?.queueConsumers ?? []), + ...base.queueConsumers, ...queues - .filter((queue) => !queueConsumerNames(previous?.queueConsumers ?? []).includes(queue)) + .filter((queue) => !queueConsumerNames(base.queueConsumers).includes(queue)) .map((queue) => ({ queue })), ], - workflows: previous?.workflows ?? [], }; } @@ -496,6 +526,7 @@ function triggerConfigFromWrangler(config: Record): CloudflareT // rollback do not accidentally reopen the public workers.dev hostname. const workersDev = typeof config.workers_dev === 'boolean' ? config.workers_dev : routes.length === 0; + const addresses = readEmailAddresses(config.addresses); return { ...(config.routes === undefined && config.route === undefined ? { preserveRoutes: true } : {}), ...(config.workers_dev === undefined ? { preserveWorkersDev: true } : {}), @@ -507,9 +538,17 @@ function triggerConfigFromWrangler(config: Record): CloudflareT crons: Array.isArray(triggers.crons) ? triggers.crons : [], queueConsumers: Array.isArray(queues.consumers) ? queues.consumers : [], workflows: Array.isArray(config.workflows) ? config.workflows : [], + ...(addresses === undefined ? {} : { addresses }), }; } +/** A well-formed `addresses` array, or `undefined` when the field is absent or malformed. */ +function readEmailAddresses(value: unknown): ReadonlyArray | undefined { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string') + ? (value as ReadonlyArray) + : undefined; +} + function nonVersionedSettingsHash(config: Record): string { const settings = Object.fromEntries( ['logpush', 'observability', 'tail_consumers', 'streaming_tail_consumers'] @@ -549,7 +588,8 @@ function parseTriggerConfig(value: unknown): CloudflareTriggerConfig | null { !Array.isArray(value.routes) || !Array.isArray(value.crons) || !Array.isArray(value.queueConsumers) || - !Array.isArray(value.workflows) + !Array.isArray(value.workflows) || + (value.addresses !== undefined && readEmailAddresses(value.addresses) === undefined) ) { return null; } @@ -565,6 +605,7 @@ function parseTriggerConfig(value: unknown): CloudflareTriggerConfig | null { crons: value.crons, queueConsumers: value.queueConsumers, workflows: value.workflows, + ...(value.addresses === undefined ? {} : { addresses: value.addresses as Array }), }; } @@ -588,6 +629,21 @@ function triggerWranglerConfig( if (manageRoutes && !triggerConfig.preserveRoutes) { config.routes = [...triggerConfig.routes]; } + // Only a snapshot that carried `addresses` may reach Wrangler's Email Routing plan: an + // absent key skips the plan, which is what a version deployed without email must do. + // That holds in both directions of a rollback — the snapshot being LEFT lends nothing + // here — so rolling back from an email-enabled version to one recorded without the key + // (a pre-`addresses` snapshot, or a deploy that had no handlers) leaves the rules where + // they are, routed to a Worker that no longer exports `email()`. Do not encode that + // transition as `[]`: wrangler plans the deletion of every rule this Worker owns on `[]`, + // a destructive plan it confirms (default No) in a TTY and refuses in CI, and a + // non-zero `triggers deploy` here is `triggers-failed`, which the rollback compensates + // by restoring the version it just left. Whether a rollback should touch Email Routing + // at all is a product call (design doc 0078, decision 13); this code replays the + // snapshot verbatim. + if (triggerConfig.addresses !== undefined) { + config.addresses = [...triggerConfig.addresses]; + } if (triggerConfig.preserveWorkersDev) { delete config.workers_dev; } @@ -621,6 +677,10 @@ function resolvePreservedTraffic( crons: desired.crons, queueConsumers: desired.queueConsumers, workflows: desired.workflows, + // Not a traffic surface, but this rebuild is on the path of every versioned + // deploy: dropping the key here would skip wrangler's Email Routing plan and + // record the version without its addresses. + ...(desired.addresses === undefined ? {} : { addresses: desired.addresses }), }; } diff --git a/packages/deploy-cloudflare/test/unit/version.test.ts b/packages/deploy-cloudflare/test/unit/version.test.ts index 334fb6b2c..d4a656a00 100644 --- a/packages/deploy-cloudflare/test/unit/version.test.ts +++ b/packages/deploy-cloudflare/test/unit/version.test.ts @@ -798,6 +798,101 @@ describe('Cloudflare version activation', () => { expect(parseCloudflareVersionMessage('void:{bad')).toBeNull(); }); + // Wrangler 4.131 applies its Email Routing plan inside `triggers deploy`, reading the + // config's `addresses`. The trigger-only config Void hands it must therefore carry the + // snapshot's addresses verbatim — and carry NOTHING when the snapshot has none, because an + // absent key skips the plan while `[]` deletes every rule the Worker owns. + it('carries Email Routing addresses through the trigger snapshot and the trigger config', async () => { + const directory = mkdtempSync(join(tmpdir(), 'void-version-addresses-')); + const configPath = join(directory, 'wrangler.json'); + const applied = async ( + triggerConfig: Parameters[0]['triggerConfig'], + ): Promise> => { + let config: Record | undefined; + await deployCloudflareVersionTriggers({ + runWrangler: async (args) => { + const configIndex = args.indexOf('-c'); + config = JSON.parse(readFileSync(args[configIndex + 1]!, 'utf-8')) as Record< + string, + unknown + >; + return ok(); + }, + workerName: 'app', + configPath, + triggerConfig, + }); + return config!; + }; + try { + writeFileSync( + configPath, + JSON.stringify({ + name: 'app', + account_id: 'account', + send_email: [{ name: 'SEND_EMAIL' }], + addresses: ['support@mail.acme.com', 'billing@mail.acme.com'], + }), + ); + const withAddresses = readCloudflareTriggerConfig(configPath); + expect(withAddresses.addresses).toEqual(['support@mail.acme.com', 'billing@mail.acme.com']); + expect(await applied(withAddresses)).toMatchObject({ + addresses: ['support@mail.acme.com', 'billing@mail.acme.com'], + }); + // The annotation round-trips the addresses, so a rollback restores that version's rules. + expect( + parseCloudflareVersionMessage(encodeCloudflareVersionMessage([], withAddresses)), + ).toEqual({ schema: 2, migrations: [], triggerConfig: withAddresses }); + + // An explicit empty array is a destructive plan and must survive as `[]`, not vanish. + writeFileSync(configPath, JSON.stringify({ name: 'app', addresses: [] })); + const emptied = readCloudflareTriggerConfig(configPath); + expect(emptied.addresses).toEqual([]); + expect(await applied(emptied)).toMatchObject({ addresses: [] }); + + // No `addresses` at all — the key must not reach Wrangler, or it would plan on it. + writeFileSync(configPath, JSON.stringify({ name: 'app', account_id: 'account' })); + const without = readCloudflareTriggerConfig(configPath); + expect(without).not.toHaveProperty('addresses'); + expect(await applied(without)).not.toHaveProperty('addresses'); + // A rollback replays the TARGET snapshot only: the version being left never lends its + // addresses to the trigger config, so a rollback from an email-enabled version to one + // recorded without `addresses` skips the plan and leaves the rules where they are — + // routed to a Worker that no longer exports `email()`. That is the one shape both an + // absent key and a pre-`addresses` (legacy) snapshot share, and it is not `[]`: a + // snapshot carrying `[]` would make every rollback to it a destructive plan (wrangler's + // default-No y/n in a TTY, a UserError in CI), which `stabilizeCloudflareVersionTriggers` + // reports as `triggers-failed` and the caller compensates by restoring the version it + // just left. Encoding "handlers removed" as `[]` is therefore a product call, not a fix. + let rollbackConfig: Record | undefined; + await deployCloudflareVersionTriggers({ + runWrangler: async (args) => { + const configIndex = args.indexOf('-c'); + rollbackConfig = JSON.parse(readFileSync(args[configIndex + 1]!, 'utf-8')) as Record< + string, + unknown + >; + return ok(); + }, + workerName: 'app', + configPath, + triggerConfig: without, + previousTriggerConfig: withAddresses, + }); + expect(rollbackConfig).not.toHaveProperty('addresses'); + // A malformed array is not an address list; treat it as absent rather than planning on it. + writeFileSync(configPath, JSON.stringify({ name: 'app', addresses: [{ bad: true }] })); + expect(readCloudflareTriggerConfig(configPath)).not.toHaveProperty('addresses'); + expect( + parseCloudflareVersionMessage( + `void:${JSON.stringify({ schema: 2, migrations: [], triggerConfig: { ...without, addresses: 'support@mail.acme.com' } })}`, + ), + ).toBeNull(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + it('uploads, probes, activates, and applies triggers in order', async () => { const runWrangler = runner(); const fetcher = vi.fn(async () => Response.json({ ready: true })); @@ -977,6 +1072,110 @@ describe('Cloudflare version activation', () => { ]); }); + it('replays the previous snapshot addresses when managed-queue cleanup rebuilds it', async () => { + // `queueConsumerCleanup` is non-empty on EVERY deploy of a project with Void-managed + // consumers (`readWranglerManagedQueueCleanupCandidates` includes the current consumers + // for schema-1 upgrades), so the rebuilt previous config is what `stabilize…` replays when + // a concurrent rollback returns traffic to the previous version. Wrangler's Email Routing + // plan takes no "previous" list — it reconciles to exactly the replayed `addresses`, and + // skips the plan when the key is absent — so the copy must keep that version's own value: + // absent, `[]`, and a list are three different plans. + const replayed = async ( + previousAddresses: ReadonlyArray | undefined, + ): Promise> => { + const activation = deferred(); + const activationStarted = deferred(); + let activeVersionId = CURRENT; + let appliedTriggerConfig: Record | undefined; + const previousTriggerConfig = { + workers_dev: true, + preview_urls: true, + routes: [], + crons: [], + queueConsumers: [], + workflows: [], + ...(previousAddresses === undefined ? {} : { addresses: previousAddresses }), + }; + const runWrangler = async (args: Array) => { + if (args[0] === 'deployments') { + return ok( + JSON.stringify({ versions: [{ version_id: activeVersionId, percentage: 100 }] }), + ); + } + if (args[0] === 'versions' && args[1] === 'upload') { + return ok( + `Worker Version ID: ${NEXT}\nVersion Preview URL: https://ready.example.workers.dev`, + ); + } + if (args[0] === 'versions' && args[1] === 'deploy') { + activeVersionId = NEXT; + activationStarted.resolve(undefined); + return activation.promise; + } + if (args[0] === 'versions' && args[1] === 'list') { + return ok( + JSON.stringify([ + { + id: CURRENT, + annotations: { + 'workers/message': encodeCloudflareVersionMessage([], previousTriggerConfig), + }, + }, + ]), + ); + } + if (args[0] === 'triggers') { + const configIndex = args.indexOf('-c'); + appliedTriggerConfig = JSON.parse(readFileSync(args[configIndex + 1]!, 'utf8')) as Record< + string, + unknown + >; + return ok(); + } + if (args[0] === 'queues') { + return ok(); + } + throw new Error(`Unexpected command: ${args.join(' ')}`); + }; + + const pending = activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'my-worker', + message: encodeCloudflareVersionMessage([], { + workers_dev: true, + preview_urls: true, + routes: [], + crons: [], + queueConsumers: [], + workflows: [], + addresses: ['billing@mail.acme.com'], + }), + queueConsumerCleanup: ['jobs'], + fetch: async () => Response.json({ ready: true }), + }); + await activationStarted.promise; + // A concurrent rollback to the previous version wins before triggers are published. + activeVersionId = CURRENT; + activation.resolve(ok()); + + await expect(pending).resolves.toMatchObject({ + ok: false, + reason: 'deployment-changed', + activeVersionId: CURRENT, + triggerRepair: 'succeeded', + }); + return appliedTriggerConfig!; + }; + + expect(await replayed(['support@mail.acme.com'])).toMatchObject({ + addresses: ['support@mail.acme.com'], + queues: { consumers: [{ queue: 'jobs' }] }, + }); + expect(await replayed([])).toMatchObject({ addresses: [] }); + expect(await replayed(undefined)).not.toHaveProperty('addresses'); + }); + it('does not compensate over a concurrent owner after trigger failure', async () => { const failedTriggers = deferred(); const triggerStarted = deferred(); diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index e1f899e21..de6ee0043 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -242,3 +242,5 @@ Agent instructions use versioned markers (`` / ` `void/db` Drizzle instance -> typed routes -> typed fetch client), built-in auth, queues, cron jobs, edge caching (ISR), and one-command deploys via `npx void deploy`. For first-time setup, prefer `npx void init`; in an empty directory, install `void` first and let the interactive flow scaffold the starter with Vite+ by default, add the matching framework adapter, configure project files, handle auth, and link or create the deploy project before the first deploy. In an existing app, `void init` configures Void in place by adding missing Vite scripts and creating or patching `vite.config.*` with `voidPlugin()`. Use `void` and `@void/*` package names in source code and package manifests. +This project uses [Void](https://void.cloud) — a fullstack Vite plugin + deployment platform for Cloudflare. `voidPlugin()` in `vite.config.ts` gives you file-based API routing on Hono (`routes/`), Inertia-inspired server-rendered pages with co-located loaders/actions (`pages/` + `@void/vue` or `@void/react`), auto-provisioned D1/KV/R2 bindings, typed Durable Object state, first-class Drizzle ORM integration (schema in `db/schema.ts` -> `void/db` Drizzle instance -> typed routes -> typed fetch client), built-in auth, queues, cron jobs, email (`void/email` for outbound in every project; an `email/` directory for inbound handlers in Void apps only — under a third-party framework that directory is a build error; on `void deploy --platform cloudflare`, set `email.from` in `void.json` and the first deploy sets Email Routing and the `SEND_EMAIL` binding up on your own zone after one confirmation), edge caching (ISR), and one-command deploys via `npx void deploy`. For first-time setup, prefer `npx void init`; in an empty directory, install `void` first and let the interactive flow scaffold the starter with Vite+ by default, add the matching framework adapter, configure project files, handle auth, and link or create the deploy project before the first deploy. In an existing app, `void init` configures Void in place by adding missing Vite scripts and creating or patching `vite.config.*` with `voidPlugin()`. Use `void` and `@void/*` package names in source code and package manifests. Database: define Drizzle tables in `db/schema.ts`, import `db` from `void/db` and tables from `@schema`. Use `void db push` for prototyping, `void db generate` for production migrations. When Void-managed auth is enabled, generation automatically includes its production Better Auth tables (including config renames/plugins); do not duplicate them in `db/schema.ts`. `drizzle-orm` and `drizzle-kit` ship with void (no extra install). Migrations live in `db/migrations/`. @@ -44,7 +44,7 @@ Deploy platform: `void init` asks for Cloudflare (default), Void, or Skip and sa Self-hosted lifecycle: installation requires a dedicated empty PostgreSQL database and transactionally claims it for one installation. Interactive administration uses Cloudflare browser OAuth with OS-keychain storage; the installed control plane receives a separate scoped runtime API token. Mutating lifecycle commands use the database claim for a cross-host advisory lock and store a secret-free authoritative lifecycle manifest in the claimed row. Local recovery checkpoints are AES-256-GCM encrypted with a key in the system keychain. After discovery elsewhere, export `VOID_PLATFORM_DATABASE_URL`; ordinary upgrades preserve deployed Worker secrets, while recreating a missing API or proxy Worker requires the original runtime, GitHub, R2, JWT, and project-encryption secrets. Existing runtime keys are never regenerated. `void platform disable [id]` durably gates traffic through installation-owned routing storage, repair and upgrade preserve that state, and `void platform enable [id]` restores it explicitly. Lifecycle redeploys preserve unmanaged Worker routes/custom domains and their rollback state, and stop before migrations or upload when Hyperdrive no longer matches the pinned database. Platform upgrades require exact packaged SQL hashes, an exact live migration prefix, and an explicit source-version/schema rollback edge before pending SQL; migrations are transactional and the old Worker version remains authoritative until target-version health passes. A locally built runtime can be supplied to install, upgrade, repair, or enable with `--runtime `; it receives the same integrity, migration, cutover, health, and rollback checks as the packaged runtime. Set `VOID_PLATFORM_SOURCE_REVISION` during its build to record a source commit in the installation checkpoint. Disable, enable, upgrade, repair, and uninstall verify remote and per-resource ownership before mutating Cloudflare; uninstall retains data unless `--purge-data` is supplied, and always retains name-addressed Workers, R2, AI Gateway, external PostgreSQL, and zones for explicit manual cleanup. -Full docs are in `node_modules/void/docs/`. If you have the `void` skill available, use it for a complete API reference covering project structure, routing, pages mode, database, auth, typed fetch, KV, storage, queues, cron jobs, CLI, configuration, and deployment. +Full docs are in `node_modules/void/docs/`. If you have the `void` skill available, use it for a complete API reference covering project structure, routing, pages mode, database, auth, typed fetch, KV, storage, queues, cron jobs, email, CLI, configuration, and deployment. Platform source and forks: the framework and platform share this repository. Use `docs/guide/platform-development.md` for local API/dashboard development, diff --git a/packages/void/getting-started-prompt.txt b/packages/void/getting-started-prompt.txt index 70220f8b7..b6099c92d 100644 --- a/packages/void/getting-started-prompt.txt +++ b/packages/void/getting-started-prompt.txt @@ -41,6 +41,8 @@ MySQL uses `"database": "mysql"`, builders from `void/schema-mysql`, and Hyperdr Connect an existing external database with `void db connect `, or provision Neon with `NEON_API_KEY=... void db connect --provider neon`. This saves `.env.local` and syncs a linked Void project unless `--local-only` is passed. Custom adapters implement `DatabaseProviderAdapter` from `void/database-provider`. +Email: send transactional mail with `import { sendEmail } from "void/email"`. On the platform every project gets a sender — `+noreply@mail.void.cloud` — which is used when you omit `from`; a custom sender domain is not supported there yet. On `void deploy --platform cloudflare` (your own Cloudflare account), set `email.from` in `void.json` to an address on a zone you own and the first deploy prints a checklist, asks once, then enables Email Routing, writes the `SEND_EMAIL` binding and the `addresses` array into `wrangler.jsonc`, and routes each `email/` handler's address to your worker (in CI, run `void email setup --platform cloudflare` once, commit `wrangler.jsonc`, and deploy with `--require-email`). Add recipients with `void email allow
` (Cloudflare emails them a verification link) and list them with `void email destinations`. Receive inbound mail by dropping `defineEmail()` handlers in the top-level `email/` directory (e.g. `email/support+[ticket].ts`) — Void apps only; under TanStack Start, React Router, SvelteKit, Nuxt, Analog or Astro an `email/` directory is a build error, while `sendEmail` keeps working. Check quota and activity with `void email usage` and `void email logs`; during `void dev` sends are captured by the local inbox at `/__void/inbox` instead of being delivered — in Void apps and Class A framework apps (TanStack Start, React Router). Class B/C integrations (SvelteKit, Nuxt, Analog, Astro) run their own dev server, so the inbox is not installed there and sends return `BINDING_MISSING`; use `createEmailTestHarness` from `void/email/testing`, which captures in-process everywhere. + Routing extras: declare static redirects/rewrites/fallbacks in `void.json` under `routing.*`, or in a `public/_redirects` file. For dynamic rewrites, call `c.rewrite(path)` in a `defineMiddleware`. Logs: surface app-level errors via `import { logger } from "void/log"` and `logger.error(msg, fields?)` (also `.warn` / `.info`) so they appear under `void project logs --level error`. Errors only persisted to your own DB are invisible to Cloudflare Tail. Logs only exist where a worker ran — for 5xx generated by the edge router or on static/SPA projects, use `void project requests --status 5xx` instead. diff --git a/packages/void/package.json b/packages/void/package.json index a3f468f79..27898c506 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -21,6 +21,7 @@ "imports": { "#self": "./dist/index.mjs", "#self/_env": "./dist/runtime/env.mjs", + "#self/email": "./dist/runtime/email.mjs", "#self/env": "./dist/runtime/env-public.mjs", "#self/client": "./dist/runtime/client.mjs", "#self/handler": "./dist/runtime/handler.mjs", @@ -223,6 +224,16 @@ "import": "./dist/runtime/ai.mjs", "require": "./dist/runtime/ai.mjs" }, + "./email": { + "types": "./dist/runtime/email.d.mts", + "import": "./dist/runtime/email.mjs", + "require": "./dist/runtime/email.mjs" + }, + "./email/testing": { + "types": "./dist/runtime/email/testing.d.mts", + "import": "./dist/runtime/email/testing.mjs", + "require": "./dist/runtime/email/testing.mjs" + }, "./sandbox": { "types": "./dist/runtime/sandbox.d.mts", "import": "./dist/runtime/sandbox.mjs", @@ -345,7 +356,7 @@ }, "dependencies": { "@cloudflare/sandbox": "^0.12.9", - "@cloudflare/vite-plugin": "1.43.0", + "@cloudflare/vite-plugin": "1.54.7", "@cloudflare/workers-types": "catalog:", "@hono/oauth-providers": "^0.9.0", "@napi-rs/keyring": "2.0.0", @@ -362,6 +373,7 @@ "drizzle-orm": "^0.45.2", "drizzle-valibot": "^0.4.2", "drizzle-zod": "^0.8.3", + "email-validator": "^2.0.4", "es-module-lexer": "^3.0.2", "hono": "^4.13.7", "ignore": "^7.0.9", @@ -369,6 +381,7 @@ "minimatch": "^10.2.6", "mysql2": "3.24.4", "pg": "8.23.0", + "postal-mime": "^2.7.4", "proper-lockfile": "^4.1.2", "wrangler": "catalog:" }, @@ -380,6 +393,7 @@ "@types/pg": "^8.23.1", "@types/proper-lockfile": "^4.1.4", "arktype": "^2.2.3", + "ci-info": "^4.4.0", "estree-walker": "^3.0.3", "magic-string": "^1.3.1", "ofetch": "2.0.0-alpha.3", diff --git a/packages/void/schema.json b/packages/void/schema.json index 76fc002ae..3c9a8c910 100644 --- a/packages/void/schema.json +++ b/packages/void/schema.json @@ -259,6 +259,10 @@ "ai": { "type": "boolean", "description": "Enable Workers AI binding" + }, + "email": { + "type": "boolean", + "description": "Enable email support (used by void/email)." } }, "additionalProperties": false @@ -392,6 +396,17 @@ } }, "additionalProperties": false + }, + "email": { + "type": "object", + "description": "Email configuration for void/email.", + "properties": { + "from": { + "type": "string", + "description": "Default sender for sendEmail(): one address, optionally with a display name (\"Acme \"). On `void deploy --backend cloudflare` its host is the domain Void sets Email Routing and Email Sending up on." + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/packages/void/src/cli/args.ts b/packages/void/src/cli/args.ts index 6803525de..d8f37cbb9 100644 --- a/packages/void/src/cli/args.ts +++ b/packages/void/src/cli/args.ts @@ -9,6 +9,7 @@ export type DeployArgs = { platform?: 'cloudflare' | 'void'; backend?: 'cloudflare'; provision?: boolean; + requireEmail?: boolean; }; export function parseDeployArgs(args: Array): DeployArgs { @@ -25,6 +26,7 @@ export function parseDeployArgs(args: Array): DeployArgs { platform: { type: 'string' }, backend: { type: 'string' }, provision: { type: 'boolean' }, + 'require-email': { type: 'boolean' }, }, allowPositionals: true, strict: true, @@ -111,6 +113,14 @@ export function parseDeployArgs(args: Array): DeployArgs { throw new Error('deploy: --provision requires --platform cloudflare.'); } + // Mode B email (design doc 0078): fail the deploy — before any upload — instead of + // continuing without email when the domain is not set up and nobody can press Enter + // (CI / non-TTY). Only the Cloudflare backend runs the email step. + const requireEmail = parsed.values['require-email'] === true ? true : undefined; + if (requireEmail && backend !== 'cloudflare' && platform !== 'cloudflare') { + throw new Error('deploy: --require-email requires --platform cloudflare.'); + } + // The Cloudflare backend deploys to the user's OWN account and therefore never // resolves a Void project. Static flags are target-neutral: both backends honor // `--dir` and `--spa` through their respective static deployment adapters. @@ -123,5 +133,5 @@ export function parseDeployArgs(args: Array): DeployArgs { } } - return { projectSlug, dir, spa, skipBuild, debug, platform, backend, provision }; + return { projectSlug, dir, spa, skipBuild, debug, platform, backend, provision, requireEmail }; } diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index bb1ba90ef..0ba513f07 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -12,6 +12,7 @@ import { formatCloudflareUserMessage } from './cloudflare-user-output.ts'; import { parseConnectArgs } from './connect-args.ts'; import { dbSubcommands, parseDbArgs } from './db-args.ts'; import { domainSubcommands, parseDomainArgs } from './domain-args.ts'; +import { emailSubcommands, parseEmailArgs } from './email-args.ts'; import { envSubcommands, parseEnvArgs } from './env-args.ts'; import { genSubcommands, parseGenArgs } from './gen-args.ts'; import { githubSubcommands, parseGithubArgs } from './github-args.ts'; @@ -134,6 +135,7 @@ if (helpPath) { platform: deployArgs.platform, backend: deployArgs.backend, provision: deployArgs.provision, + requireEmail: deployArgs.requireEmail, }).catch((err) => { log.error(formatCloudflareUserMessage(err.message ?? String(err))); process.exit(1); @@ -174,6 +176,10 @@ if (helpPath) { runSubcommandGroup('domain', domainSubcommands, parseDomainArgs, (root, parsed) => import('./domain.ts').then(({ runDomainCommand }) => runDomainCommand(root, parsed)), ); +} else if (command === 'email') { + runSubcommandGroup('email', emailSubcommands, parseEmailArgs, (root, parsed) => + import('./email.ts').then(({ runEmailCommand }) => runEmailCommand(root, parsed)), + ); } else if (command === 'db') { runSubcommandGroup('db', dbSubcommands, parseDbArgs, (root, parsed) => import('./db.ts').then(({ runDbCommand }) => runDbCommand(root, parsed)), @@ -263,6 +269,11 @@ if (helpPath) { hint: 'add | delete | list | status', description: 'Configure custom domains', }, + { + name: 'email', + hint: 'usage | logs | destinations | allow | disallow | status | setup', + description: 'Inspect email activity and manage verified recipients', + }, { name: 'github', hint: 'install | installations | connect | update | status | disconnect | link | join', @@ -361,6 +372,10 @@ if (helpPath) { return runSubcommandGroup('build', buildSubcommands, parseBuildArgs, (r, p) => import('./build-cmd.ts').then(({ runBuildCommand }) => runBuildCommand(r, p)), ); + case 'email': + return runSubcommandGroup('email', emailSubcommands, parseEmailArgs, (r, p) => + import('./email.ts').then(({ runEmailCommand }) => runEmailCommand(r, p)), + ); case 'db': return runSubcommandGroup('db', dbSubcommands, parseDbArgs, (r, p) => import('./db.ts').then(({ runDbCommand }) => runDbCommand(r, p)), diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 8d57bb998..6e0216397 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -115,6 +115,17 @@ export type ProjectResponse = { export type CreateProjectResponse = { id: string; slug: string; + /** + * Present exactly when the platform scheduled the project owner's own + * address to be registered as an outbound email destination. The + * registration runs after the response is sent, so this says nothing about + * its outcome — Cloudflare may mail a verification link, or adopt an + * address the owner already verified. `void email destinations` shows the + * resulting state. + */ + owner_email_destination?: { + address: string; + }; }; export type DeleteProjectResponse = { @@ -195,6 +206,52 @@ export type BuildLogsResponse = { external: string | null; }; +export type EmailUsageResponse = { + month: string; + sent: number; + received: number; + limit: number; + remaining: number; + suspended: boolean; +}; + +export type EmailLogEntry = { + timestamp: number; + direction: 'sent' | 'received'; + from: string; + to: string; + subject?: string; + status: string; +}; + +export type EmailLogsResponse = { + logs: Array; + pending?: boolean; + note?: string; +}; + +export type EmailDestinationResponse = { + id: string; + address: string; + status: 'pending' | 'verified' | 'failed'; + verified_at: string | null; + created_at: string; + /** + * Set when the platform (not Cloudflare) mailed the confirmation link — + * on the first add AND on every repeat add while the row is pending, since + * a repeat POST is how that link gets re-sent. + */ + platformVerificationPending?: boolean; + /** + * `false` when the call mailed nothing and the row waits on a Cloudflare + * link sent EARLIER: a repeat add of a CF-pending address, or a first add + * (201) of an address Cloudflare already held pending — the owner's own, + * from another project or a hand add. CF refused the create and mails its + * link on create only, so a bare 201 must not read as "a link went out". + */ + verificationSent?: boolean; +}; + export type DomainResponse = { id: string; project_id: string; @@ -1199,4 +1256,94 @@ export class PlatformClient { return res.json(); } + + async getEmailUsage(projectId: string): Promise { + const res = await fetch(`${this.baseUrl}/projects/${projectId}/email/usage`, { + headers: { + ...this.authHeaders, + }, + }); + + if (!res.ok) { + await throwPlatformApiError('Failed to fetch email usage', res); + } + + return res.json(); + } + + async getEmailLogs(projectId: string, limit?: number): Promise { + const url = new URL(`${this.baseUrl}/projects/${projectId}/email/logs`); + if (limit !== undefined) { + url.searchParams.set('limit', String(limit)); + } + + const res = await fetch(url, { + headers: { + ...this.authHeaders, + }, + }); + + if (!res.ok) { + await throwPlatformApiError('Failed to fetch email logs', res); + } + + return res.json(); + } + + async listEmailDestinations(projectId: string): Promise> { + const res = await fetch(`${this.baseUrl}/projects/${projectId}/email/destinations`, { + headers: { + ...this.authHeaders, + }, + }); + + if (!res.ok) { + await throwPlatformApiError('Failed to list email destinations', res); + } + + return res.json(); + } + + async addEmailDestination( + projectId: string, + address: string, + ): Promise { + const res = await fetch(`${this.baseUrl}/projects/${projectId}/email/destinations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.authHeaders, + }, + body: JSON.stringify({ address }), + }); + + if (!res.ok) { + await throwPlatformApiError('Failed to add email destination', res); + } + + // 201 registered the address just now; 200 found it already on the + // project's list. The body is the same shape either way, so the status + // is the "created" signal — whether a link went out is the body's + // `verificationSent` / `platformVerificationPending`, not the status. + const body = (await res.json()) as EmailDestinationResponse; + return { ...body, created: res.status === 201 }; + } + + async removeEmailDestination(projectId: string, destinationId: string): Promise<{ ok: boolean }> { + const res = await fetch( + `${this.baseUrl}/projects/${projectId}/email/destinations/${destinationId}`, + { + method: 'DELETE', + headers: { + ...this.authHeaders, + }, + }, + ); + + if (!res.ok) { + await throwPlatformApiError('Failed to remove email destination', res); + } + + return res.json(); + } } diff --git a/packages/void/src/cli/cloudflare-deploy/classify.ts b/packages/void/src/cli/cloudflare-deploy/classify.ts index d50308d5b..16b5e2c9c 100644 --- a/packages/void/src/cli/cloudflare-deploy/classify.ts +++ b/packages/void/src/cli/cloudflare-deploy/classify.ts @@ -65,7 +65,7 @@ export function classifyCloudflareDeployApp( [ 'deploy: --platform cloudflare found no Void backend to deploy.', 'A full Void app has at least one of: routes/, pages/, middleware, crons/, queues/,', - 'or an SSR entry. Add your server code, or use `void deploy` for a static site.', + 'email/, or an SSR entry. Add your server code, or use `void deploy` for a static site.', ].join('\n'), ); } diff --git a/packages/void/src/cli/create-project.ts b/packages/void/src/cli/create-project.ts index 623ff2276..14f3c0d5f 100644 --- a/packages/void/src/cli/create-project.ts +++ b/packages/void/src/cli/create-project.ts @@ -59,6 +59,16 @@ export async function promptAndCreateProject( } const config: ProjectConfig = { projectId: project.id, slug: project.slug }; writeProjectConfig(root, attachSelectedPlatform(root, config, client.apiUrl)); + if (project.owner_email_destination) { + // The platform registers the owner's address after it has answered, so + // the response cannot say whether Cloudflare mailed a verification link + // or adopted an address already verified on an earlier project. Say the + // one thing that is true either way, and where the outcome shows up. + const { address } = project.owner_email_destination; + log.info( + `Registering ${address} as a recipient for this project. Cloudflare emails it a verification link unless it is already verified — \`void email destinations\` shows the state.`, + ); + } return config; } diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 524fe25d7..12c8c91c6 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -11,6 +11,7 @@ import { writeFileSync, } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { isCI as ciInfoIsCI } from 'ci-info'; import { Minimatch } from 'minimatch'; import { basename, dirname, join, normalize, relative, resolve } from 'pathslash'; @@ -90,6 +91,11 @@ import { } from '../prerender.ts'; import { readProjectPaths, resolveProjectPaths } from '../project-paths.ts'; import { scanQueuesSync } from '../queues/scan.ts'; +import { + hasEmailHandlers, + scanEmailHandlersSync, + type EmailHandlersScan, +} from '../router/email.ts'; import { scanRoutes } from '../router/scan.ts'; import { scanWebSocketRoutesSync } from '../router/websocket.ts'; import { @@ -141,6 +147,20 @@ import { formatStreamDeployError, } from './deploy-errors.ts'; import { uploadAssetsToR2, type DirectR2PutItem } from './direct-r2-upload.ts'; +import { + applyEmailSetup, + createNodeEmailDns, + EMAIL_FROM_VAR, + findEmailConfigConflicts, + preflightEmail, + SEND_EMAIL_BINDING, + renderAddressMap, + renderEmailChecklist, + type EmailCloudflareInputs, + type EmailDns, + type EmailFetch, + type EmailPreflight, +} from './email-cloudflare.ts'; import type { EnvSchemaProbeInput, EnvSchemaProbeResult, @@ -227,7 +247,7 @@ let loadedProvisionModule: ProvisionModule | undefined; * Cloudflare fork (`runCloudflareDeploy` / `computeDesiredCloudflareResources`) * so a managed deploy never imports it — and thus never loads `proper-lockfile`. */ -async function loadProvisionModule(): Promise { +export async function loadProvisionModule(): Promise { loadedProvisionModule ??= await import('./provision.ts'); return loadedProvisionModule; } @@ -268,12 +288,14 @@ import { bindingEntriesForField, FRIENDLY_BINDING_NAMES, type LiveBindingConfig, + type ProvisionedWranglerPatch, type WranglerCompat, } from './wrangler.ts'; const log = { error: (message: string) => clackLog.error(formatCloudflareUserMessage(message)), info: (message: string) => clackLog.info(formatCloudflareUserMessage(message)), + message: (message: string) => clackLog.message(formatCloudflareUserMessage(message)), step: (message: string) => clackLog.step(formatCloudflareUserMessage(message)), success: (message: string) => clackLog.success(formatCloudflareUserMessage(message)), warn: (message: string) => clackLog.warn(formatCloudflareUserMessage(message)), @@ -295,6 +317,12 @@ export type RunDeployOptions = { /** @deprecated Use platform instead. */ backend?: 'cloudflare'; provision?: boolean; + /** + * Mode B email (0078): fail the deploy — before any upload — instead of continuing + * without email when the mail domain is not set up and the step cannot finish it + * (CI / non-TTY, a blocker, a declined prompt). `--platform cloudflare` only. + */ + requireEmail?: boolean; }; export function resolveDeploymentPlatform( @@ -600,7 +628,7 @@ const MANAGED_DEPLOY_BUILD_ENV = { VOID_DEPLOY_BUILD: '1' } as const; * top-level config this flow provisions + preflights. Scrubbed from every child env * and fenced in the project dotenv (see below), on top of the provisioner's transport * scrub/fence which owns the endpoint-redirect + `CLOUDFLARE_ENV` vars: - * - `WRANGLER_CI_OVERRIDE_NAME` — wrangler 4.107 applies it AFTER config load + * - `WRANGLER_CI_OVERRIDE_NAME` — wrangler 4.131 applies it AFTER config load * (`mergeSharedConfigArgs` → `getCIOverrideName()`), REPLACING the upload name, so a * bare `wrangler deploy` uploads a worker whose name the generated-config check never * validated. @@ -614,7 +642,7 @@ const DEPLOY_CONFIG_OVERRIDE_ENV_VARS = [ ] as const; /** - * The dotenv files wrangler 4.107 reloads into `process.env` (`override: true`) for + * The dotenv files wrangler 4.131 reloads into `process.env` (`override: true`) for * the orchestrator's `--env=` calls (`getDefaultEnvFiles("")`), so a repo dotenv here * would resurrect `WRANGLER_CI_OVERRIDE_NAME` at the DEPLOY step after the child-env * scrub removed it. @@ -707,7 +735,7 @@ export function assertNoConfigOverrideInProjectDotenv( * the deploy tail (build / whoami / migrate / deploy) share one source of truth. * Returns the single resolved API base URL to pin into every child env. */ -function resolveDeployTransport(root: string, rootConfig: Record): string { +export function resolveDeployTransport(root: string, rootConfig: Record): string { const provision = requireProvisionModule(); assertNoAmbientConfigOverride(); provision.assertNoTransportRedirectInProjectDotenv(root); @@ -926,6 +954,11 @@ export const DEPLOY_PASSTHROUGH_ASSERT_FIELDS: ReadonlyArray = Object.ke * hostname/path the account controls (traffic interception), and flipping `workers_dev` / * `preview_urls` changes its public exposure. None is a "binding" either, so the assert-set * can't see them, yet wrangler uploads them. + * - `addresses` — the worker's INBOUND MAIL attachment (0078 Mode B). wrangler ≥ 4.113 + * reconciles the account's Email Routing rules from this array on `wrangler deploy`: a build + * that injects an address redirects the account's inbound mail to the deployed worker, and one + * that drops the root's removes the rules Void's email step set up. Not a binding either, and + * the CF plugin passes the key through verbatim, so it used to fail OPEN. * Void's own build emits NONE of them: a clean `vite build` (root declaring none) leaves every one * ABSENT from the emitted artifact (verified against a real build), so an emptiness check * ({@link isEmptyPassthroughValue}) keeps the normalizer's default-fill from false-positiving. @@ -938,6 +971,7 @@ export const DEPLOY_WHOLE_OBJECT_PINNED_FIELDS: ReadonlyArray = [ 'route', 'workers_dev', 'preview_urls', + 'addresses', ]; /** @@ -1370,7 +1404,7 @@ function deployPointerPath(root: string): string { /** * Cloudflare credential env vars that must not reach untrusted project code. This includes - * Wrangler 4.107's full authentication set, verified against + * Wrangler 4.131's full authentication set, verified against * `wrangler-dist/cli.js`. It covers every env var wrangler reads to authenticate — * the API-client auth inputs (`apiToken`/`apiKey`/`apiEmail`/`userServiceKey`) plus * the `getEnvironmentVariableFactory` auth tokens — each canonical name AND its @@ -2081,6 +2115,9 @@ export function verifyGeneratedDeployConfig( // Void emits none, so undeclared → absent → both-empty → pass; a build-injected `routes` // entry (attaching the worker to a hostname the account controls) or a flipped // `workers_dev`/`preview_urls` fails the deepEqual against the absent/declared root value. + // • `addresses` — the worker's inbound-mail attachment. Same pin, minus the both-empty pass: + // `[]` is loaded (it deletes every owned routing rule), so an emitted `[]` passes only + // when the root declared exactly `[]`. // • `durable_objects` — an emitted binding carrying a cross-script field targets ANOTHER // worker's DOs; Void's own DOs are always LOCAL, so it must match a root binding. // • `ai` — when the root declared its own ai binding it must SURVIVE byte-identical (a build @@ -2099,7 +2136,14 @@ export function verifyGeneratedDeployConfig( const rootValue = raw.get(field); // Both-empty is the only case an empty emitted value passes: a root that declared a // non-empty value emitted back empty REMOVES a control the source configured. - if (isEmptyPassthroughValue(emitted) && isEmptyPassthroughValue(rootValue)) { + // `addresses: []` is NOT empty to wrangler — it plans the deletion of every rule this + // worker owns (wrangler returns early only on `undefined`, and never default-fills the + // key) — so it gets no both-empty pass: an emitted `[]` must equal a root-declared `[]`. + if ( + field !== 'addresses' && + isEmptyPassthroughValue(emitted) && + isEmptyPassthroughValue(rootValue) + ) { continue; } if (!deepEqual(emitted, rootValue)) { @@ -2341,7 +2385,17 @@ export type DeployWranglerRunResult = { */ export type DeployWranglerRun = ( args: Array, - opts: { capture: boolean; input?: string }, + opts: { + capture: boolean; + /** Piped to the child's stdin (e.g. a secret value for `wrangler secret put`). */ + input?: string; + /** + * Extra env pins layered OVER the deploy child env for this one spawn (e.g. + * `WRANGLER_WRITE_LOGS=false` for `auth token`). Never a credential — the runner's own + * env already carries the wrangler auth. + */ + env?: Record; + }, ) => DeployWranglerRunResult | Promise; /** @@ -2423,6 +2477,21 @@ export interface CloudflareDeployDeps { * validate. */ readEmittedWorkerVars?: (root: string) => EmittedWorkerVarsResult; + /** + * Override CI / non-interactive detection for the Mode B email prompt (default: wrangler's + * own {@link isWranglerNonInteractiveOrCI}, so Void and wrangler call the same run + * interactive). + */ + isCI?: boolean; + /** + * The one-Enter email prompt (default: clack `confirm`, default Yes). Receives the question + * and resolves the answer; a cancelled prompt is `false`. + */ + confirmEmailSetup?: (message: string) => Promise; + /** Public-DNS probes for the email preflight (default: `node:dns/promises`). */ + emailDns?: EmailDns; + /** `fetch` for the two bearer calls wrangler has no command for (default: global `fetch`). */ + emailFetch?: EmailFetch; /** Override `process.exit` (tests assert the exit code without halting). */ exit?: (code: number) => never; } @@ -2564,7 +2633,7 @@ function suffixedResourceName(workerName: string, suffix: string): string { * build emits (and the deploy uploads) the non-override name; deriving from the * override here would pin a name the scrubbed build never produces. */ -function deriveWorkerName(root: string): string { +export function deriveWorkerName(root: string): string { let pkgName: string | undefined; try { const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')) as { name?: unknown }; @@ -2694,6 +2763,19 @@ export async function computeDesiredCloudflareResources( * injected, dropped, or retargeted a Cron Trigger. */ expectedCrons: Array; + /** + * Whether the app uses email at all — inbound `email/` handlers ({@link hasEmailHandlers}) OR + * an outbound `void/email` import (the import walk's `needsEmail`, the SAME signal index.ts + * gates the dev inbox on). Gates the Mode B email step (3b-ii, design doc 0078); the managed + * platform path never reads it. + */ + needsEmail: boolean; + /** + * The `email/` handler scan the email step derives `addresses` from — the SAME + * `scanEmailHandlersSync(paths.sourceRoot)` index.ts compiles the worker's `email()` export + * from, so the routed addresses can never disagree with the dispatcher. + */ + emailHandlers: EmailHandlersScan; }> { // The id predicates are resolved via the memoized dynamic import so this exported // helper works when called directly in unit tests (outside the fork) without a @@ -2830,6 +2912,11 @@ export async function computeDesiredCloudflareResources( // this reconstruction. const expectedCrons = scanJobsSync(root, paths.cronsDir).flatMap((job) => job.crons); + // Email — the handler directory is scanned from the SAME sourceRoot index.ts compiles it from + // (`scanEmailHandlersSync(paths.sourceRoot)`); outbound-only use shows up as the import walk's + // `needsEmail` (a `void/email` import anywhere in the scan dirs). + const emailHandlers = scanEmailHandlersSync(paths.sourceRoot); + return { desired, reasons, @@ -2839,6 +2926,8 @@ export async function computeDesiredCloudflareResources( migrations, needsAI: bindings.needsAI, expectedCrons, + needsEmail: bindings.needsEmail === true || hasEmailHandlers(emailHandlers), + emailHandlers, // Mirror index.ts's emission signal EXACTLY (line 658): the sandbox container is emitted when // `void.json` enables it OR the source imports `void/sandbox` (the import-walk signal). Using // `isSandboxEnabled` alone would miss an import-only sandbox app (e.g. kitchen-sink) and the @@ -3416,7 +3505,7 @@ export function assertRemoteMigrationsDeployable( } /** - * wrangler 4.107's own migration-file order comparator, reproduced EXACTLY + * wrangler 4.131's own migration-file order comparator, reproduced EXACTLY * (`compareMigrationPaths` / `compareSegments` / `leadingMigrationNumber`, * `wrangler-dist/cli.js` `src/d1/migrations/helpers.ts`): segment-by-segment, comparing each * segment's LEADING NUMERIC PREFIX (`parseInt(segment.split('_')[0], 10)`) numerically first, @@ -3537,7 +3626,7 @@ function listWranglerMigrationFiles(directory: string, pattern: string): Array/*.sql` pattern or the * configured `migrations_pattern`, tracks each by its relative `.sql` name, and applies them @@ -3637,9 +3726,11 @@ export async function checkWranglerMigrationApplySet( * child env (via {@link deployChildEnv} — reused provisioner scrub/pin: CF Access + * every transport selector incl. `CLOUDFLARE_ENV` + the deploy config-override vars * scrubbed, one account + API base pinned), so `whoami`/`migrate`/`deploy` can never - * consult a different ambient account or endpoint. `capture` pipes or inherits stdio. + * consult a different ambient account or endpoint. `capture` pipes or inherits stdio; + * `opts.env` layers one more pin over the child env for a single spawn (`auth token` gets + * `WRANGLER_WRITE_LOGS=false`, see {@link runWranglerAuthToken}). */ -function createDeployWranglerRunner( +export function createDeployWranglerRunner( root: string, accountId: string | undefined, apiBaseUrl: string, @@ -3651,6 +3742,9 @@ function createDeployWranglerRunner( if (!opts.capture) { childEnv.WRANGLER_LOG = 'none'; } + if (opts.env) { + Object.assign(childEnv, opts.env); + } const result = spawnSync(command as string, [...prefix, ...args], { cwd: root, stdio: @@ -3721,6 +3815,367 @@ async function chooseCloudflareDeployAccount(options: { return account.id; } +/** + * wrangler's own interactivity gate (`isNonInteractiveOrCI`, workers-utils): `ci-info`'s `isCI` + * OR either stdio not a TTY. The email step (3b-ii) uses THIS rather than the deploy's `isCI` + * (stdin-only, or a CI token source): wrangler asks its own y/n on a destructive `addresses` + * plan change after the upload under the same gate, and the two halves of one deploy must call + * the same run interactive — otherwise Void would prompt where wrangler then throws in "CI", or + * skip a prompt wrangler goes on to ask. + */ +export function isWranglerNonInteractiveOrCI(): boolean { + return ciInfoIsCI || !(process.stdin.isTTY && process.stdout.isTTY); +} + +/** + * Borrow the wrangler session's bearer for the two email calls wrangler has no command for + * (0078 "The flow, by lane": subdomain routing enable, the subaddressing PATCH, plus the + * read-only zone classification). Spawns `wrangler auth token --json` through the deploy runner + * — the pinned uploader, the pinned account + API base — with `WRANGLER_WRITE_LOGS=false` + * layered on the child env: without it wrangler appends the token to + * `~/.wrangler/logs/wrangler-*.log`, mode `-rw-r--r--` (`shouldLogToDisk`). The captured stdout + * is parsed and the token string is handed to the caller's closure ONLY: it is never logged, + * never placed in `process.env` or any later child env, and never part of an error — every + * failure resolves `null`, which the email module reports as "session bearer unavailable". + */ +export async function runWranglerAuthToken( + runWrangler: DeployWranglerRun, + configPath: string, +): Promise { + let result: DeployWranglerRunResult; + try { + result = await Promise.resolve( + runWrangler(['auth', 'token', '--json', '-c', configPath, '--env='], { + capture: true, + env: { WRANGLER_WRITE_LOGS: 'false' }, + }), + ); + } catch { + return null; + } + if (!isCleanWranglerExit(result)) { + return null; + } + // `--json` suppresses the banner, so stdout is the object alone; tolerate surrounding noise. + const start = result.stdout.indexOf('{'); + const end = result.stdout.lastIndexOf('}'); + if (start === -1 || end <= start) { + return null; + } + let parsed: { type?: unknown; token?: unknown } | null; + try { + parsed = JSON.parse(result.stdout.slice(start, end + 1)) as typeof parsed; + } catch { + return null; + } + // An `api_key` session answers `{ key, email }` — there is no single bearer to borrow. + const type = parsed?.type; + const token = parsed?.token; + return (type === 'oauth' || type === 'api_token') && typeof token === 'string' && token.length > 0 + ? token + : null; +} + +/** What the post-deploy address map prints — the input of {@link renderAddressMap}. */ +type EmailAddressMapInput = Parameters[0]; + +interface CloudflareEmailStepInput { + root: string; + configPath: string; + voidConfig: VoidConfig; + /** The pre-probe root config (step 3) — `send_email` / `addresses` are read from it. */ + rootConfig: Record; + handlers: EmailHandlersScan; + workerName: string; + accountId: string; + /** The ONE pinned transport (3a) — the two bearer `fetch` calls follow it like every wrangler child. */ + apiBaseUrl: string; + runWrangler: DeployWranglerRun; + /** wrangler's gate ({@link isWranglerNonInteractiveOrCI}) or the test override. */ + nonInteractive: boolean; + confirm: (message: string) => Promise; + fetchImpl: EmailFetch; + dns: EmailDns; +} + +interface CloudflareEmailStepResult { + /** + * True when the step FINISHED: every row read ready, or the one Enter applied the rest. + * What `--require-email` gates on. A worker can still SHIP with email (the `SEND_EMAIL` + * binding committed) on a branch that returns false — `map` says so. + */ + satisfied: boolean; + /** True when the root wrangler config was rewritten — the caller re-parses `rootConfig`. */ + configWritten: boolean; + /** The post-deploy address map, when the worker ships with the binding; `null` otherwise. */ + map: EmailAddressMapInput | null; +} + +/** + * Step 3b-ii — Mode B email on the developer's own account (design doc 0078, "One keystroke"). + * Preflight is read-only; the checklist is shown; ONE `confirm` (default Yes) authorizes the + * apply, which runs only the rows not ready and returns the root-config patch this writes. + * Every branch that cannot finish prints why and returns `satisfied: false` so the deploy + * continues (never a deploy failure on its own — `--require-email` is the caller's call), + * WITH the email setup the config already carries when the binding is committed, and + * without email otherwise. No output of this step ever contains the session bearer: the + * module borrows it through `getSessionBearer` inside its own closures and drops it. + */ +async function runCloudflareEmailStep( + input: CloudflareEmailStepInput, +): Promise { + const without: CloudflareEmailStepResult = { satisfied: false, configWritten: false, map: null }; + const from = input.voidConfig.email?.from; + if (!from) { + log.warn( + [ + 'deploy: this app uses email, but void.json names no sender.', + 'Add "email": { "from": "you@mail.acme.com" } to void.json — deploying without email.', + ].join('\n'), + ); + return without; + } + const { rootConfig } = input; + const configName = basename(input.configPath); + const inputs: EmailCloudflareInputs = { + handlers: input.handlers, + from, + workerName: input.workerName, + accountId: input.accountId, + existingSendEmail: Array.isArray(rootConfig.send_email) + ? (rootConfig.send_email as Array<{ name?: unknown }>) + : undefined, + existingAddresses: rootConfig.addresses, + existingVars: + rootConfig.vars && typeof rootConfig.vars === 'object' && !Array.isArray(rootConfig.vars) + ? (rootConfig.vars as Record) + : undefined, + configConflicts: findEmailConfigConflicts(rootConfig), + // The SAME `-c`/`--env=` context every other credentialed wrangler run gets. + wranglerArgs: ['-c', input.configPath, '--env='], + apiBaseUrl: input.apiBaseUrl, + runWrangler: input.runWrangler, + getSessionBearer: () => runWranglerAuthToken(input.runWrangler, input.configPath), + fetchImpl: input.fetchImpl, + dns: input.dns, + }; + + let pre: EmailPreflight; + try { + pre = await preflightEmail(inputs); + } catch (err) { + log.warn(`deploy: email preflight failed — ${getErrorMessage(err)}. Deploying without email.`); + return without; + } + const mapFor = ( + addresses: Array, + sendingReady: boolean, + creating: Array = [], + ): EmailAddressMapInput => ({ + from, + routes: pre.derived.routes, + addresses, + sendingReady, + creating, + }); + // Pin `configPath` so read/parse/write hit the SAME file every other step uses. A refused + // write (e.g. an `addresses` array with a comment nested inside it) is the developer's + // territory (decision 13): surface it as-is; the caller decides what the deploy ships. + const writeConfig = (patch: ProvisionedWranglerPatch): boolean => { + try { + writeProvisionedWranglerConfig(input.root, patch, { configPath: input.configPath }); + return true; + } catch (err) { + log.error(`deploy: could not update ${configName} — ${getErrorMessage(err)}.`); + return false; + } + }; + + // Every account row ready: no prompt, no account call, zero keystrokes (the second deploy). + // A sending row the committed binding remembers as refused (Workers Free) lands here too + // — the deploy never retries the onboarding; the map ends `(verified destinations only)` + // and `void email setup` is where the retry is offered (`preflightEmail`'s memory rule). + // The two CONFIG rows are reconciled here by a plain file write, because a stale one is + // not idle: an `addresses` entry no longer in the pruned set reaches wrangler's plan as a + // `conflict` (a takeover prompt in a TTY, a UserError after the upload in CI — decision + // 12), and a stale `__VOID_EMAIL_FROM` ships the OLD sender while the map prints the new. + // + // The `null` (key removed) branch below is reachable in exactly one shape: every derived + // address is routed elsewhere (a forward, another worker — a worker rename makes all of + // them so), so this worker OWNS none of those rules and `[]` would plan nothing either; + // the key goes because a plan on a stale entry is a `conflict`. A handler deleted while + // its entry stays never gets here: the preflight (step 8) has no provenance for entries + // Void wrote on an earlier deploy, so it reads that leftover as an entry Void did not + // derive — the decision-13 blocker, array untouched, rule still routed to this worker + // (guide: "Deleting a handler leaves its address in `addresses`"). Making the leftover + // Void-managed instead (a marker like `void:managed-queues`, then `[]` or a prune) is + // decision 13's other branch, and under `triggers deploy` a `[]` plan that deletes owned + // rules is destructive: wrangler asks (default No) in a TTY and throws in CI, which the + // activation reports as `triggers-failed` and compensates by restoring the previous + // version. A product call, not a deploy default. + if (pre.ready) { + const patch: ProvisionedWranglerPatch = {}; + const rows: Array = []; + if (pre.addressesStale) { + patch.addresses = pre.addresses.length > 0 ? pre.addresses : null; + rows.push( + pre.addresses.length > 0 + ? `addresses: ${JSON.stringify(pre.addresses)}` + : 'addresses removed', + ); + } + if (pre.sender.state !== 'ready') { + patch.vars = { [EMAIL_FROM_VAR]: from }; + rows.push(`vars.${EMAIL_FROM_VAR}: ${JSON.stringify(from)}`); + } + let configWritten = false; + if (rows.length > 0) { + // The prune rows the checklist would have carried: say why an entry goes. + for (const foreign of pre.addressesStale ? pre.rules.foreign : []) { + log.message( + ` ! ${foreign.address}: already routed to ${foreign.owner}; left alone, not in addresses`, + ); + } + if (!writeConfig(patch)) { + log.warn(`deploy: deploying with the email setup ${configName} already carries.`); + return { + ...without, + map: mapFor(existingAddresses(rootConfig, pre), pre.sending.state === 'ready'), + }; + } + configWritten = true; + log.message(` ✔ config ${configName} updated (${rows.join(', ')})`); + } + return { + satisfied: true, + configWritten, + map: mapFor(pre.addresses, pre.sending.state === 'ready', pre.rulesPending), + }; + } + + // The checklist carries the `✘` rows, so a blocked run explains itself before the warn line. + log.message(renderEmailChecklist(pre)); + // The three exits below leave the account alone. What the worker then ships is whatever + // the config already carries — so when the binding is committed the deploy is WITH the + // existing setup (map printed, wording says so), and a committed `addresses` array whose + // routing preflight found off is withdrawn first: wrangler's plan on it 400s (`2062`) + // AFTER the upload, on every deploy, until the key goes (0078: "deleted, never `[]`, + // when a later preflight finds routing off"). Only Void's own array is touched (decision + // 13); deleting the KEY changes no rule — wrangler skips the plan on `undefined`. + const shipsBinding = pre.binding.state === 'present'; + const leaveAccount = (reason: string): CloudflareEmailStepResult => { + let configWritten = false; + if ( + Array.isArray(rootConfig.addresses) && + pre.addressesOwnership.state === 'mine' && + pre.routing.state !== 'ready' + ) { + // A refused write leaves the array where it is; the deploy goes on as it would have. + if (writeConfig({ addresses: null })) { + configWritten = true; + log.message( + ` ! config addresses removed from ${configName} (routing on ${pre.domain} is ${pre.routing.state}); no routing rule is created for ${(rootConfig.addresses as Array).map(String).join(', ')}`, + ); + } + } + log.warn( + `deploy: ${reason} — ${shipsBinding ? `deploying with the email setup ${configName} already carries.` : 'deploying without email.'}`, + ); + return { + satisfied: false, + configWritten, + map: shipsBinding + ? mapFor( + configWritten ? [] : existingAddresses(rootConfig, pre), + pre.sending.state === 'ready', + ) + : null, + }; + }; + if (pre.blockers.some((b) => b.scope === 'all')) { + return leaveAccount(`email on ${pre.domain} cannot be set up from here.`); + } + if (input.nonInteractive) { + return leaveAccount( + [ + `email on ${pre.domain} is not ${shipsBinding ? 'fully ' : ''}set up, and this shell cannot ask.`, + 'Run `void email setup --platform cloudflare` once locally, commit wrangler.jsonc, then redeploy.', + ].join('\n'), + ); + } + if (!(await input.confirm(`Set up email on ${pre.domain}?`))) { + return leaveAccount( + 'email setup skipped — run `void email setup --platform cloudflare` later.', + ); + } + + const out = await applyEmailSetup(pre, inputs); + if (out.lines.length > 0) { + log.message(out.lines.join('\n')); + } + let configWritten = false; + if ( + out.patch.sendEmail !== undefined || + out.patch.addresses !== undefined || + out.patch.vars !== undefined + ) { + if ( + !writeConfig({ + sendEmail: out.patch.sendEmail, + addresses: out.patch.addresses, + vars: out.patch.vars, + }) + ) { + log.warn('deploy: deploying without email.'); + return without; + } + configWritten = true; + } + const bindingPresent = shipsBinding || out.patch.sendEmail !== undefined; + const written = out.patch.addresses ?? []; + // `satisfied` is post-apply READINESS, not binding presence. `applyEmailSetup` writes + // `patch.sendEmail` as soon as EITHER routing or sending came up, and withholds + // `addresses` when the bearer failed or routing is off — so a run whose subdomain + // routing POST failed ships the binding with no inbound rule behind it, which is + // exactly the end state the `leaveAccount` branch above already returns as + // `satisfied: false`. Gating `--require-email` on the binding alone would contradict + // both that branch and the error text ("not fully set up … refusing to deploy"), so + // this mirrors `preflightEmail`'s own `ready` predicate instead: the inbound rows + // (routing + subaddressing, with `addresses` actually written — `out.inboundReady`, the + // same field `void email setup` exits on) + the binding. The sending row needs no term + // of its own: apply has just made its one `sending enable` call (or found the row ready + // or blocked), and the binding it writes on that same run is what the NEXT preflight + // reads as the memory of a refusal — binding present + sending pending is settled there + // (Workers Free, `10105`; the memory rule in `preflightEmail`), so it is settled here, + // or the two would disagree on the very next deploy. `map` stays on `bindingPresent`: + // the worker DOES ship with email, and a deploy may ship with email while `satisfied` + // is false. + const satisfied = bindingPresent && out.inboundReady; + return { + satisfied, + configWritten, + map: bindingPresent + ? mapFor( + written, + out.sendingReady, + pre.rules.absent.filter((a) => written.includes(a)), + ) + : null, + }; +} + +/** The committed `addresses` entries that are still Void's pruned set (what wrangler keeps). */ +function existingAddresses( + rootConfig: Record, + pre: EmailPreflight, +): Array { + return Array.isArray(rootConfig.addresses) + ? (rootConfig.addresses as Array) + .map((a) => (typeof a === 'string' ? a.toLowerCase() : '')) + .filter((a) => pre.addresses.includes(a)) + : []; +} + /** * Ensure an auth-enabled worker has one persistent Better Auth secret. * @@ -3933,6 +4388,8 @@ export async function runCloudflareDeploy( needsAI, needsSandbox, expectedCrons, + needsEmail, + emailHandlers, } = await computeDesiredCloudflareResources(root, voidConfig, configPath); // 2a. External-database migrations are applied directly after every local gate @@ -3971,7 +4428,10 @@ export async function runCloudflareDeploy( // derive the Better Auth schema in an isolated subprocess under the build's EFFECTIVE mode + // the SAME scrubbed env the build uses — still BEFORE whoami / provision / build / deploy.) - // 3. Preflight — pin ONE transport, pin the account, confirm wrangler auth. + // 3. Preflight — pin ONE transport, pin the account, confirm wrangler auth. `rootConfig` is the + // pre-probe ground truth the post-probe laundering guard (after 3c) compares against; the ONLY + // re-parses are the account discovery's (3b) and the email step's (3b-ii), both of which write + // BEFORE any project code runs. let rootConfig = parseRootWranglerConfig(configPath); // 3a. Resolve + pin the ONE transport for the WHOLE deploy (build + whoami + migrate + @@ -4040,6 +4500,90 @@ export async function runCloudflareDeploy( return exit(1); } + // The orchestrator's own wrangler runner — created here, after the account is pinned and before + // the FIRST credentialed run of the email step below; shared by whoami / provision probes / migrate / deploy. + const runWrangler = deps.runWrangler ?? createDeployWranglerRunner(root, accountId, apiBaseUrl); + // The worker name `wrangler deploy` uploads as: the root's `name`, else derived. Needed by the + // email step (routing rules are matched to it) and reconciled into the root config at step 5. + const workerName = readWranglerTopLevelString(rootConfig, 'name') ?? deriveWorkerName(root); + + // 3b-ii. Mode B email (design doc 0078) — detect, preflight, and with one Enter set up the + // developer's own mail domain. Slotted AFTER the uploader hash (3b-i) and the account pin (3b), so its credentialed + // wrangler runs execute the pinned bundle, and BEFORE the env-schema probe (3c), so no project + // code has run when a credentialed call is made — and so `rootConfig` can simply be RE-PARSED + // after the write: the post-probe laundering guard (the full-config `deepEqual` after 3c) then + // sees `send_email` / `addresses` / `vars` as the pre-probe baseline and needs no exception. + // Only `needsEmail` apps pay for it. The step never fails the deploy on its own; + // `--require-email` turns every "deploying without email" branch into an exit BEFORE anything + // is built or uploaded. + let emailMap: EmailAddressMapInput | null = null; + if (needsEmail) { + if (!assertUploaderStillPinned()) { + return exit(1); + } + const email = await runCloudflareEmailStep({ + root, + configPath, + voidConfig, + rootConfig, + handlers: emailHandlers, + workerName, + accountId, + apiBaseUrl, + runWrangler, + nonInteractive: deps.isCI ?? isWranglerNonInteractiveOrCI(), + confirm: + deps.confirmEmailSetup ?? + (async (message) => { + const answer = await confirm({ message, initialValue: true }); + return !isCancel(answer) && answer === true; + }), + fetchImpl: deps.emailFetch ?? fetch, + dns: deps.emailDns ?? (await createNodeEmailDns()), + }); + if (options.requireEmail && !email.satisfied) { + log.error( + [ + 'deploy: --require-email — email is not fully set up for this deploy (see above); refusing to deploy.', + 'Nothing was built or uploaded. Set email up, or drop --require-email.', + ].join('\n'), + ); + return exit(1); + } + if (email.configWritten) { + rootConfig = parseRootWranglerConfig(configPath); + } + emailMap = email.map; + } else { + // The app stopped using email, but the root still carries what a Mode B deploy wrote. + // wrangler keeps those rules pointed at a worker that no longer exports `email()`, so + // inbound mail there fails with nothing in this output — say so. Warn only: deleting the + // key would ORPHAN the live rules (wrangler skips the plan on `undefined`), and `[]` is + // a destructive plan (a y/n in a TTY, a UserError after the upload in CI) — whether Void + // should write it is a product call, not a deploy default (decision 11: never a failure). + const staleAddresses = Array.isArray(rootConfig.addresses) + ? (rootConfig.addresses as Array).filter((a): a is string => typeof a === 'string') + : []; + const staleBinding = + Array.isArray(rootConfig.send_email) && + (rootConfig.send_email as Array<{ name?: unknown } | null>).some( + (e) => e?.name === SEND_EMAIL_BINDING, + ); + if (staleAddresses.length > 0 || staleBinding) { + log.warn( + [ + 'deploy: this app no longer declares email handlers or imports void/email, but wrangler.jsonc still carries the email setup Void wrote:', + ...staleAddresses.map( + (a) => + ` • ${a} — still routed to worker "${workerName}", which no longer exports email(); inbound mail there will fail.`, + ), + ...(staleBinding ? [` • send_email ${SEND_EMAIL_BINDING} binding`] : []), + 'Deploying as-is. To detach: remove `addresses`, `send_email` and `vars.__VOID_EMAIL_FROM` from wrangler.jsonc and delete the rules in the Cloudflare dashboard (Email → Email Routing → Routing rules), or set `addresses: []` and let `void deploy --platform cloudflare` ask before deleting every Void-owned rule.', + ].join('\n'), + ); + } + } + // 3c. Derive the env-INSENSITIVE schema facts — the required-secret NAMES + the keys // flagged `.secret()` — AND enforce the v1 envDir/mode narrowing, in an ISOLATED // SUBPROCESS under a scrubbed env. Importing the user's env.ts + resolving the Vite @@ -4097,9 +4641,8 @@ export async function runCloudflareDeploy( // 3e. Confirm wrangler auth. `whoami` gets the SAME `-c`/`--env=`/pinned-transport // context as migrate/deploy, so it authenticates against the exact endpoint they use. - const runWrangler = deps.runWrangler ?? createDeployWranglerRunner(root, accountId, apiBaseUrl); - // Re-verify the uploader immediately before this FIRST credentialed run — the env-schema probe - // above ran project code (env.ts) that could have overwritten the hashed bundle `whoami` executes. + // Re-verify the uploader immediately before this credentialed run — the env-schema probe above + // ran project code (env.ts) that could have overwritten the hashed bundle `whoami` executes. if (!assertUploaderStillPinned()) { return exit(1); } @@ -4340,8 +4883,8 @@ export async function runCloudflareDeploy( // file (never from CLOUDFLARE_ACCOUNT_ID) into dist/ssr/wrangler.json, so an env-only // pin would otherwise emit an artifact with NO `account_id` and the identity check // below could never confirm the pin. Persisting it makes the documented env-only pin - // deploy the verified account. - const workerName = readWranglerTopLevelString(rootConfig, 'name') ?? deriveWorkerName(root); + // deploy the verified account. (`workerName` was resolved from the same root `name` before the + // email step, which never touches it.) const frameworkQueues = detectedFramework ? scanQueuesSync(root, resolveProjectPaths(root, voidConfig).queuesDir) : []; @@ -4745,7 +5288,7 @@ export async function runCloudflareDeploy( } // Resolve + validate the D1 migrate target ONLY when there ARE checked-in migrations: a D1 - // desired purely from DB-use or auth can have no `db/migrations`, and wrangler 4.107 + // desired purely from DB-use or auth can have no `db/migrations`, and wrangler 4.131 // HARD-FAILS `d1 migrations apply` on a missing/empty folder (it never no-ops). if (desired.d1 && migrations.length > 0) { // `migrations_dir` must be present in the config: wrangler defaults a missing value to @@ -4769,7 +5312,7 @@ export async function runCloudflareDeploy( ); return exit(1); } - // wrangler 4.107 resolves the migrate target by FIRST-matching the passed token + // wrangler 4.131 resolves the migrate target by FIRST-matching the passed token // against `database_name` OR `binding` (getDatabaseInfoFromConfig, src/d1/utils.ts) // and reads THAT entry's `migrations_dir`. The gate validated the entry matched by // BINDING, so migrate by a token that resolves to exactly that entry — fail closed @@ -5107,14 +5650,14 @@ export async function runCloudflareDeploy( // generated cron/queue configuration and then synchronize triggers from the root // config, silently dropping generated Cron Triggers and queue consumers. // `--experimental-provision=false` / `--experimental-auto-create=false` disable - // wrangler 4.107's OWN resource provisioning (both default true, deployCommand + + // wrangler 4.131's OWN resource provisioning (both default true, deployCommand + // globalFlags, wrangler-dist/cli.js), so ONLY `provisionCloudflareResources` may create // resources — a bare deploy can never auto-create a missing binding (e.g. an R2 bucket // absent remotely but passing the local drift predicate), even in CI. - // `--autoconfig=false` disables wrangler 4.107's framework detection + auto-config - // (`autoconfig` defaults true, deployCommand cli.js:292315). Without it, a detected + // `--autoconfig=false` disables wrangler 4.131's framework detection + auto-config + // (`autoconfig` defaults true, deployCommand cli.js:283108). Without it, a detected // framework marker (e.g. OpenNext) can make wrangler DELEGATE the deploy to another tool - // (`maybeDelegateToOpenNextDeployCommand`, cli.js:292343) — which + // (`maybeDelegateToOpenNextDeployCommand`, cli.js:282991) — which // would upload something OTHER than the artifact Void just built and verified. Forcing it // off guarantees wrangler uploads exactly the verified `dist/ssr/wrangler.json`. // @@ -5211,6 +5754,11 @@ export async function runCloudflareDeploy( versionId: execution.versionId, }); log.success(`Deployed "${workerName}" to your Cloudflare account.\n${execution.previewUrl}`); + // The address map — the one thing nobody can find today (0078). Printed whenever the worker + // shipped with email set up; a run that went without email already said so at 3b-ii. + if (emailMap) { + log.message(renderAddressMap(emailMap)); + } } /** @@ -5218,7 +5766,7 @@ export async function runCloudflareDeploy( * writer do (wrangler.jsonc first, then wrangler.json). Falls back to the * default wrangler.jsonc path when none exists yet — the provisioner creates it. */ -function discoverRootWranglerConfig(root: string): string { +export function discoverRootWranglerConfig(root: string): string { if (existsSync(join(root, 'wrangler.toml'))) { throw new Error( 'Found wrangler.toml. Direct Cloudflare deployment needs one root wrangler.jsonc or wrangler.json with the same settings. Convert the config before deploying.', @@ -5507,12 +6055,19 @@ export async function resolveCloudflareVoidStaticPreset( const bindings = inferProjectBindings(root, { paths }); const authEnabled = bindings.needsAuth || findVoidAuthConfig(root, paths) !== null || voidConfig.auth !== undefined; + // The SAME predicate the managed path's fully-static optimization uses + // (`hasNonPageBackend`), so the two static branches can never disagree on + // whether an `email/` handler needs a worker: the thin static edge Worker + // exports no `email()`, so inbound mail to a project routed there bounces. const hasRuntimeBackend = - routeScan.routes.length > 0 || - routeScan.middleware.length > 0 || - (routeScan.websockets?.length ?? 0) > 0 || - authEnabled || - bindings.needsLive || + hasNonPageBackend({ + hasRoutes: routeScan.routes.length > 0, + hasMiddleware: routeScan.middleware.length > 0, + hasWebSockets: (routeScan.websockets?.length ?? 0) > 0, + hasEmail: routeScan.email !== undefined && hasEmailHandlers(routeScan.email), + authEnabled, + hasLive: bindings.needsLive === true, + }) || scanJobsSync(root, paths.cronsDir).length > 0 || scanQueuesSync(root, paths.queuesDir).length > 0; if (hasRuntimeBackend) { @@ -6257,6 +6812,42 @@ export function hasPrerenderSources(inputs: PrerenderSourceInputs): boolean { return inputs.hasPages || inputs.hasSsrEntry || (inputs.explicitPrerenderPaths?.length ?? 0) > 0; } +export type NonPageBackendInputs = { + hasRoutes: boolean; + hasMiddleware: boolean; + hasWebSockets: boolean; + hasEmail: boolean; + authEnabled: boolean; + hasLive: boolean; +}; + +/** + * Whether the project needs a worker for something other than its pages. + * + * An `output: "static"` project whose pages all prerender takes the static-site + * path, which uploads assets and NO worker script. Every worker surface that is + * not a page has to be listed here, because a missing one is not a failed + * deploy — it is a successful deploy with that feature silently absent. + * + * `email` belongs here for the same reason `hasRoutes` does: an `email/` + * handler compiles to the worker's `email()` export (`router/compile.ts`), and + * the platform dispatches inbound mail through that export. With no worker, the + * mail bounces at the proxy with `project has no email handler`. + * + * Schedules and queues are deliberately NOT folded in. They are checked at the + * call site against their own resolved lists, not against a scan flag. + */ +export function hasNonPageBackend(inputs: NonPageBackendInputs): boolean { + return ( + inputs.hasRoutes || + inputs.hasMiddleware || + inputs.hasWebSockets || + inputs.hasEmail || + inputs.authEnabled || + inputs.hasLive + ); +} + export function resolveStaticDeployPreset( root: string, voidConfig: VoidConfig, @@ -7494,9 +8085,15 @@ async function runFullDeploy( (p) => p.shouldPrerender && ((p.params.length === 0 && !p.catchAll) || p.hasGetPrerenderPaths), ); - const hasNonPageBackend = - hasRoutes || hasMiddleware || hasWebSockets || authEnabled || liveConfig !== undefined; - if (allPrerenderable && !hasNonPageBackend && schedules.length === 0 && queues.length === 0) { + const nonPageBackend = hasNonPageBackend({ + hasRoutes, + hasMiddleware, + hasWebSockets, + hasEmail: routeScan.email !== undefined && hasEmailHandlers(routeScan.email), + authEnabled, + hasLive: liveConfig !== undefined, + }); + if (allPrerenderable && !nonPageBackend && schedules.length === 0 && queues.length === 0) { log.info('All pages prerendered — deploying as static site'); cliLog?.info('full_to_static_redirect', { reason: 'all_pages_prerendered' }); return runStaticDeploy( diff --git a/packages/void/src/cli/email-args.ts b/packages/void/src/cli/email-args.ts new file mode 100644 index 000000000..55f85488b --- /dev/null +++ b/packages/void/src/cli/email-args.ts @@ -0,0 +1,211 @@ +import type { SubcommandDef } from './subcommand-prompt.ts'; + +const PLATFORM_FLAG = { + name: '--platform', + arg: '', + description: 'Your own Cloudflare account (`cloudflare`) instead of the Void platform', +}; + +export const emailSubcommands: Array = [ + { name: 'usage', description: 'Show monthly email send/receive counts and quota' }, + { name: 'logs', description: 'Show recent email delivery activity' }, + { name: 'destinations', description: 'List verified recipient addresses for outbound mail' }, + { name: 'allow', arg: '
', description: 'Add a recipient and trigger CF verification' }, + { name: 'disallow', arg: '
', description: 'Remove a verified recipient' }, + { + name: 'status', + description: 'Show the email setup state and address map', + flags: [PLATFORM_FLAG], + }, + { + name: 'setup', + description: 'Set email up on your own Cloudflare account (--platform cloudflare)', + flags: [PLATFORM_FLAG], + }, +]; + +const EMAIL_SUBCOMMANDS = [ + 'usage', + 'logs', + 'destinations', + 'allow', + 'disallow', + 'status', + 'setup', +] as const; + +export interface EmailArgs { + subcommand: (typeof EMAIL_SUBCOMMANDS)[number]; + projectSlug?: string; + limit?: number; + address?: string; + /** + * `--platform cloudflare`: the developer's own account (design doc 0078, Mode B). + * `--platform void` names the Void platform explicitly; leaving the flag off means the + * same. The legacy `--backend cloudflare` alias normalizes to `'cloudflare'` here. + */ + platform?: 'cloudflare' | 'void'; +} + +/** + * Read a value-taking flag in its `--flag value` or `--flag=value` form. Returns the + * raw value, or `undefined` when the flag is the last token or is followed by another + * option — the same rule `parseArgs` applies for `void deploy`, so `--platform --limit 3` + * is a missing value, not a value of `--limit`. + */ +function readFlagValue( + rest: ReadonlyArray, + i: number, + flag: string, +): { value: string | undefined; next: number } { + const token = rest[i]!; + if (token.startsWith(`${flag}=`)) { + return { value: token.slice(flag.length + 1), next: i }; + } + const value = rest[i + 1]; + if (value === undefined || value.startsWith('-')) { + return { value: undefined, next: i }; + } + return { value, next: i + 1 }; +} + +export function parseEmailArgs(args: Array): EmailArgs { + const subcommand = args[0] as EmailArgs['subcommand']; + if (!subcommand || !(EMAIL_SUBCOMMANDS as ReadonlyArray).includes(subcommand)) { + throw new Error( + 'email: Usage: void email [args] [--project ] [--limit ] [--platform cloudflare].', + ); + } + + let limit: number | undefined; + // Raw values, one per occurrence, so a repeated flag is reported as repeated even when + // its final value would have been accepted — the same precedence `void deploy` keeps. + // `--project` is collected the same way: the last value used to win silently, so a + // wrapper's override and a caller's could disagree and `void email disallow` would + // mutate whichever project came last. + const projectValues: Array = []; + const platformValues: Array = []; + const backendValues: Array = []; + const positional: Array = []; + + const rest = args.slice(1); + for (let i = 0; i < rest.length; i++) { + // `--project` and `--limit` take the same missing-value path as `--platform`: a bare + // flag used to fall through as a positional, which the read-only subcommands never + // reject — so `void email usage --project` ran against the linked project and + // `void email logs --limit` ignored the limit instead of reporting it missing. + if (rest[i] === '--project' || rest[i]?.startsWith('--project=')) { + const { value, next } = readFlagValue(rest, i, '--project'); + // `--project=` and `--project ''` (an empty shell variable) read as a bare flag: + // an empty slug is not nullish, so `runEmailCommand` would otherwise skip + // `VOID_PROJECT` and act on the linked project as if no override was given. + if (value === undefined || value.trim() === '') { + throw new Error('email: --project requires a value (a project name).'); + } + projectValues.push(value); + i = next; + } else if (rest[i] === '--limit' || rest[i]?.startsWith('--limit=')) { + const { value: raw, next } = readFlagValue(rest, i, '--limit'); + if (raw === undefined) { + throw new Error('email: --limit requires a value (a positive integer).'); + } + const n = parseInt(raw, 10); + if (!Number.isFinite(n) || n <= 0) { + throw new Error(`email: --limit must be a positive integer (got '${raw}').`); + } + limit = n; + i = next; + } else if (rest[i] === '--platform' || rest[i]?.startsWith('--platform=')) { + const { value, next } = readFlagValue(rest, i, '--platform'); + if (value === undefined) { + throw new Error("email: --platform requires a value ('cloudflare' or 'void')."); + } + platformValues.push(value); + i = next; + } else if (rest[i] === '--backend' || rest[i]?.startsWith('--backend=')) { + const { value, next } = readFlagValue(rest, i, '--backend'); + if (value === undefined) { + throw new Error("email: --backend requires a value ('cloudflare')."); + } + backendValues.push(value); + i = next; + } else { + positional.push(rest[i]); + } + } + + // Same rule as `void deploy`: `--project` at most once. + if (projectValues.length > 1) { + throw new Error('email: --project can only be specified once.'); + } + const projectSlug = projectValues[0]; + + // `--platform ` is canonical; `--backend cloudflare` is the legacy + // alias `void deploy` also keeps. Same rules as deploy: each at most once, never both. + if (backendValues.length > 1) { + throw new Error('email: --backend can only be specified once.'); + } + if (platformValues.length > 1) { + throw new Error('email: --platform can only be specified once.'); + } + if (platformValues.length > 0 && backendValues.length > 0) { + throw new Error('email: --platform and --backend cannot be used together.'); + } + + let platform: EmailArgs['platform']; + const platformValue = platformValues[0]; + if (platformValue !== undefined) { + if (platformValue !== 'cloudflare' && platformValue !== 'void') { + throw new Error("email: --platform must be 'cloudflare' or 'void'."); + } + platform = platformValue; + } + const backendValue = backendValues[0]; + if (backendValue !== undefined) { + if (backendValue !== 'cloudflare') { + throw new Error("email: --backend must be 'cloudflare'."); + } + platform = 'cloudflare'; + } + + // `void email allow|disallow` takes exactly one address. Silently + // dropping extras (the previous behavior, which kept only the first) + // led to confusing UX — `void email allow a@x.dev b@y.dev` quietly + // ignored `b@y.dev`. Error explicitly so the user knows to retry. + if (positional.length > 1) { + throw new Error( + `email: \`void email ${subcommand}\` takes exactly one positional argument (got ${positional.length}: ${positional.map((p) => JSON.stringify(p)).join(', ')}).`, + ); + } + + const address = positional[0]; + + if ((subcommand === 'allow' || subcommand === 'disallow') && !address) { + throw new Error(`email: Usage: void email ${subcommand}
[--project ].`); + } + + // The Cloudflare platform reads the local project (void.json, email/, wrangler.jsonc) + // and the developer's own wrangler session — it never resolves a Void project, so + // `--project` would name a destination it cannot use. Only `status` and `setup` + // have a Mode B shape; the platform-only subcommands reject the flag outright. + // `--platform void` is the default spelled out, so it is accepted everywhere. + if (platform === 'cloudflare') { + if (subcommand !== 'status' && subcommand !== 'setup') { + throw new Error( + `email: --platform cloudflare applies to \`void email status\` and \`void email setup\` only (\`${subcommand}\` is a Void platform command).`, + ); + } + if (projectSlug !== undefined) { + throw new Error( + 'email: --project cannot be combined with --platform cloudflare (it reads the local project, not a Void project).', + ); + } + if (address !== undefined) { + throw new Error( + `email: \`void email ${subcommand} --platform cloudflare\` takes no argument.`, + ); + } + } + + return { subcommand, projectSlug, limit, address, platform }; +} diff --git a/packages/void/src/cli/email-cloudflare.ts b/packages/void/src/cli/email-cloudflare.ts new file mode 100644 index 000000000..4c052bbb5 --- /dev/null +++ b/packages/void/src/cli/email-cloudflare.ts @@ -0,0 +1,1674 @@ +/** + * Mode B email — the developer's OWN Cloudflare account (`void deploy --platform + * cloudflare`). Design doc 0078, "Mode B — the developer's own Cloudflare account". + * + * Four pure-ish units, every Cloudflare touch injected so unit tests run on fakes: + * + * deriveAddresses email/ scan + domain → wrangler `addresses` (+ refusals) + * preflightEmail read-only probes → one `EmailPreflight` state object + * renderEmailChecklist preflight → the block shown before the one Enter + * applyEmailSetup preflight → only the rows not ready, in order, + * returning the wrangler.jsonc patch + * + * Both callers (`void deploy --platform cloudflare` step 3b-ii and `void email + * setup|status --platform cloudflare`) borrow the session bearer through the deploy + * runner's `runWranglerAuthToken` and gate the prompt on `isWranglerNonInteractiveOrCI` + * (both in `cli/deploy.ts`), so the two never disagree. + * + * Rules that hold the whole thing together (0078 "The flow, by lane"): + * + * - Void never refreshes, stores or forwards a token. Everything with a wrangler + * command goes through the injected runner (the deploy runner, pinned wrangler). + * The login session's bearer is borrowed via `getSessionBearer()` (backed by + * `wrangler auth token --json`) ONLY for the calls wrangler has no command for — + * subdomain routing enable, the subaddressing PATCH, and the `GET /zones?name=` + * zone classification — and it is dropped after each call. `fetchWithSessionBearer` + * is the ONLY function that ever receives the token; nothing here logs it, embeds + * it in a printed line, or places it in an error. + * - Rules and the catch-all are wrangler's job: Void derives `addresses`, writes the + * root config, and the existing `wrangler deploy` applies the plan. Void PRUNES an + * address already routed elsewhere so that plan is never destructive (decision 12). + * - `addresses` is written only when routing is ready for the domain, and deleted + * (never `[]`, which would delete every owned rule) otherwise (decision 11). + * - Void owns the whole top-level `addresses` array in Mode B; entries Void did not + * derive skip the email step for the deploy, array untouched (decision 13). + * - `ready` means no ACCOUNT work is left. Routing rules are `wrangler deploy`'s own + * (`rulesPending`), and the two CONFIG rows — `addresses` against the pruned set, + * `vars.__VOID_EMAIL_FROM` against `email.from` — are reconciled by a plain file write + * on every deploy, never behind the prompt (`addressesStale`, `sender`). A sending + * onboarding the committed `send_email` binding remembers as refused (Workers Free) + * is settled, not work: the binding is written only by a run that already attempted + * it. `void email setup` offers the retry (`sendingRetryable`); the deploy never does. + * - The session gate reads `whoami --json`: a `wrangler login` session must carry both + * email scopes; an API token (`CLOUDFLARE_API_TOKEN`, the CI credential) cannot list + * its permissions, so it is gated by the reads, which fail closed; a Global API Key is + * refused by name (no single bearer for the two REST-only calls). + * + * The prose parsers are PINNED to the wrangler version void ships (see + * `WRANGLER_EMAIL_PROSE_VERSION` and `test/fixtures/wrangler-email//`). + * A parser miss fails CLOSED: the row reads `unknown` and the checklist says to run + * `void email setup`. Every `wrangler email` command prints an "open beta command" + * warning on stderr, so the parsers read STDOUT only; stderr is consulted solely for + * wrangler's `[code: N]` API error token on a FAILED command. + */ + +import type { EmailHandlersScan } from '../router/email.ts'; +import { findBindingNameOwner } from './wrangler.ts'; + +/** The wrangler whose `email` prose the parsers below are pinned to. */ +export const WRANGLER_EMAIL_PROSE_VERSION = '4.131.0'; + +/** The `send_email` binding name Void provisions and the runtime reads. */ +export const SEND_EMAIL_BINDING = 'SEND_EMAIL'; + +/** The root-config `vars` key carrying `void.json` `email.from` into the worker. */ +export const EMAIL_FROM_VAR = '__VOID_EMAIL_FROM'; + +/** Cloudflare's public API base — the same one `wrangler` targets by default. */ +export const CLOUDFLARE_API_BASE_URL = 'https://api.cloudflare.com/client/v4'; + +/** The wrangler session scopes Mode B needs (`whoami --json` `tokenPermissions`). */ +export const REQUIRED_EMAIL_SCOPES: ReadonlyArray = [ + 'email_routing:write', + 'email_sending:write', +]; + +/** + * Cloudflare Email API error codes this module classifies (0078 "error map"). + * Everything else is reported verbatim. + */ +export const CF_EMAIL_ERROR_CODES: Readonly> = { + 2062: 'Email Routing is not enabled on this zone', + 2040: 'already onboarded for Email Sending', + 2036: 'account is not entitled to Email Sending', + 10105: 'account is not entitled to Email Sending', +}; + +/** The one-time dashboard step printed when the subdomain routing call cannot be made. */ +export function subdomainDashboardFallback(apex: string, domain: string): string { + return `Cloudflare dashboard → ${apex} → Email → Settings → Subdomains → add ${domain}, then redeploy`; +} + +/** The Workers Free hint printed when Email Sending onboarding fails. */ +export const SENDING_FREE_PLAN_HINT = + 'Workers Paid needed for arbitrary recipients. Inbound works; sendEmail() to verified destinations only.'; + +// ── Injected seams ───────────────────────────────────────────────────────────── + +/** Mirrors the deploy runner's structured result (`DeployWranglerRunResult`). */ +export type EmailWranglerRunResult = { + status: number; + stdout: string; + stderr: string; + /** Non-null ONLY when the child was killed by a signal — a HARD failure. */ + signal?: NodeJS.Signals | null; + /** Spawn-error message when the child could not start — a HARD failure. */ + error?: string | null; +}; + +/** + * Seam over void's pinned wrangler — structurally the deploy orchestrator's + * `DeployWranglerRun`, declared here so this module never imports `deploy.ts`. + */ +export type EmailWranglerRun = ( + args: Array, + opts: { capture: boolean }, +) => EmailWranglerRunResult | Promise; + +/** Public-DNS probes. Empty arrays mean "no record"; a throw means "could not read". */ +export interface EmailDns { + resolveMx(name: string): Promise>; + resolveTxt(name: string): Promise>>; +} + +export type EmailFetch = typeof fetch; + +/** + * Everything `preflightEmail` / `applyEmailSetup` need. Every Cloudflare touch is a + * seam; `getSessionBearer` resolves `null` when the session cannot be borrowed + * (`wrangler auth token` failed) — never throws through to the deploy. + */ +export interface EmailCloudflareInputs { + handlers: EmailHandlersScan; + /** `void.json` `email.from` — the ONLY domain input (decision 9). */ + from: string; + /** The worker name `wrangler deploy` uploads as (rules are matched to it). */ + workerName: string; + /** The reconciled effective account id the deploy is pinned to. */ + accountId: string; + /** Existing root-config `send_email` array (verbatim), if any. */ + existingSendEmail?: Array<{ name?: unknown }> | null; + /** Existing root-config top-level `addresses` array (verbatim), if any. */ + existingAddresses?: unknown; + /** Existing root-config `vars` object (verbatim) — `__VOID_EMAIL_FROM` is compared to `from`. */ + existingVars?: Record | null; + /** + * Root-config shapes the writer would refuse (`findEmailConfigConflicts`), computed by + * the caller from the parsed root config; each becomes an `all` blocker so the refusal + * lands BEFORE the Enter rather than after the account was changed. + */ + configConflicts?: Array; + /** Number of source files calling `sendEmail()` — shown in the header line only. */ + sendEmailFiles?: number; + /** Extra argv appended to every wrangler call (the deploy passes `-c --env=`). */ + wranglerArgs?: Array; + apiBaseUrl?: string; + runWrangler: EmailWranglerRun; + getSessionBearer: () => Promise; + fetchImpl: EmailFetch; + dns: EmailDns; +} + +/** + * The root-config shapes `writeProvisionedWranglerConfig` refuses for the two names Mode + * B writes — `SEND_EMAIL` into `send_email`, `__VOID_EMAIL_FROM` into `vars` — read from + * the parsed root config BEFORE anything is asked, so a collision with another binding + * type (or a malformed `send_email` / `vars`) refuses the step up front instead of after + * the account was changed. Same guard as the writer's, which stays the last line of + * defence (the file can change between preflight and write). + */ +export function findEmailConfigConflicts(config: Record): Array { + // A Set: one malformed field is reported by both lookups (the owner scan reads every field). + const conflicts = new Set(); + const check = (field: string, name: string): void => { + let owner: string | undefined; + try { + owner = findBindingNameOwner(config, field, name); + } catch (err) { + conflicts.add(`wrangler.jsonc: ${err instanceof Error ? err.message : String(err)}`); + return; + } + if (owner !== undefined) { + conflicts.add( + `wrangler.jsonc already uses ${name} as a ${owner} binding; rename it (Void writes ${name} into ${field})`, + ); + } + }; + check('send_email', SEND_EMAIL_BINDING); + check('vars', EMAIL_FROM_VAR); + return [...conflicts]; +} + +// ── Derivation ───────────────────────────────────────────────────────────────── + +export type EmailDomainMode = 'apex' | 'subdomain'; + +export interface DerivedEmailAddresses { + /** Deduplicated, literal addresses first, `*@` last. */ + addresses: Array; + /** Handlers that get NO route in this mode, with the message shown to the developer. */ + refused: Array<{ handler: string; reason: string }>; + /** `address → handler file` for the post-deploy map (catch-all listed as `*@…`). */ + routes: Array<{ address: string; handler: string }>; +} + +/** + * Host part of `email.from`, lowercased; `null` when it is not `local@host`. `void.json` + * also accepts the display-name form (`Acme `, see `config.ts`), + * so the address inside `<…>` is read when present. + * + * Validated per LABEL, not as one character class: the host is handed to wrangler as a + * positional (`email routing settings `), where a label starting with `-` is read + * by yargs as a flag (`--config.evil` becomes an option and the domain positional goes + * missing), and an empty label (`..acme.com`) is not a name at all. + */ +export function emailFromDomain(from: string): string | null { + const bracketed = /<([^<>]*)>\s*$/.exec(from.trim()); + const address = bracketed ? (bracketed[1] as string) : from; + const at = address.lastIndexOf('@'); + if (at <= 0 || at === address.length - 1) { + return null; + } + const host = address + .slice(at + 1) + .trim() + .toLowerCase(); + const labels = host.split('.'); + if (labels.length < 2 || !labels.every((l) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(l))) { + return null; + } + return host; +} + +/** + * `email/.ts` and `email/+[tag].ts` → `@`. + * `email/_default.ts` and any dynamic local part (`[name].ts`, `[name]+[tag].ts`) + * need a catch-all, which Cloudflare offers only on a zone apex: `*@` on the apex + * path, refused with a message on a subdomain. + * + * Reuses the scanner's compiled pattern kinds rather than re-parsing filenames — the + * grammar lives in `router/email.ts` and this must never disagree with the dispatcher. + */ +export function deriveAddresses( + handlers: EmailHandlersScan, + domain: string, + mode: EmailDomainMode, +): DerivedEmailAddresses { + const literals: Array = []; + const routes: Array<{ address: string; handler: string }> = []; + const refused: Array<{ handler: string; reason: string }> = []; + let needsCatchAll = false; + const catchAllHandlers: Array = []; + + for (const def of handlers.addresses) { + const handler = `email/${def.filePath}`; + if (def.pattern.kind === 'static' || def.pattern.kind === 'static-tag') { + // `address` is the lowercased basename; the literal is everything before the `+`. + const literal = def.address.split('+')[0] ?? def.address; + const address = `${literal}@${domain}`; + if (!literals.includes(address)) { + literals.push(address); + } + routes.push({ address, handler }); + continue; + } + if (mode === 'subdomain') { + refused.push({ + handler, + reason: `a dynamic local part needs a catch-all, and a catch-all exists only on a zone apex; nothing at @${domain} reaches it`, + }); + continue; + } + needsCatchAll = true; + catchAllHandlers.push(handler); + } + + if (handlers.defaultHandler) { + const handler = `email/${handlers.defaultHandler}`; + if (mode === 'subdomain') { + refused.push({ + handler, + reason: `a catch-all exists only on an apex; other @${domain} mail bounces`, + }); + } else { + needsCatchAll = true; + catchAllHandlers.push(handler); + } + } + + const addresses = [...literals]; + if (needsCatchAll) { + const catchAll = `*@${domain}`; + addresses.push(catchAll); + for (const handler of catchAllHandlers) { + routes.push({ address: catchAll, handler }); + } + } + return { addresses, refused, routes }; +} + +// ── Prose parsers (pinned to WRANGLER_EMAIL_PROSE_VERSION) ───────────────────── + +/** + * `wrangler whoami --json` — the fields Mode B reads. `tokenPermissions` is filled from + * the STORED `wrangler login` state only (4.131 `getTokenPermissions` → `getScopes`), so + * it describes the credential in use only when `authType` is `OAuth Token`; under + * `CLOUDFLARE_API_TOKEN` it is absent (or, with a stale login file beside the env token, + * it describes the file, not the token). + */ +export interface WhoamiJson { + loggedIn: boolean; + /** `OAuth Token` | `User API Token` | `Account API Token` | `Global API Key` (4.131 `getAuthType`). */ + authType?: string; + email?: string; + accounts?: Array<{ id: string; name: string }>; + tokenPermissions?: Array; +} + +export function parseWhoamiJson(stdout: string): WhoamiJson | null { + // The JSON is the whole of stdout for `--json`; tolerate a leading banner anyway. + const start = stdout.indexOf('{'); + if (start < 0) { + return null; + } + try { + const parsed = JSON.parse(stdout.slice(start)) as unknown; + if ( + !parsed || + typeof parsed !== 'object' || + typeof (parsed as WhoamiJson).loggedIn !== 'boolean' + ) { + return null; + } + return parsed as WhoamiJson; + } catch { + return null; + } +} + +/** + * `wrangler email routing settings ` stdout (4.131 `src/email-routing/settings.ts`): + * + * Email Routing for void.cloud: + * Enabled: true + * Status: ready + * Created: … + * Modified: … + * Tag: 5b66f5cb84fd7dcfdd0170515e622181 + * + * `Tag` is the Email Routing settings tag, which is the ZONE ID in practice (measured: + * void.app's tag equals its zone id). `GET /zones?name=` is the documented authority; + * it is consulted only when the session bearer is already in hand (see `preflightEmail`). + */ +export interface RoutingSettingsProse { + name: string; + enabled: boolean; + status: string; + tag: string; +} + +export function parseRoutingSettings(stdout: string): RoutingSettingsProse | null { + const name = /^Email Routing for (\S+):\s*$/m.exec(stdout)?.[1]; + const enabled = /^\s*Enabled:\s+(true|false)\s*$/m.exec(stdout)?.[1]; + const status = /^\s*Status:\s+(\S+)\s*$/m.exec(stdout)?.[1]; + const tag = /^\s*Tag:\s+([0-9a-f]{32})\s*$/m.exec(stdout)?.[1]; + if (!name || !enabled || !status || !tag) { + return null; + } + return { name, enabled: enabled === 'true', status, tag }; +} + +export interface RoutingRuleProse { + id: string; + name: string; + enabled: boolean; + /** `to:
` matchers, lowercased addresses. */ + to: Array; + /** `worker:`, `forward:,`, `drop` — verbatim wrangler tokens. */ + actions: Array; +} + +export interface RoutingRulesProse { + rules: Array; + catchAll: { enabled: boolean; actions: Array } | null; +} + +/** + * `wrangler email routing rules list ` stdout (4.131 `src/email-routing/rules/list.ts`): + * + * Rule: + * Name: + * Enabled: true + * Matchers: to:support@void.cloud + * Actions: worker:acme-support + * Priority: 0 + * + * Catch-all rule: disabled, action: drop + * (use `wrangler email routing rules get catch-all` to view details) + * + * With no rule at all: `No routing rules found.` / no custom rule: `No custom routing + * rules found.`. Both forms are pinned by fixture. + */ +export function parseRoutingRules(stdout: string): RoutingRulesProse | null { + const lines = stdout.split(/\r?\n/); + const rules: Array = []; + let catchAll: RoutingRulesProse['catchAll'] = null; + let sawSentinel = false; + let current: RoutingRuleProse | null = null; + + for (const raw of lines) { + const line = raw.trimEnd(); + if (/^No (custom )?routing rules found\.$/.test(line)) { + sawSentinel = true; + continue; + } + const catchAllMatch = /^Catch-all rule: (enabled|disabled), action: (.*)$/.exec(line); + if (catchAllMatch) { + catchAll = { + enabled: catchAllMatch[1] === 'enabled', + actions: splitActionList(catchAllMatch[2] ?? ''), + }; + current = null; + continue; + } + const ruleMatch = /^Rule: (\S+)$/.exec(line); + if (ruleMatch) { + current = { id: ruleMatch[1] as string, name: '', enabled: false, to: [], actions: [] }; + rules.push(current); + continue; + } + if (!current) { + continue; + } + const field = /^\s{2}(Name|Enabled|Matchers|Actions|Priority):\s*(.*)$/.exec(line); + if (!field) { + continue; + } + const value = (field[2] ?? '').trim(); + switch (field[1]) { + case 'Name': + current.name = value; + break; + case 'Enabled': + current.enabled = value === 'true'; + break; + case 'Matchers': + current.to = value + .split(', ') + .filter((m) => m.startsWith('to:')) + .map((m) => m.slice(3).toLowerCase()); + break; + case 'Actions': + current.actions = splitActionList(value); + break; + default: + break; + } + } + + // Fail closed on prose we did not recognise: an output with neither a sentinel, a + // rule, nor a catch-all line is not this wrangler's rules listing. + if (!sawSentinel && rules.length === 0 && !catchAll) { + return null; + } + return { rules, catchAll }; +} + +function splitActionList(value: string): Array { + return value + .split(', ') + .map((a) => a.trim()) + .filter(Boolean); +} + +/** + * `wrangler email sending settings ` stdout (4.131 `src/email-routing/sending/settings.ts`): + * + * Email Sending for void.cloud: + * Enabled: true + * Tag: 19af4fdbee924a92916afea0de5a8fdd + * … + * Return-path domain: cf-bounce.void.cloud + * + * Not onboarded: exit 1 with `No sending subdomain found for \`\`.` on STDERR. + */ +export interface SendingSettingsProse { + name: string; + enabled: boolean; + tag: string; +} + +export function parseSendingSettings(stdout: string): SendingSettingsProse | null { + const name = /^Email Sending for (\S+):\s*$/m.exec(stdout)?.[1]; + const enabled = /^\s*Enabled:\s+(true|false)\s*$/m.exec(stdout)?.[1]; + const tag = /^\s*Tag:\s+([0-9a-f]{32})\s*$/m.exec(stdout)?.[1]; + if (!name || !enabled || !tag) { + return null; + } + return { name, enabled: enabled === 'true', tag }; +} + +/** wrangler renders an `APIError` note as ` [code: ]`; extract the code. */ +export function wranglerApiErrorCode(text: string): number | null { + const match = /\[code:\s*(\d+)\]/.exec(stripAnsi(text)); + return match ? Number(match[1]) : null; +} + +const ANSI_SGR_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + +function stripAnsi(text: string): string { + return text.replace(ANSI_SGR_RE, ''); +} + +const ZONE_NOT_FOUND_RE = /Could not find (?:a )?zone for `([^`]+)`/; +const SENDING_NOT_FOUND_RE = /No sending subdomain found for `([^`]+)`/; + +function isHardFailure(result: EmailWranglerRunResult): boolean { + return Boolean(result.signal) || Boolean(result.error); +} + +// ── Preflight ────────────────────────────────────────────────────────────────── + +export type ReadyState = 'ready' | 'pending' | 'unknown'; + +export interface EmailPreflightBlocker { + /** `all` stops the whole email step; `sending` skips only Email Sending onboarding. */ + scope: 'all' | 'sending'; + message: string; +} + +export interface EmailPreflight { + from: string; + domain: string; + /** The zone the domain belongs to, once resolved (`domain` itself on the apex path). */ + apex: string | null; + mode: EmailDomainMode | null; + workerName: string; + accountId: string; + usage: { handlers: Array; sendEmailFiles?: number }; + session: { + state: 'ok' | 'not-logged-in' | 'missing-scopes' | 'api-key' | 'unknown'; + /** `whoami --json` `authType`; an API token's permissions are not enumerable. */ + authType?: string; + email?: string; + accountName?: string; + missingScopes: Array; + }; + zone: { + state: 'found' | 'not-found' | 'other-account' | 'partial' | 'unknown'; + zoneId?: string; + /** `full` | `partial` | `secondary` from `GET /zones?name=`; unset without the bearer. */ + type?: string; + }; + /** MX on the zone apex; `foreign` when a non-Cloudflare exchange answers. */ + apexMx: { state: 'empty' | 'cloudflare' | 'foreign' | 'unknown'; exchanges: Array }; + /** MX on the mail domain itself (same as `apexMx` on the apex path). */ + domainMx: { state: 'empty' | 'cloudflare' | 'foreign' | 'unknown'; exchanges: Array }; + routing: { state: ReadyState; status?: string; enabled?: boolean }; + /** Readable only through the bearer (`GET /zones/{z}/email/routing`); `unknown` without it. */ + subaddressing: { state: 'on' | 'off' | 'unknown' }; + sending: { state: ReadyState; dnsConflicts: Array }; + rules: { + state: ReadyState; + mine: Array; + foreign: Array<{ address: string; owner: string }>; + absent: Array; + }; + binding: { state: 'present' | 'absent' | 'foreign'; foreignName?: string }; + /** Root-config `vars.__VOID_EMAIL_FROM` against `from` — a config row, not an account row. */ + sender: { state: 'ready' | 'stale' | 'absent' }; + addressesOwnership: { state: 'absent' | 'mine' | 'foreign'; foreign: Array }; + /** + * True when the root `addresses` array (absent, or Void's own) is not the pruned set + * below — a local, prompt-free rewrite, never account work, so it does not gate `ready`. + * A stale entry left in place reaches wrangler's plan as a `conflict` (decision 12). + */ + addressesStale: boolean; + derived: DerivedEmailAddresses; + /** `derived.addresses` minus the foreign-routed ones — what `addresses` will hold. */ + addresses: Array; + /** Derived addresses in `addresses` with no rule yet — `wrangler deploy` creates them. */ + rulesPending: Array; + blockers: Array; + /** + * True when Email Sending onboarding is still on the table: the row is `pending` and no + * sending-scoped blocker stands in the way — exactly when apply would make its one + * `wrangler email sending enable` call (the checklist's `onboard … for Email Sending` + * row). With the binding committed such a row is SETTLED (`ready` stays true; see the + * memory rule in `preflightEmail`), so this is what `void email setup` offers as its own + * prompt — the retry after a Workers Paid upgrade — instead of running it unasked. ONE + * field, computed here, so the offer and apply's call cannot drift. + */ + sendingRetryable: boolean; + /** + * True when nothing is left for APPLY to do on the account — no prompt, zero calls — + * with one remembered exception: a sending row the committed binding records as refused + * (`sendingRetryable`), which apply WOULD retry, so the deploy never runs apply on a + * ready preflight and `void email setup` asks first. Routing rules are wrangler's + * (`rulesPending` is informational), and the two config rows (`sender`, + * `addressesStale`) are reconciled by a plain file write. + */ + ready: boolean; +} + +function mxState(exchanges: Array): 'empty' | 'cloudflare' | 'foreign' { + if (exchanges.length === 0) { + return 'empty'; + } + return exchanges.every((x) => /\.mx\.cloudflare\.net\.?$/i.test(x)) ? 'cloudflare' : 'foreign'; +} + +async function probeMx( + dns: EmailDns, + name: string, +): Promise<{ state: 'empty' | 'cloudflare' | 'foreign' | 'unknown'; exchanges: Array }> { + try { + const records = await dns.resolveMx(name); + const exchanges = records + .slice() + .sort((a, b) => a.priority - b.priority) + .map((r) => r.exchange.toLowerCase()); + return { state: mxState(exchanges), exchanges }; + } catch (err) { + if (isNoRecord(err)) { + return { state: 'empty', exchanges: [] }; + } + return { state: 'unknown', exchanges: [] }; + } +} + +async function probeTxt(dns: EmailDns, name: string): Promise<'present' | 'absent' | 'unknown'> { + try { + const records = await dns.resolveTxt(name); + return records.length > 0 ? 'present' : 'absent'; + } catch (err) { + return isNoRecord(err) ? 'absent' : 'unknown'; + } +} + +function isNoRecord(err: unknown): boolean { + const code = (err as { code?: unknown } | null)?.code; + return code === 'ENODATA' || code === 'ENOTFOUND'; +} + +/** Candidate zone names for `mail.acme.com`: `mail.acme.com`, `acme.com`. */ +function zoneCandidates(domain: string): Array { + const labels = domain.split('.'); + const out: Array = []; + for (let i = 0; i <= labels.length - 2; i += 1) { + out.push(labels.slice(i).join('.')); + } + return out; +} + +function shortId(id: string): string { + return `${id.slice(0, 8)}…`; +} + +/** + * Read-only preflight. Order matters only for cost: public DNS first (free), then + * `whoami` (no network beyond wrangler's own), then the three `wrangler email` reads, + * then — if the bearer can be borrowed — the two GETs that have no wrangler command. + * Nothing here writes. + */ +export async function preflightEmail(inputs: EmailCloudflareInputs): Promise { + const domain = emailFromDomain(inputs.from); + if (!domain) { + throw new Error( + `email.from must be an address like you@mail.example.com (got ${JSON.stringify(inputs.from)})`, + ); + } + const apiBaseUrl = inputs.apiBaseUrl ?? CLOUDFLARE_API_BASE_URL; + const extra = inputs.wranglerArgs ?? []; + const run = async (args: Array): Promise => + inputs.runWrangler([...args, ...extra], { capture: true }); + + const usageHandlers = [ + ...inputs.handlers.addresses.map((d) => `email/${d.filePath}`), + ...(inputs.handlers.defaultHandler ? [`email/${inputs.handlers.defaultHandler}`] : []), + ]; + + const pre: EmailPreflight = { + from: inputs.from, + domain, + apex: null, + mode: null, + workerName: inputs.workerName, + accountId: inputs.accountId, + usage: { handlers: usageHandlers, sendEmailFiles: inputs.sendEmailFiles }, + session: { state: 'unknown', missingScopes: [] }, + zone: { state: 'unknown' }, + apexMx: { state: 'unknown', exchanges: [] }, + domainMx: { state: 'unknown', exchanges: [] }, + routing: { state: 'unknown' }, + subaddressing: { state: 'unknown' }, + sending: { state: 'unknown', dnsConflicts: [] }, + rules: { state: 'unknown', mine: [], foreign: [], absent: [] }, + binding: { state: 'absent' }, + sender: { state: 'absent' }, + addressesOwnership: { state: 'absent', foreign: [] }, + addressesStale: false, + derived: { addresses: [], refused: [], routes: [] }, + addresses: [], + rulesPending: [], + blockers: [], + sendingRetryable: false, + ready: false, + }; + const block = (scope: 'all' | 'sending', message: string): void => { + pre.blockers.push({ scope, message }); + }; + // Every writer refusal the caller precomputed lands here, before anything is asked. + for (const conflict of inputs.configConflicts ?? []) { + block('all', conflict); + } + + // 1. Session: logged in, and — for a browser (OAuth) session — both email scopes + // (0078: a session that predates wrangler 4.81 has neither; the pinned wrangler's + // default `login` scope set carries both, one browser Allow). An API token's + // permissions are not enumerable (`tokenPermissions` never describes it), so the gate + // is the reads below: a refused read fails closed as an `unknown` row. A Global API + // Key has no single bearer `auth token` can hand over, so the subaddressing rows could + // never read; it is refused up front, by name. + // + // Every blocker names `void cloudflare login` (AGENTS.md: Cloudflare operations are + // described through Void, never as a wrangler command). That command is a no-op on a + // session `whoami --json` already reads cleanly — it re-uses the existing session + // rather than re-consenting — so a session that IS readable but insufficient (the + // scope and Global API Key branches) says what to clear first: `void cloudflare + // logout`, or the CLOUDFLARE_API_KEY pair, which wrangler prefers over both an OAuth + // session and CLOUDFLARE_API_TOKEN. + const whoami = await run(['whoami', '--json']); + const who = isHardFailure(whoami) || whoami.status !== 0 ? null : parseWhoamiJson(whoami.stdout); + if (!who) { + // `whoami --json` failed or printed no session. + pre.session.state = 'unknown'; + block('all', 'could not read the Cloudflare session — run `void cloudflare login`'); + } else if (!who.loggedIn) { + pre.session.state = 'not-logged-in'; + block('all', 'not logged in to Cloudflare — run `void cloudflare login`'); + } else { + pre.session.authType = who.authType; + pre.session.email = who.email; + pre.session.accountName = who.accounts?.find((a) => a.id === inputs.accountId)?.name; + if (who.authType === 'Global API Key') { + pre.session.state = 'api-key'; + block( + 'all', + 'the Cloudflare session is a Global API Key (CLOUDFLARE_API_KEY), which has no bearer to borrow for the subaddressing calls — unset CLOUDFLARE_API_KEY, then run `void cloudflare login` or set CLOUDFLARE_API_TOKEN to a token with Email Routing Edit + Email Sending Edit', + ); + } else if (who.authType === undefined || who.authType === 'OAuth Token') { + // A missing `authType` is an older payload shape: keep the scope gate (fail closed). + const scopes = new Set(who.tokenPermissions ?? []); + pre.session.missingScopes = REQUIRED_EMAIL_SCOPES.filter((s) => !scopes.has(s)); + if (pre.session.missingScopes.length > 0) { + pre.session.state = 'missing-scopes'; + block( + 'all', + `the Cloudflare session lacks ${pre.session.missingScopes.join(' + ')} — run \`void cloudflare logout\`, then \`void cloudflare login\` (one browser Allow)`, + ); + } else { + pre.session.state = 'ok'; + } + } else { + pre.session.state = 'ok'; + } + } + const tokenSession = pre.session.state === 'ok' && pre.session.authType !== 'OAuth Token'; + // Appended to a row an API-token session could not read: the likeliest cause is scope. + const tokenHint = tokenSession + ? ' (an API token needs Email Routing Edit + Email Sending Edit on the zone and account)' + : ''; + + // 2. Zone: walk the labels the way wrangler's own `resolveDomain` does, through + // `email routing settings ` — the first candidate that is a zone in the + // pinned account is the apex, and its prose also carries routing status + zone id. + let settings: RoutingSettingsProse | null = null; + let settingsMiss = false; + if (pre.session.state === 'ok') { + for (const candidate of zoneCandidates(domain)) { + const result = await run(['email', 'routing', 'settings', candidate]); + if (isHardFailure(result)) { + settingsMiss = true; + break; + } + if (result.status !== 0) { + if (ZONE_NOT_FOUND_RE.test(stripAnsi(result.stderr))) { + continue; + } + settingsMiss = true; + break; + } + settings = parseRoutingSettings(result.stdout); + if (!settings) { + settingsMiss = true; + } + break; + } + } + if (settings) { + pre.apex = settings.name.toLowerCase(); + pre.mode = pre.apex === domain ? 'apex' : 'subdomain'; + pre.zone = { state: 'found', zoneId: settings.tag }; + } else if (settingsMiss) { + block( + 'all', + `could not read Email Routing settings — unknown — run \`void email setup --platform cloudflare\`${tokenHint}`, + ); + } else if (pre.session.state === 'ok') { + pre.zone.state = 'not-found'; + } + + // 3. Public DNS. Foreign MX on the mail domain is the one thing Void never routes over. + pre.domainMx = await probeMx(inputs.dns, domain); + pre.apexMx = pre.apex && pre.apex !== domain ? await probeMx(inputs.dns, pre.apex) : pre.domainMx; + const dmarc = await probeTxt(inputs.dns, `_dmarc.${domain}`); + const bounce = await probeTxt(inputs.dns, `cf-bounce.${domain}`); + if (dmarc === 'present') { + pre.sending.dnsConflicts.push(`_dmarc.${domain}`); + } + if (bounce === 'present') { + pre.sending.dnsConflicts.push(`cf-bounce.${domain}`); + } + if (dmarc === 'unknown' || bounce === 'unknown') { + pre.sending.dnsConflicts.push(`(DNS lookup failed for _dmarc.${domain} / cf-bounce.${domain})`); + } + + // 4. The bearer, borrowed once for the two reads wrangler has no command for: + // `GET /zones?name=` (documented zone authority — WITHOUT `account.id`, so a + // zone in another account is told apart from one not on Cloudflare, and `type` + // refuses partial/secondary zones) and `GET /zones/{z}/email/routing` + // (`support_subaddress`, which the settings prose does not print). A `null` bearer + // leaves both rows `unknown`; nothing about the deploy fails on that. + let bearer: string | null = null; + if (pre.session.state === 'ok') { + bearer = await inputs.getSessionBearer().catch(() => null); + } + if (bearer) { + const candidates = pre.apex ? [pre.apex] : zoneCandidates(domain); + for (const candidate of candidates) { + const zones = await fetchWithSessionBearer( + inputs.fetchImpl, + bearer, + 'GET', + `/zones?name=${encodeURIComponent(candidate)}`, + undefined, + apiBaseUrl, + ); + if (!zones.ok) { + break; + } + const list = Array.isArray(zones.result) ? (zones.result as Array) : []; + const zone = list[0]; + if (!zone) { + continue; + } + pre.zone.type = zone.type; + if (zone.account?.id && zone.account.id !== inputs.accountId) { + pre.zone = { state: 'other-account', zoneId: zone.id, type: zone.type }; + } else if (zone.type && zone.type !== 'full') { + pre.zone = { state: 'partial', zoneId: zone.id, type: zone.type }; + } else { + pre.zone = { state: 'found', zoneId: zone.id, type: zone.type }; + if (!pre.apex) { + pre.apex = zone.name.toLowerCase(); + pre.mode = pre.apex === domain ? 'apex' : 'subdomain'; + } + } + break; + } + if (pre.zone.state === 'found' && pre.zone.zoneId) { + const routing = await fetchWithSessionBearer( + inputs.fetchImpl, + bearer, + 'GET', + `/zones/${pre.zone.zoneId}/email/routing`, + undefined, + apiBaseUrl, + ); + const flag = (routing.result as { support_subaddress?: unknown } | null)?.support_subaddress; + if (routing.ok && typeof flag === 'boolean') { + pre.subaddressing.state = flag ? 'on' : 'off'; + } + } + } + bearer = null; + + switch (pre.zone.state) { + case 'not-found': + block( + 'all', + `no zone for ${domain} in Cloudflare account ${inputs.accountId} — add the domain to this account first`, + ); + break; + case 'other-account': + block( + 'all', + `${pre.apex ?? domain} is a zone in a different Cloudflare account than ${inputs.accountId}`, + ); + break; + case 'partial': + block( + 'all', + `${pre.apex ?? domain} is a ${pre.zone.type} zone; Email Routing needs a full zone on Cloudflare DNS`, + ); + break; + default: + break; + } + + if (!pre.apex || !pre.mode) { + pre.ready = false; + return pre; + } + const apex = pre.apex; + const mode = pre.mode; + + // 5. Routing status. `ready` is the only good state. `unlocked` / `misconfigured*` + // mean a developer changed the locked records — print, never re-lock (0078). + if (settings) { + if (mode === 'apex') { + pre.routing = { + state: settings.enabled && settings.status === 'ready' ? 'ready' : 'pending', + status: settings.status, + enabled: settings.enabled, + }; + if (/^(unlocked|misconfigured)/.test(settings.status)) { + block( + 'all', + `Email Routing on ${apex} is ${settings.status} — fix it in the Cloudflare dashboard; Void never re-locks records you unlocked`, + ); + } + } else { + // No command reads a subdomain's routing state (`GET …/routing/dns?subdomain=` is + // deprecated), so the subdomain's MX is the readiness signal: Cloudflare's own + // exchanges mean enabled. The apex prose still guards against an unlocked apex. + pre.routing = { + state: + pre.domainMx.state === 'cloudflare' + ? 'ready' + : pre.domainMx.state === 'unknown' + ? 'unknown' + : 'pending', + status: settings.status, + enabled: settings.enabled, + }; + if (/^(unlocked|misconfigured)/.test(settings.status)) { + block( + 'all', + `Email Routing on ${apex} is ${settings.status} — fix it in the Cloudflare dashboard; Void never re-locks records you unlocked`, + ); + } + } + } + if (pre.routing.state !== 'ready') { + if (pre.domainMx.state === 'foreign') { + block( + 'all', + `${domain} already receives mail (${pre.domainMx.exchanges.join(', ')}). Void never enables routing over live mail.${ + mode === 'apex' ? ` Use something@mail.${apex}.` : '' + }`, + ); + } else if (pre.domainMx.state === 'unknown') { + block( + 'all', + `could not read the MX records of ${domain} — unknown — run \`void email setup --platform cloudflare\``, + ); + } + } + + // 6. Derivation, then rules: mine / foreign / absent per derived address. + pre.derived = deriveAddresses(inputs.handlers, domain, mode); + const rulesResult = await run(['email', 'routing', 'rules', 'list', apex]); + const rules = + isHardFailure(rulesResult) || rulesResult.status !== 0 + ? null + : parseRoutingRules(rulesResult.stdout); + if (!rules) { + pre.rules.state = 'unknown'; + block( + 'all', + `could not read Email Routing rules — unknown — run \`void email setup --platform cloudflare\`${tokenHint}`, + ); + } else { + const workerToken = `worker:${inputs.workerName}`; + for (const address of pre.derived.addresses) { + if (address.startsWith('*@')) { + if (!rules.catchAll || !rules.catchAll.enabled || rules.catchAll.actions.includes('drop')) { + pre.rules.absent.push(address); + } else if (rules.catchAll.actions.includes(workerToken)) { + pre.rules.mine.push(address); + } else { + pre.rules.foreign.push({ address, owner: rules.catchAll.actions.join(', ') }); + } + continue; + } + // An enabled rule decides the row. A disabled one routes nothing: OUR disabled rule + // reads `absent`, like the disabled catch-all above — the address stays in + // `addresses`, and wrangler's plan (which writes every rule `enabled: true`) turns + // it back on at the next deploy. A disabled FOREIGN rule stays `foreign`: the + // matcher is still somebody else's, and taking it over is the same conflict the + // enabled case prunes the address to avoid. + const rule = + rules.rules.find((r) => r.enabled && r.to.includes(address)) ?? + rules.rules.find((r) => r.to.includes(address)); + if (!rule) { + pre.rules.absent.push(address); + } else if (rule.actions.includes(workerToken)) { + if (rule.enabled) { + pre.rules.mine.push(address); + } else { + pre.rules.absent.push(address); + } + } else { + pre.rules.foreign.push({ address, owner: rule.actions.join(', ') }); + } + } + pre.rules.state = pre.rules.absent.length === 0 ? 'ready' : 'pending'; + } + const foreign = new Set(pre.rules.foreign.map((f) => f.address)); + pre.addresses = pre.derived.addresses.filter((a) => !foreign.has(a)); + + // 7. Sending. Not-found is a clean "not onboarded"; anything else unparsable is unknown. + const sendingResult = await run(['email', 'sending', 'settings', domain]); + if (isHardFailure(sendingResult)) { + pre.sending.state = 'unknown'; + } else if (sendingResult.status === 0) { + const parsed = parseSendingSettings(sendingResult.stdout); + pre.sending.state = parsed ? (parsed.enabled ? 'ready' : 'pending') : 'unknown'; + } else if (SENDING_NOT_FOUND_RE.test(stripAnsi(sendingResult.stderr))) { + pre.sending.state = 'pending'; + } else { + pre.sending.state = 'unknown'; + } + if (pre.sending.state === 'unknown') { + block( + 'sending', + `could not read Email Sending settings — unknown — run \`void email setup --platform cloudflare\`${tokenHint}`, + ); + } else if (pre.sending.state === 'pending' && pre.sending.dnsConflicts.length > 0) { + block( + 'sending', + `${pre.sending.dnsConflicts.join(', ')} already exists; onboarding writes its own _dmarc (p=reject) and DKIM records and would conflict — remove them or keep sending off`, + ); + } + + // 8. The config rows: `send_email` binding and `addresses` ownership (decision 13). + const existingSendEmail = inputs.existingSendEmail ?? []; + const names = existingSendEmail.map((e) => (typeof e?.name === 'string' ? e.name : '')); + if (names.includes(SEND_EMAIL_BINDING)) { + pre.binding = { state: 'present' }; + } else if (names.length > 0) { + pre.binding = { state: 'foreign', foreignName: names[0] }; + block( + 'all', + `wrangler.jsonc already has a send_email binding named ${JSON.stringify(names[0])}; rename it to ${SEND_EMAIL_BINDING} (a second binding would be silent)`, + ); + } + if (inputs.existingAddresses !== undefined && inputs.existingAddresses !== null) { + const existing = Array.isArray(inputs.existingAddresses) + ? inputs.existingAddresses.map((a) => (typeof a === 'string' ? a.toLowerCase() : String(a))) + : ['(not an array)']; + // The candidate set is THIS scan's derivation. Void records no provenance for the + // entries it wrote on an earlier deploy (the array carries no marker), so an entry + // left behind by a deleted handler and a hand-written one are the same thing here: + // both are `unknown`, and the whole step is skipped with the array untouched + // (decision 13). With no handlers left at all the candidate set is empty and every + // committed entry is `unknown` — the rule stays routed to this worker. Whether such + // a leftover should instead be Void-managed (pruned, or `[]` when nothing is left) is + // decision 13's other branch; see the deploy step's note above `patch.addresses`. + const candidates = new Set(pre.derived.addresses); + const unknown = existing.filter((a) => !candidates.has(a)); + if (unknown.length > 0) { + pre.addressesOwnership = { state: 'foreign', foreign: unknown }; + block( + 'all', + `wrangler.jsonc addresses holds entries Void did not derive (${unknown.join(', ')}); remove ${unknown.map((a) => JSON.stringify(a)).join(', ')} from addresses, or manage addresses by hand and unset email.from — skipping email setup this deploy`, + ); + } else { + pre.addressesOwnership = { state: 'mine', foreign: [] }; + // Void's own array, but not the pruned set: a foreign-routed entry left in place + // (or a worker rename, which makes every rule foreign) reaches wrangler's plan as a + // `conflict`, and `[]` would plan the deletion of every owned rule. + const pruned = new Set(pre.addresses); + pre.addressesStale = + existing.length !== pre.addresses.length || existing.some((a) => !pruned.has(a)); + } + } else { + pre.addressesStale = pre.addresses.length > 0; + } + const senderVar = inputs.existingVars?.[EMAIL_FROM_VAR]; + pre.sender.state = + senderVar === inputs.from ? 'ready' : senderVar === undefined ? 'absent' : 'stale'; + pre.rulesPending = pre.rules.absent.filter((a) => pre.addresses.includes(a)); + + // Ready = nothing left for apply to do on the ACCOUNT. A refused handler and a pruned + // address are `!` rows, not work; a sending-scoped blocker is something Void cannot act + // on, so it does not keep every later deploy prompting. Routing rules are created by + // `wrangler deploy` from the written `addresses`, so a rule not there yet is not apply's + // work either (`rulesPending`); the same goes for the config rows, which a plain file + // write reconciles without a prompt. + // + // A sending row still pending WITH the binding committed is settled too — the committed + // binding is the memory of a refused onboarding. `sending settings` answers not-found + // for "Workers Free" and for "Paid, never onboarded" alike, so no read tells the two + // apart; only the mutating `sending enable` does. And `applyEmailSetup` writes + // `send_email` ONLY on a run that has already made that call: the binding goes in once + // routing or sending came up, and `enable` runs on every apply whose sending row is not + // ready and not blocked. So binding present + sending pending reads "attempted and + // refused" (Workers Free, `10105`). Without this rule the refusal came back on every + // deploy — a prompt in a TTY, "cannot ask" in CI, `--require-email` refused forever, + // `void email status` exit 1 — against a setup that is complete for inbound (replies + // go through the native `message.reply()`, never the binding) and sends to verified + // destinations. The cost: a later Workers Paid upgrade is not picked up by the deploy; + // `void email setup` offers the retry (`sendingRetryable`). A binding committed by hand + // reads the same way — the developer chose it. Note that with the binding present every + // sending state is settled today (`unknown` always carries a sending-scoped blocker), so + // the row gates `ready` only through the binding; the three terms stay apart because + // each is settled for a different reason. + const sendingBlocked = pre.blockers.some((b) => b.scope === 'sending'); + pre.sendingRetryable = pre.sending.state === 'pending' && !sendingBlocked; + const sendingSettled = + pre.sending.state === 'ready' || + sendingBlocked || + (pre.sending.state === 'pending' && pre.binding.state === 'present'); + pre.ready = + !pre.blockers.some((b) => b.scope === 'all') && + pre.routing.state === 'ready' && + pre.subaddressing.state === 'on' && + sendingSettled && + pre.binding.state === 'present'; + return pre; +} + +type ZoneRecord = { id: string; name: string; type?: string; account?: { id?: string } }; + +// ── Checklist ────────────────────────────────────────────────────────────────── + +/** Column padding for the checklist; an overflowing cell gets three spaces (0078 transcript). */ +function pad(text: string, width: number): string { + return text.length >= width ? `${text} ` : text.padEnd(width); +} + +/** + * The block shown before the one Enter — status rows, then the `+`/`!` change list + * under the "This changes YOUR Cloudflare account" line (0078 transcript). Blockers + * render as `✘` rows at the end so the developer sees why nothing will be asked. + */ +export function renderEmailChecklist(pre: EmailPreflight): string { + const apex = pre.apex ?? '?'; + const lines: Array = []; + const inUse = [ + ...pre.usage.handlers, + ...(pre.usage.sendEmailFiles + ? [ + `sendEmail() in ${pre.usage.sendEmailFiles} file${pre.usage.sendEmailFiles === 1 ? '' : 's'}`, + ] + : []), + ]; + lines.push(` Email in use ${inUse.join(' · ')}`); + const row = (label: string, value: string, note = ''): void => { + lines.push(` ${pad(label, 10)}${note ? `${pad(value, 23)}${note}` : value}`); + }; + row('domain', pre.domain, '(void.json email.from)'); + const session = [ + pre.session.accountName + ? `account ${pre.session.accountName} (${shortId(pre.accountId)})` + : `account ${shortId(pre.accountId)}`, + // An API token's permissions cannot be listed; a refused read shows up as its row. + pre.session.authType && pre.session.authType !== 'OAuth Token' + ? `session ${pre.session.email ? `${pre.session.email} ` : ''}(${pre.session.authType})` + : pre.session.email + ? `session ${pre.session.email}` + : null, + ] + .filter(Boolean) + .join(' · '); + row('zone', pre.zone.state === 'found' ? apex : `${apex} ${pre.zone.state}`, session); + if (pre.mode === 'subdomain') { + row( + 'apex MX', + pre.apexMx.exchanges[0] ?? (pre.apexMx.state === 'unknown' ? 'unknown' : 'none'), + 'left alone — mail lives on the subdomain', + ); + } else { + row( + 'apex MX', + pre.apexMx.exchanges[0] ?? (pre.apexMx.state === 'unknown' ? 'unknown' : 'none'), + pre.apexMx.state === 'cloudflare' + ? 'Cloudflare Email Routing' + : pre.apexMx.state === 'empty' + ? 'empty — the apex can take routing' + : '', + ); + } + const routingWord = + pre.routing.state === 'ready' + ? 'enabled' + : pre.routing.state === 'unknown' + ? 'unknown — run void email setup --platform cloudflare' + : `not enabled${pre.mode === 'apex' && pre.routing.status && pre.routing.status !== 'unconfigured' ? ` (${pre.routing.status})` : ''}`; + const subWord = + pre.subaddressing.state === 'on' + ? 'subaddressing on' + : pre.subaddressing.state === 'off' + ? 'subaddressing off' + : 'subaddressing unknown'; + row('routing', `${pre.domain} ${routingWord} · ${subWord}`); + // A pending row the committed binding remembers as refused (see `preflightEmail`) reads + // settled, so it says what still works and where the retry lives; a sending-scoped + // blocker keeps the bare word — its ✘ row at the end carries the reason. + const sendingWord = + pre.sending.state === 'ready' + ? 'onboarded' + : pre.sending.state === 'unknown' + ? 'unknown — run void email setup --platform cloudflare' + : pre.sendingRetryable && pre.binding.state === 'present' + ? 'not onboarded — verified destinations only; after upgrading to Workers Paid run void email setup --platform cloudflare' + : 'not onboarded'; + row('sending', `${pre.domain} ${sendingWord}`); + if (pre.derived.addresses.length === 0) { + row('rules', '(none — no email/ handlers)'); + } + for (const address of pre.derived.addresses) { + const foreignRule = pre.rules.foreign.find((f) => f.address === address); + const status = pre.rules.mine.includes(address) + ? 'ready' + : foreignRule + ? `owned elsewhere: ${foreignRule.owner} — pruned` + : pre.rules.state === 'unknown' + ? 'unknown' + : 'absent'; + row('rules', `${address} → ${pre.workerName}`, `(${status})`); + } + row( + 'binding', + SEND_EMAIL_BINDING, + `(${pre.binding.state === 'present' ? 'present' : pre.binding.state === 'foreign' ? `found ${pre.binding.foreignName} instead` : 'absent'})`, + ); + row('sender', pre.from, `(vars.${EMAIL_FROM_VAR} ${pre.sender.state})`); + lines.push(''); + + const changes: Array = []; + const change = (what: string, why: string): void => { + changes.push(` + ${pad(what, 44)}${why}`); + }; + const warn = (what: string, why: string): void => { + changes.push(` ! ${pad(what, 44)}${why}`); + }; + // A subdomain is added to a zone that already routes; when the apex does not, the apply + // step makes no call (see `applyEmailSetup`) and the dashboard step is the row instead. + const subdomainNeedsApex = pre.mode === 'subdomain' && pre.routing.enabled !== true; + if (pre.routing.state !== 'ready' && subdomainNeedsApex) { + warn( + `enable Email Routing on ${pre.domain}`, + `Email Routing is not enabled on ${apex}; Void never enables it on the apex from the subdomain path — ${subdomainDashboardFallback(apex, pre.domain)}`, + ); + } else if (pre.routing.state !== 'ready') { + change( + `enable Email Routing on ${pre.domain}`, + `Cloudflare writes and locks 3 MX + 1 SPF record ${pre.mode === 'subdomain' ? 'there' : 'on the apex'}`, + ); + } + if (pre.subaddressing.state !== 'on') { + change(`turn on subaddressing for ${apex}`, 'support+anything@ reaches support@'); + } + if (pre.sending.state !== 'ready' && !pre.blockers.some((b) => b.scope === 'sending')) { + change( + `onboard ${pre.domain} for Email Sending`, + `MX/SPF/DKIM on cf-bounce.${pre.domain}, _dmarc.${pre.domain} (p=reject)`, + ); + } + // The config write, listed by the members that actually change (a config-only row — + // `addresses` or the sender var — is rewritten without a prompt on the deploy path). + const parts: Array = []; + if (pre.binding.state !== 'present') { + parts.push(`send_email: [{ name: "${SEND_EMAIL_BINDING}" }]`); + } + if (pre.addressesStale) { + parts.push( + pre.addresses.length > 0 + ? `addresses: ${JSON.stringify(pre.addresses)}` + : 'addresses: (removed)', + ); + } + if (pre.sender.state !== 'ready') { + parts.push(`vars.${EMAIL_FROM_VAR}: ${JSON.stringify(pre.from)}`); + } + if (parts.length > 0) { + change('wrangler.jsonc', parts.join(', ')); + } + for (const address of pre.rules.absent) { + change('routing rule (created on deploy)', `${address} → ${pre.workerName}`); + } + for (const foreign of pre.rules.foreign) { + warn(foreign.address, `already routed to ${foreign.owner}; left alone, not in addresses`); + } + for (const refused of pre.derived.refused) { + warn(refused.handler, refused.reason); + } + for (const blocker of pre.blockers) { + changes.push(` ✘ ${blocker.message}`); + } + if (changes.length === 0) { + lines.push(' Nothing to change — email is set up.'); + } else { + lines.push( + ` This changes YOUR Cloudflare account.${pre.mode === 'subdomain' && !subdomainNeedsApex ? ` ${apex} itself is not touched.` : ''}`, + ...changes, + ); + } + return lines.join('\n'); +} + +// ── Bearer bridge ────────────────────────────────────────────────────────────── + +export interface SessionBearerResponse { + ok: boolean; + status: number; + result: unknown; + errors: Array<{ code: number; message: string }>; +} + +/** + * The ONLY function that receives the session bearer. It puts the token in the + * `Authorization` header and nowhere else: not in the URL, not in a log line, not in + * the returned object, not in an error. A transport failure is returned as + * `{ ok: false, status: 0 }` with the underlying message (which names the URL at + * most) — never thrown, so a caller can never leak the token through a stack trace. + */ +export async function fetchWithSessionBearer( + fetchImpl: EmailFetch, + bearer: string, + method: 'GET' | 'POST' | 'PATCH', + path: string, + body?: unknown, + apiBaseUrl: string = CLOUDFLARE_API_BASE_URL, +): Promise { + const headers: Record = { Authorization: `Bearer ${bearer}` }; + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + // Nothing this function returns may carry the token: a transport error message or an + // API error message that echoed the header is scrubbed before it leaves this closure. + const scrub = (text: string): string => text.split(bearer).join('[redacted]'); + let response: Response; + try { + response = await fetchImpl(`${apiBaseUrl}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (err) { + return { + ok: false, + status: 0, + result: null, + errors: [{ code: 0, message: scrub(err instanceof Error ? err.message : String(err)) }], + }; + } + let payload: { + success?: boolean; + result?: unknown; + errors?: Array<{ code?: number; message?: string }>; + } = {}; + try { + payload = (await response.json()) as typeof payload; + } catch { + payload = {}; + } + const errors = (payload.errors ?? []).map((e) => ({ + code: typeof e?.code === 'number' ? e.code : 0, + message: typeof e?.message === 'string' ? scrub(e.message) : '', + })); + return { + ok: response.ok && payload.success !== false, + status: response.status, + result: payload.result ?? null, + errors, + }; +} + +// ── Apply ────────────────────────────────────────────────────────────────────── + +/** + * The root-config patch Mode B writes. Shape-compatible with the `sendEmail` / + * `addresses` / `vars` members of `ProvisionedWranglerPatch` (`cli/wrangler.ts`): + * `addresses: null` deletes the key (never `[]`), `undefined` leaves it untouched. + */ +export interface EmailWranglerPatch { + sendEmail?: { name: string }; + addresses?: Array | null; + vars?: Record; +} + +export interface EmailApplyResult { + patch: EmailWranglerPatch; + /** Human lines, in order — ✔ rows, hint lines, the dashboard fallback. */ + lines: Array; + routingReady: boolean; + subaddressingReady: boolean; + sendingReady: boolean; + /** True when `addresses` was NOT written this run and the key is deleted instead. */ + addressesWithheld: boolean; + /** + * The inbound rows, together: routing ready AND subaddressing on AND `addresses` + * written. Routing alone is not "set up" — a refused subaddressing PATCH withholds + * `addresses` (no bearer call may fail, decision 11), so the next deploy creates no + * rule at all, and until the flag is on `support+T-42@` matches no rule for `support@`. + * `void email setup` exits 0 on this; `--require-email` gates on it plus the binding — + * the sending row needs no term there, because the binding this same run writes is what + * the next preflight reads as the memory of a refused onboarding (see `preflightEmail`). + * ONE field, computed here, so the two callers cannot drift. + */ + inboundReady: boolean; +} + +/** + * Run ONLY the rows preflight found not ready, in 0078 order: + * + * routing apex → `wrangler email routing enable ` + * subdomain → bearer POST /zones/{z}/email/routing/dns { name } [UNVERIFIED] + * subaddr bearer PATCH /zones/{z}/email/routing { support_subaddress: true } [UNVERIFIED] + * (both paths — wrangler has no command for it) + * sending `wrangler email sending enable `; `[code: 2040]` = already + * onboarded → ok; any other failure → hint line, continue without sending + * + * Any bearer failure (no bearer, or a non-2xx) prints the dashboard fallback line and + * WITHHOLDS `addresses` this run — never a deploy failure (decision 11). A preflight + * with an `all` blocker applies nothing and returns an empty patch. + */ +export async function applyEmailSetup( + pre: EmailPreflight, + inputs: EmailCloudflareInputs, +): Promise { + const lines: Array = []; + const result: EmailApplyResult = { + patch: {}, + lines, + routingReady: pre.routing.state === 'ready', + subaddressingReady: pre.subaddressing.state === 'on', + sendingReady: pre.sending.state === 'ready', + addressesWithheld: false, + inboundReady: false, + }; + const blocked = pre.blockers.filter((b) => b.scope === 'all'); + if (blocked.length > 0 || !pre.apex || !pre.mode) { + for (const b of blocked) { + lines.push(` ✘ ${b.message}`); + } + if (blocked.length === 0) { + lines.push( + ' ✘ email: the zone could not be resolved — unknown — run `void email setup --platform cloudflare`', + ); + } + return result; + } + const apex = pre.apex; + const apiBaseUrl = inputs.apiBaseUrl ?? CLOUDFLARE_API_BASE_URL; + const extra = inputs.wranglerArgs ?? []; + const run = async (args: Array): Promise => + inputs.runWrangler([...args, ...extra], { capture: true }); + + // The bearer is borrowed lazily, once, and dropped at the end of this function. + let bearer: string | null | undefined; + let bearerFailed = false; + let bearerObtained = false; + const borrow = async (): Promise => { + if (bearer === undefined) { + bearer = await inputs.getSessionBearer().catch(() => null); + if (bearer) { + bearerObtained = true; + } else { + bearerFailed = true; + } + } + return bearer; + }; + const withBearer = async ( + method: 'POST' | 'PATCH', + path: string, + body: unknown, + ): Promise => { + const token = await borrow(); + if (!token) { + return null; + } + const response = await fetchWithSessionBearer( + inputs.fetchImpl, + token, + method, + path, + body, + apiBaseUrl, + ); + if (!response.ok) { + bearerFailed = true; + } + return response; + }; + const describeFailure = (response: SessionBearerResponse | null): string => { + if (!response) { + // `auth token --json` failed, so no bearer could be borrowed. + return 'could not borrow the Cloudflare session (session-bearer step failed)'; + } + const first = response.errors[0]; + const code = first?.code ? ` [code: ${first.code}]` : ''; + const known = first?.code ? CF_EMAIL_ERROR_CODES[first.code] : undefined; + return `Cloudflare answered ${response.status}${code}${known ? ` — ${known}` : first?.message ? ` — ${first.message}` : ''}`; + }; + + // 1. Routing. + const apexRoutingOff = pre.mode === 'subdomain' && pre.routing.enabled !== true; + if (!result.routingReady) { + if (pre.mode === 'apex') { + const enable = await run(['email', 'routing', 'enable', apex]); + if (!isHardFailure(enable) && enable.status === 0) { + result.routingReady = true; + lines.push(` ✔ routing ${apex} enabled`); + } else { + const code = wranglerApiErrorCode(enable.stderr); + lines.push( + ` ✘ routing could not enable Email Routing on ${apex}${code ? ` [code: ${code}]${CF_EMAIL_ERROR_CODES[code] ? ` — ${CF_EMAIL_ERROR_CODES[code]}` : ''}` : ''}`, + ); + } + } else if (apexRoutingOff) { + // A subdomain is added to a zone that ALREADY routes (Cloudflare's Subdomains form + // lives under an enabled apex). What the `name`-bearing POST does against a zone + // whose routing is off is unmeasured, and the one outcome that would matter — the + // zone itself being enabled, locking MX over the apex's live mail — is exactly what + // the apex refusal exists to prevent. So no call is made; `addresses` is withheld. + lines.push( + ` ✘ routing ${pre.domain}: Email Routing is not enabled on ${apex} — a subdomain can only be added to a zone that already routes; Void never enables routing on the apex from the subdomain path`, + ); + lines.push(` ${subdomainDashboardFallback(apex, pre.domain)}`); + } else { + // [UNVERIFIED] POST …/email/routing/dns { name } enables routing on a subdomain + // (OpenAPI body field `name`; wrangler's own `routing enable` calls the deprecated + // POST …/routing/enable which is apex-only). See 0078 "Still unverified" 1. + const response = await withBearer('POST', `/zones/${pre.zone.zoneId}/email/routing/dns`, { + name: pre.domain, + }); + if (response?.ok) { + result.routingReady = true; + lines.push(` ✔ routing ${pre.domain} enabled`); + } else { + lines.push(` ✘ routing ${pre.domain}: ${describeFailure(response)}`); + lines.push(` ${subdomainDashboardFallback(apex, pre.domain)}`); + } + } + } + + // 2. Subaddressing — per-zone, off by default, no wrangler command (0078 measured). + // [UNVERIFIED] PATCH with the OAuth session; see 0078 "Still unverified" 2. + if (!result.subaddressingReady && !bearerFailed && !apexRoutingOff) { + const response = await withBearer('PATCH', `/zones/${pre.zone.zoneId}/email/routing`, { + support_subaddress: true, + }); + if (response?.ok) { + result.subaddressingReady = true; + lines.push(` ✔ routing subaddressing on for ${apex}`); + } else { + lines.push(` ✘ routing subaddressing on ${apex}: ${describeFailure(response)}`); + lines.push( + ` turn it on in the Cloudflare dashboard → ${apex} → Email → Settings → Subaddressing`, + ); + } + } else if (!result.subaddressingReady) { + // Skipped because an earlier bearer step failed: name the actual reason. + lines.push( + ` ✘ routing subaddressing on ${apex}: skipped (${ + apexRoutingOff + ? `Email Routing is not enabled on ${apex}` + : bearerObtained + ? 'the subdomain routing call failed' + : 'session bearer unavailable' + })`, + ); + } + bearer = null; + + // 3. Sending. 2040 = already onboarded. Anything else: hint, continue without it. + const sendingBlocked = pre.blockers.filter((b) => b.scope === 'sending'); + if (!result.sendingReady && sendingBlocked.length > 0) { + for (const b of sendingBlocked) { + lines.push(` ! sending ${b.message}`); + } + } else if (!result.sendingReady) { + const enable = await run(['email', 'sending', 'enable', pre.domain]); + const code = isHardFailure(enable) ? null : wranglerApiErrorCode(enable.stderr); + if (!isHardFailure(enable) && (enable.status === 0 || code === 2040)) { + result.sendingReady = true; + lines.push(` ✔ sending ${pre.domain} onboarded`); + } else { + const known = code ? CF_EMAIL_ERROR_CODES[code] : undefined; + lines.push( + ` ! sending ${pre.domain} not onboarded${code ? ` [code: ${code}]` : ''}${known ? ` — ${known}` : ''}`, + ); + lines.push(` ${SENDING_FREE_PLAN_HINT}`); + } + } + + // 4. The patch. The binding is written only when routing is ready or sending is + // onboarded (a bare account never carries it). `addresses` is written only when + // routing is ready AND no bearer call failed this run; otherwise the key is deleted. + if (result.routingReady || result.sendingReady) { + result.patch.sendEmail = { name: SEND_EMAIL_BINDING }; + result.patch.vars = { [EMAIL_FROM_VAR]: pre.from }; + } + result.addressesWithheld = bearerFailed || !result.routingReady; + if (!result.addressesWithheld && pre.addresses.length > 0) { + result.patch.addresses = pre.addresses; + } else { + result.patch.addresses = null; + if (result.addressesWithheld && pre.addresses.length > 0) { + lines.push( + ` ! config addresses withheld this run (${bearerFailed ? 'session-bearer step failed' : 'routing not ready'}); no routing rule is created for ${pre.addresses.join(', ')}`, + ); + } + } + if (result.patch.sendEmail || result.patch.addresses !== undefined) { + lines.push(' ✔ config wrangler.jsonc updated'); + } + result.inboundReady = + result.routingReady && result.subaddressingReady && !result.addressesWithheld; + return result; +} + +// ── Address map ──────────────────────────────────────────────────────────────── + +/** + * The post-deploy block (0078 transcript): + * + * inbound support@mail.acme.com → email/support+[ticket].ts + * outbound sendEmail() from support@mail.acme.com + */ +export function renderAddressMap(map: { + from: string; + routes: Array<{ address: string; handler: string }>; + /** Addresses actually written to `addresses` — routes outside it are marked pending. */ + addresses: Array; + sendingReady: boolean; + /** The pending marker; `void email status` passes one that does not name a deploy. */ + pendingLabel?: string; + /** Written addresses whose rule does not exist yet — `wrangler deploy` creates it. */ + creating?: Array; +}): string { + const lines: Array = []; + const width = Math.max(0, ...map.routes.map((r) => r.address.length)); + const pending = map.pendingLabel ?? '(no rule this deploy)'; + for (const route of map.routes) { + const live = map.addresses.includes(route.address); + const marker = !live + ? ` ${pending}` + : map.creating?.includes(route.address) + ? ' (rule created by this deploy)' + : ''; + lines.push(` inbound ${pad(route.address, width + 2)}→ ${route.handler}${marker}`); + } + lines.push( + ` outbound sendEmail() from ${map.from}${map.sendingReady ? '' : ' (verified destinations only)'}`, + ); + return lines.join('\n'); +} + +// ── Node DNS adapter ─────────────────────────────────────────────────────────── + +/** The production `EmailDns`: `node:dns/promises` against the system resolver. */ +export async function createNodeEmailDns(): Promise { + const dns = await import('node:dns/promises'); + return { + resolveMx: (name) => dns.resolveMx(name), + resolveTxt: (name) => dns.resolveTxt(name), + }; +} diff --git a/packages/void/src/cli/email.ts b/packages/void/src/cli/email.ts new file mode 100644 index 000000000..7692a6577 --- /dev/null +++ b/packages/void/src/cli/email.ts @@ -0,0 +1,451 @@ +import { confirm, isCancel, log, note } from '@clack/prompts'; +import { parseRootWranglerConfig, readWranglerTopLevelString } from '@void/deploy-cloudflare'; +import { readConfig } from '../config.ts'; +import { readProjectPaths } from '../project-paths.ts'; +import { scanEmailHandlersSync } from '../router/email.ts'; +import { getToken } from './auth.ts'; +import { PlatformClient } from './client.ts'; +import { + createDeployWranglerRunner, + deriveWorkerName, + discoverRootWranglerConfig, + isWranglerNonInteractiveOrCI, + loadProvisionModule, + resolveDeployTransport, + runWranglerAuthToken, +} from './deploy.ts'; +import type { EmailArgs } from './email-args.ts'; +import { + applyEmailSetup, + createNodeEmailDns, + findEmailConfigConflicts, + preflightEmail, + renderAddressMap, + renderEmailChecklist, + type EmailCloudflareInputs, + type EmailDns, + type EmailFetch, + type EmailPreflight, + type EmailWranglerRun, +} from './email-cloudflare.ts'; +import { pc } from './output.ts'; +import { readProjectConfig } from './project.ts'; +import { writeProvisionedWranglerConfig } from './wrangler.ts'; + +export async function runEmailCommand(root: string, args: EmailArgs): Promise { + // Mode B (design doc 0078) never touches the Void platform: no token, no project. + if (args.platform === 'cloudflare') { + return runEmailCloudflareCommand(root, args); + } + 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.', + ); + process.exit(1); + } + if (args.subcommand === 'status') { + log.error( + 'email: `void email status` is available with `--platform cloudflare` today. On the Void platform use `void email usage` and `void email destinations`.', + ); + process.exit(1); + } + + const token = getToken(root); + if (!token) { + log.error('Not logged in. Run `void auth login` first.'); + process.exit(1); + } + + const client = new PlatformClient(token, { root }); + + // Resolve project + const requestedSlug = args.projectSlug ?? process.env.VOID_PROJECT?.trim(); + let projectId: string | undefined; + + if (requestedSlug) { + const projects = await client.listProjects(); + const project = projects.find((p) => p.slug === requestedSlug); + if (!project) { + log.error(`project: No project found with slug '${requestedSlug}'.`); + process.exit(1); + } + projectId = project.id; + } else { + const config = readProjectConfig(root); + if (!config) { + log.error('No linked project. Run `void project link` or pass --project .'); + process.exit(1); + } + projectId = config.projectId; + } + + switch (args.subcommand) { + case 'usage': + return emailUsage(client, projectId!); + case 'logs': + return emailLogs(client, projectId!, args.limit); + case 'destinations': + return emailDestinations(client, projectId!); + case 'allow': + return emailAllow(client, projectId!, args.address!); + case 'disallow': + return emailDisallow(client, projectId!, args.address!); + } +} + +async function emailUsage(client: PlatformClient, projectId: string): Promise { + const usage = await client.getEmailUsage(projectId); + const status = usage.suspended ? pc.red('SUSPENDED') : pc.green('active'); + const lines = [ + `Month: ${usage.month}`, + `Status: ${status}`, + `Outbound: ${usage.sent} / ${usage.limit} (${usage.remaining} remaining)`, + `Inbound: ${usage.received}`, + ]; + note(lines.join('\n'), 'Email Usage'); +} + +async function emailDestinations(client: PlatformClient, projectId: string): Promise { + const rows = await client.listEmailDestinations(projectId); + if (rows.length === 0) { + log.info( + 'No verified destinations. Run `void email allow
` to add one — Cloudflare will email the recipient with a verification link.', + ); + return; + } + const lines = rows.map((d) => { + const status = + d.status === 'verified' + ? pc.green('verified') + : d.status === 'pending' + ? pc.yellow('pending') + : pc.red('failed'); + return ` ${d.address.padEnd(40)} ${status}`; + }); + note(lines.join('\n'), 'Email Destinations'); +} + +/** + * `void email allow
` — exported for the unit test; the command + * entry (`runEmailCommand`) resolves the client and project first. + */ +export async function emailAllow( + client: Pick, + projectId: string, + address: string, +): Promise { + const dest = await client.addEmailDestination(projectId, address); + // Cloudflare mails its link on create only; the platform re-sends its own + // on every repeat add (`platformVerificationPending`). A 201 alone does not + // prove a send: the api adopts an address Cloudflare already held. + const sent = + dest.verificationSent !== false && (dest.created || dest.platformVerificationPending); + if (dest.status === 'verified') { + log.success(`${dest.address} is already verified.`); + } else if (sent) { + log.success(`Verification email sent to ${dest.address}.`); + log.info( + 'They click the link in their inbox — no Void or Cloudflare account needed. `void email destinations` to poll status.', + ); + } else if (dest.created) { + log.warn( + `${dest.address} is on this project's list now, but Cloudflare already had the address pending (added by another project, or by hand), so no new verification email was sent — the link it mailed when the address was first added is the one to click. ${freshLinkHint(dest.address)}`, + ); + } else { + log.info( + `${dest.address} is still pending — Cloudflare emailed them a verification link when the address was first added, and a repeat add sends nothing new. ${freshLinkHint(dest.address)}`, + ); + } +} + +/** + * Cloudflare has no API to resend its verification mail; the only fresh link + * is a fresh registration, and the platform deletes the Cloudflare-side + * address only once no project still lists it. + */ +function freshLinkHint(address: string): string { + return `For a fresh link, remove the address from every project that lists it (\`void email disallow ${address}\`), then \`void email allow ${address}\` — Cloudflare mails a new link only when it registers the address anew.`; +} + +/** + * `void email disallow
` — exported for the unit test; the command + * entry (`runEmailCommand`) resolves the client and project first. + * + * No deploy is involved: the api tightens the proxy's `email-allowlist` + * KV mirror inside the DELETE (and answers 503 if that write fails), and + * the proxy re-reads the mirror with a 60s `cacheTtl` on both the send + * path and inbound forward/reply — so the address is refused once that + * per-colo cache turns over. + */ +export async function emailDisallow( + client: Pick, + projectId: string, + address: string, +): Promise { + const rows = await client.listEmailDestinations(projectId); + const dest = rows.find((d) => d.address.toLowerCase() === address.toLowerCase()); + if (!dest) { + log.error(`destination: '${address}' is not in the project's allowlist.`); + process.exit(1); + } + await client.removeEmailDestination(projectId, dest.id); + log.success(`Removed ${dest.address}. Sends to it are refused within about a minute.`); +} + +/** + * `void email logs` — exported for the unit test; the command entry + * (`runEmailCommand`) resolves the client and project first. + */ +export async function emailLogs( + client: Pick, + projectId: string, + limit?: number, +): Promise { + // The API currently ignores `limit`; server-side wiring is pending the + // Analytics Engine integration. Forwarding it now keeps the CLI + // forward-compatible — once persistent storage lands, no client change is + // needed. + const result = await client.getEmailLogs(projectId, limit); + if (result.pending) { + log.info(result.note ?? 'Persistent log storage is not yet wired up.'); + // The proxy's own send/receive records are the platform worker's console + // output, which a project's credentials cannot read — so nothing points + // at them. The one thing a user CAN read today is their own worker's + // console output: the email handler is invoked over RPC on the same + // tailed script as every HTTP request, so it lands in `void project logs`. + log.info( + 'Email activity logs are not available yet. Console output from your email handler appears in `void project logs`.', + ); + return; + } + if (result.logs.length === 0) { + log.info('No email activity recorded.'); + return; + } + const lines = result.logs.map((entry) => { + const ts = new Date(entry.timestamp).toISOString(); + const dir = entry.direction === 'sent' ? '→' : '←'; + const subject = entry.subject ? ` "${entry.subject}"` : ''; + return `${ts} ${dir} ${entry.from} → ${entry.to} ${entry.status}${subject}`; + }); + note(lines.join('\n'), 'Email Logs'); +} + +// ── Mode B: the developer's own Cloudflare account ───────────────────────────── + +/** + * Seams for {@link runEmailCloudflareCommand}, so the command is unit-testable with no + * wrangler, network, DNS, terminal, or Cloudflare account — the same pattern the deploy + * orchestrator's `CloudflareDeployDeps` uses. + */ +export interface EmailCloudflareCommandDeps { + /** wrangler runner for the read-only probes and the `email … enable` writes. */ + runWrangler?: EmailWranglerRun; + /** The session-bearer bridge (default: `wrangler auth token --json`, logs off). */ + getSessionBearer?: () => Promise; + fetchImpl?: EmailFetch; + dns?: EmailDns; + /** Override the interactivity gate (default: wrangler's own, `!isWranglerNonInteractiveOrCI()`). */ + isInteractive?: boolean; + /** The one Enter (default: clack `confirm`, default Yes). */ + confirm?: (message: string) => Promise; + /** Block printer for the checklist / address map (default: clack `log.message`). */ + print?: (block: string) => void; + exit?: (code: number) => never; +} + +/** + * `void email setup --platform cloudflare` and `void email status --platform cloudflare`. + * + * `setup` is the deploy's step 3b-ii on its own: preflight → checklist → the one Enter → + * apply → write the root wrangler.jsonc. It exists for CI: a non-interactive deploy that + * finds email not ready prints the checklist and says to run this once, commit + * wrangler.jsonc, and redeploy. Rules and the catch-all stay wrangler's job — the next + * `void deploy --platform cloudflare` applies the plan from the written `addresses`. It + * is also the ONLY place a sending onboarding the committed binding remembers as refused + * (Workers Free) is retried — on its own prompt, after a Workers Paid upgrade; the deploy + * reads that row as settled and never retries it. + * + * `status` is the preflight alone, printed, exit 1 when not ready. + */ +export async function runEmailCloudflareCommand( + root: string, + args: EmailArgs, + deps: EmailCloudflareCommandDeps = {}, +): Promise { + const exit = deps.exit ?? ((code: number): never => process.exit(code)); + const print = deps.print ?? ((block: string): void => log.message(block)); + const fail = (message: string): never => { + log.error(message); + return exit(1); + }; + + // 1. Inputs from the local project: `email.from` is the ONLY domain input (decision 9). + const voidConfig = readConfig(root); + const from = voidConfig.email?.from; + if (!from) { + return fail( + 'email: no sender configured. Add "email": { "from": "you@mail.acme.com" } to void.json — its host is the domain email is set up on.', + ); + } + const paths = readProjectPaths(root); + const handlers = scanEmailHandlersSync(paths.sourceRoot); + + // 2. The same pinned transport + account + worker name as `void deploy --platform + // cloudflare`, so every wrangler child here consults the account the deploy will. + const configPath = discoverRootWranglerConfig(root); + const rootConfig = parseRootWranglerConfig(configPath); + await loadProvisionModule(); + let apiBaseUrl: string; + try { + apiBaseUrl = resolveDeployTransport(root, rootConfig); + } catch (err) { + return fail(`email: ${err instanceof Error ? err.message : String(err)}`); + } + const accountId = + readWranglerTopLevelString(rootConfig, 'account_id') ?? + (process.env.CLOUDFLARE_ACCOUNT_ID?.trim() || undefined); + if (!accountId) { + return fail( + [ + 'email: no Cloudflare account is pinned.', + 'Set `account_id` in your root wrangler.jsonc, or export CLOUDFLARE_ACCOUNT_ID —', + 'the same pin `void deploy --platform cloudflare` needs.', + ].join('\n'), + ); + } + const workerName = readWranglerTopLevelString(rootConfig, 'name') ?? deriveWorkerName(root); + const runWrangler = deps.runWrangler ?? createDeployWranglerRunner(root, accountId, apiBaseUrl); + // The same bearer bridge the deploy's step 3b-ii uses: `wrangler auth token --json` on the + // pinned runner, WRANGLER_WRITE_LOGS=false so the token never reaches wrangler's on-disk log. + const getSessionBearer = + deps.getSessionBearer ?? (() => runWranglerAuthToken(runWrangler, configPath)); + const sendEmail = rootConfig.send_email; + const inputs: EmailCloudflareInputs = { + handlers, + from, + workerName, + accountId, + existingSendEmail: Array.isArray(sendEmail) ? (sendEmail as Array<{ name?: unknown }>) : null, + existingAddresses: rootConfig.addresses, + existingVars: + rootConfig.vars && typeof rootConfig.vars === 'object' && !Array.isArray(rootConfig.vars) + ? (rootConfig.vars as Record) + : null, + configConflicts: findEmailConfigConflicts(rootConfig), + wranglerArgs: ['-c', configPath, '--env='], + apiBaseUrl, + runWrangler, + getSessionBearer, + fetchImpl: deps.fetchImpl ?? fetch, + dns: deps.dns ?? (await createNodeEmailDns()), + }; + + // 3. Preflight — read-only. + let pre: EmailPreflight; + try { + pre = await preflightEmail(inputs); + } catch (err) { + return fail(`email: ${err instanceof Error ? err.message : String(err)}`); + } + print(renderEmailChecklist(pre)); + + if (args.subcommand === 'status') { + print( + renderAddressMap({ + from, + routes: pre.derived.routes, + addresses: pre.rules.mine, + sendingReady: pre.sending.state === 'ready', + pendingLabel: '(no rule yet)', + }), + ); + if (pre.ready) { + log.success(`Email is set up on ${pre.domain}.`); + return; + } + log.warn( + `Email is not set up on ${pre.domain} yet — run \`void email setup --platform cloudflare\`.`, + ); + return exit(1); + } + + // 4. The one Enter. An `all` blocker means nothing can be applied; a ready preflight + // needs no prompt (apply makes zero calls and the config write is idempotent) — with + // one exception: a sending row the committed binding remembers as refused (Workers + // Free; `sendingRetryable`, see `preflightEmail`). Apply WOULD make that one `sending + // enable` call again, so it is offered as its own prompt — the retry after a Workers + // Paid upgrade, which the deploy never attempts — and is never run unasked. + if (pre.blockers.some((b) => b.scope === 'all')) { + return fail('email: nothing was changed — fix the ✘ rows above and rerun.'); + } + if (!pre.ready || pre.sendingRetryable) { + const interactive = deps.isInteractive ?? !isWranglerNonInteractiveOrCI(); + if (!interactive) { + return fail( + 'email: `void email setup` changes your Cloudflare account, so it needs an interactive terminal. Run it locally once, commit wrangler.jsonc, then redeploy.', + ); + } + const ask = + deps.confirm ?? + (async (message: string): Promise => { + const answer = await confirm({ message, initialValue: true }); + return !isCancel(answer) && answer === true; + }); + const question = pre.ready + ? `Onboard ${pre.domain} for Email Sending? Inbound already works; onboarding needs Workers Paid.` + : `Set up email on ${pre.domain}?`; + if (!(await ask(question))) { + log.info('Nothing changed.'); + return; + } + } + + // 5. Apply, then write the root config through the one comment-preserving writer. + const out = await applyEmailSetup(pre, inputs); + if (out.lines.length > 0) { + print(out.lines.join('\n')); + } + try { + writeProvisionedWranglerConfig(root, out.patch, { configPath }); + } catch (err) { + return fail( + `email: could not update ${configPath} — ${err instanceof Error ? err.message : String(err)}`, + ); + } + + print( + renderAddressMap({ + from, + routes: pre.derived.routes, + addresses: out.patch.addresses ?? [], + sendingReady: out.sendingReady, + pendingLabel: '(no rule yet)', + }), + ); + // The inbound rows, not routing alone: a refused subaddressing PATCH after a successful + // enable withholds `addresses`, so the next deploy creates no rule and `+tag` mail + // matches nothing — "set up" would be false, and CI's `--require-email` (which gates on + // the same `inboundReady`) would refuse the deploy this command exists to prepare. A + // sending row that stays pending (Workers Free) is NOT a failure here: inbound is + // complete, `addresses` is committed, the plan hint above is the warning, and the + // binding written alongside is what the next preflight reads as the memory of that + // refusal — so `void email status` and `--require-email` read this state as set up too. + if (!out.inboundReady) { + const missing = !out.routingReady + ? `Email Routing on ${pre.domain} is not ready` + : !out.subaddressingReady + ? `subaddressing on ${pre.apex} is still off (support+anything@ would not reach support@)` + : '`addresses` was withheld'; + log.warn( + `Email is not set up on ${pre.domain} yet — ${missing}; no routing rule is created until it is. Finish the step above, then rerun \`void email setup --platform cloudflare\`.`, + ); + return exit(1); + } + if ((out.patch.addresses ?? []).length > 0) { + log.info( + 'Routing rules are created by the next `void deploy --platform cloudflare`, which applies the Email Routing plan from `addresses`.', + ); + } + log.success(`Email set up on ${pre.domain}.`); +} diff --git a/packages/void/src/cli/env-validation.ts b/packages/void/src/cli/env-validation.ts index a31f2212e..d9342dae2 100644 --- a/packages/void/src/cli/env-validation.ts +++ b/packages/void/src/cli/env-validation.ts @@ -365,7 +365,7 @@ export class SelfHostSecretListError extends Error { /** * FIRST-DEPLOY specialization of {@link SelfHostSecretListError}: the remote * secret list could not be fetched because the worker has NOT been deployed - * yet. wrangler 4.107's `secret list` catches the API worker-not-found error + * yet. wrangler 4.131's `secret list` catches the API worker-not-found error * (codes 10007 / 10090 — `WORKER_NOT_FOUND` / `WORKER_LEGACY_ENVIRONMENT_NOT_FOUND`, * per `isWorkerNotFoundError` in wrangler-dist/cli.js) and rethrows a `UserError` * ("… not found. If this is a new Worker, run `wrangler deploy` first to create @@ -869,7 +869,7 @@ export async function dischargeSelfHostEnvContext( /** * Read the pinned config's top-level `account_id` and reconcile it with * the caller's requested account, mirroring the provisioner's account gate - * (provision.ts `provisionCloudflareResourcesLocked`). wrangler 4.107 gives the + * (provision.ts `provisionCloudflareResourcesLocked`). wrangler 4.131 gives the * config's `account_id` PRECEDENCE over `CLOUDFLARE_ACCOUNT_ID`, so a real * config-vs-requested mismatch is a fail-closed error (thrown BEFORE any spawn). * Returns the ONE effective account to pin (config wins, else requested), or @@ -1075,7 +1075,7 @@ function describeVarSource(source: VarSource | undefined): string { * The hard error for a REQUIRED key set to an EMPTY value (`FOO=""`) in * the effective build map. Values-free (key names + source KIND/filename only), * thrown ALWAYS — independent of whether the key exists as a remote secret. The - * build bakes the empty string into plaintext `vars`, and wrangler 4.107 gives a + * build bakes the empty string into plaintext `vars`, and wrangler 4.131 gives a * config var precedence over a same-named remote secret (`checkRemoteSecretsOverride` * in wrangler-dist/cli.js warns "… will replace these remote secrets with the * configuration values"), so shipping this would ship an empty value for a @@ -1137,7 +1137,7 @@ function firstDeployMessage(missingKeys: Array): string { } /** - * The exact `wrangler secret list` argv. Verified against the wrangler 4.107.0 + * The exact `wrangler secret list` argv. Verified against the wrangler 4.131.0 * bundle (`wrangler-dist/cli.js`, `secretListCommand`): * - `secret list` accepts `--format` with choices `json`/`pretty`; `json` is * the default AND suppresses the banner (`printBanner` is format==='pretty'), @@ -1158,7 +1158,7 @@ function secretListArgs(configPath: string): Array { } /** - * wrangler 4.107's `secret list` worker-not-found signal. When the worker has + * wrangler 4.131's `secret list` worker-not-found signal. When the worker has * never been deployed, `fetchSecrets` throws the API worker-not-found error and * `secretListCommand.handler` — ONLY when `isWorkerNotFoundError(e)` is true * (`e.code === 10007` WORKER_NOT_FOUND or `10090` diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index dea979a66..5a99d4592 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -157,6 +157,11 @@ const HELP_TREE: HelpTree = { command: '--platform ', description: 'Override the platform saved by void init', }, + { + command: '--require-email', + description: + 'Fail instead of deploying without email when the mail domain is not set up (with --platform cloudflare)', + }, { command: '--debug', description: 'Mirror the deploy log to stderr (always written to ~/.void/logs)', @@ -428,6 +433,80 @@ const HELP_TREE: HelpTree = { }, }, }, + email: { + summary: 'Inspect email activity and manage verified recipients.', + usage: ['void email '], + commands: [ + { command: 'usage', description: 'Show monthly outbound/inbound counts and quota' }, + { command: 'logs', description: 'Show recent email delivery activity' }, + { command: 'destinations', description: 'List verified recipient addresses' }, + { command: 'allow
', description: 'Add a recipient and trigger CF verification' }, + { command: 'disallow
', description: 'Remove a recipient from the allowlist' }, + { + command: 'status --platform cloudflare', + description: 'Show the email setup state and address map on your own account', + }, + { + command: 'setup --platform cloudflare', + description: 'Set email up on your own Cloudflare account (one confirmation)', + }, + ], + children: { + usage: withProjectOption( + 'Show monthly outbound/inbound counts and quota.', + 'void email usage [options]', + ), + // Not `withProjectOption`: `logs` is the one email subcommand with a + // flag of its own that actually does something, so the shared helper + // would hide it. Same shape as `domain status` above. + logs: { + summary: 'Show recent email delivery activity.', + usage: ['void email logs [options]'], + options: [ + { command: '--project ', description: 'Project name override' }, + { + command: '--limit ', + description: 'How many entries to show (positive integer)', + }, + ], + }, + destinations: withProjectOption( + 'List verified recipient addresses.', + 'void email destinations [options]', + ), + allow: withProjectOption( + 'Add a recipient and trigger a Cloudflare verification email.', + 'void email allow
[options]', + ), + disallow: withProjectOption( + 'Remove a recipient from the verified allowlist.', + 'void email disallow
[options]', + ), + // Mode B (design doc 0078): both read the local project + your wrangler session, + // never a Void project, so neither takes `--project`. + status: { + summary: 'Show the email setup state and address map. Exit 1 when not ready.', + usage: ['void email status --platform cloudflare'], + options: [ + { + command: '--platform cloudflare', + description: 'Your own Cloudflare account (the only platform with a status today)', + }, + ], + }, + setup: { + summary: + 'Set email up on your own Cloudflare account: enable Email Routing, onboard Email Sending, write send_email + addresses into wrangler.jsonc. Asks once before changing the account.', + usage: ['void email setup --platform cloudflare'], + options: [ + { + command: '--platform cloudflare', + description: 'Your own Cloudflare account (required; the Void platform needs no setup)', + }, + ], + }, + }, + }, github: { summary: 'Connect a GitHub repository for automatic deploys.', usage: ['void github '], @@ -874,6 +953,8 @@ export function printGlobalHelp(): void { '', ...globalCommandGroup('domain'), '', + ...globalCommandGroup('email'), + '', ...globalCommandGroup('github'), '', ...globalCommandGroup('build'), diff --git a/packages/void/src/cli/preset.ts b/packages/void/src/cli/preset.ts index a2051b464..fe5368941 100644 --- a/packages/void/src/cli/preset.ts +++ b/packages/void/src/cli/preset.ts @@ -223,7 +223,7 @@ export function resolveWranglerCommand(root: string, subcommand: string): string * cross-platform (it sidesteps the Windows `.cmd`/`.ps1` shim that Node cannot * spawn with `shell: false`): * 1. MODULE RESOLUTION of `wrangler/package.json` relative to THIS module (the - * `void` package). The provisioner targets wrangler 4.107's EXACT flags, + * `void` package). The provisioner targets wrangler 4.131's EXACT flags, * `--json` shapes, and error strings, so void's own pinned wrangler must be * PREFERRED — an app that hoists an older, incompatible wrangler at its root * must not shadow it. Under strict pnpm (void + consumers use pnpm) void's @@ -300,7 +300,7 @@ function resolveLocalWranglerJsBin( resolveWranglerPackageJson: () => string | undefined, ): string | undefined { // Prefer void's own wrangler (module resolution relative to preset.ts): the - // provisioner targets its exact 4.107 flags/JSON/errors, so an older app-root + // provisioner targets its exact 4.131 flags/JSON/errors, so an older app-root // wrangler must not win over it. const fromVoid = wranglerCliEntryFromPackageJson(resolveWranglerPackageJson()); if (fromVoid) { @@ -341,7 +341,7 @@ function wranglerCliEntryFromPackageJson(pkgPath: string | undefined): string | } /** - * The relative CLI entry from a wrangler manifest: `main` first (wrangler 4.107 sets it + * The relative CLI entry from a wrangler manifest: `main` first (wrangler 4.131 sets it * to `wrangler-dist/cli.js`, which equals `exports["."].default`), then the `.` export * (`{ default: string }` or a bare string). Returns `undefined` when neither is a string. */ @@ -559,6 +559,7 @@ export function findBackendSources(root: string): Array { paths.middlewareDir, paths.cronsDir, paths.queuesDir, + paths.emailDir, ]) { if (existsSync(dir)) { found.push(`${relative(root, dir)}/`); @@ -591,6 +592,9 @@ export function hasBackendFiles(root: string): boolean { if (existsSync(paths.queuesDir)) { return true; } + if (existsSync(paths.emailDir)) { + return true; + } if (hasSsrEntry(root, paths)) { return true; } diff --git a/packages/void/src/cli/project-slug.ts b/packages/void/src/cli/project-slug.ts index 3c4ca04a0..865c7f76a 100644 --- a/packages/void/src/cli/project-slug.ts +++ b/packages/void/src/cli/project-slug.ts @@ -1,3 +1,10 @@ +/** + * Longest slug the platform accepts — the same figure as the API's + * `MAX_SLUG_LENGTH` and the runtime's `VALID_SLUG` (64-octet SMTP local-part + * minus `+noreply`). + */ +export const MAX_SLUG_LENGTH = 56; + export function validateProjectSlug(value: string | undefined): string | undefined { const slug = (value ?? '').trim(); if (slug.length === 0) { @@ -11,6 +18,14 @@ export function validateProjectSlug(value: string | undefined): string | undefin return 'Slug must be lowercase alphanumeric with dashes.'; } + // The slug is an email local-part prefix (`+noreply@`, `+@`) + // and RFC 5321 caps a local-part at 64 octets; the API refuses anything over + // 56 with the same rule (`MAX_SLUG_LENGTH`). Checked here too so the prompt + // answers before the round trip. + if (slug.length > MAX_SLUG_LENGTH) { + return `Slug must be at most ${MAX_SLUG_LENGTH} characters (it forms the email sender +noreply@).`; + } + // Plan-based slug-length restrictions are enforced server-side so the check // can read the caller's current plan. The retry loop in create-project.ts // re-prompts when the server rejects. diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index 0c967ac71..60f9bf64e 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -209,7 +209,7 @@ export interface HyperdriveConfig { } /** - * Seam over listing the account's Hyperdrive configs (wrangler 4.107 has NO + * Seam over listing the account's Hyperdrive configs (wrangler 4.131 has NO * machine-readable `hyperdrive list` and no get-by-name, so existence is checked * via the Cloudflare REST API). The default implementation * ({@link createDefaultListHyperdriveConfigs}) calls @@ -296,7 +296,7 @@ const R2_BUCKET_NAME_PLACEHOLDER = 'default'; // hard error) instead of being recorded as a real resource. const D1_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const HEX32_ID_RE = /^[0-9a-f]{32}$/i; -/** R2 bucket names ARE the identity: wrangler 4.107's `isValidR2BucketName`. */ +/** R2 bucket names ARE the identity: wrangler 4.131's `isValidR2BucketName`. */ const R2_BUCKET_NAME_RE = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/; /** @@ -340,7 +340,7 @@ const defaultLogger: ProvisionLogger = { }; /** - * The transport-redirecting env vars wrangler 4.107 honors. Each can point + * The transport-redirecting env vars wrangler 4.131 honors. Each can point * wrangler's authenticated API transport at a DIFFERENT endpoint (or select a * different env-block/account) than the ONE this provision pinned: * - `CLOUDFLARE_API_BASE_URL` / `CF_API_BASE_URL` — override the API base URL directly @@ -360,7 +360,7 @@ const TRANSPORT_REDIRECT_ENV_VARS = [ ] as const; /** - * Wrangler 4.107's diagnostic-output sinks that would PERSIST the raw provisioning + * Wrangler 4.131's diagnostic-output sinks that would PERSIST the raw provisioning * argv (including a Hyperdrive connection string, password and all) to disk: * - `WRANGLER_OUTPUT_FILE_PATH` / `WRANGLER_OUTPUT_FILE_DIRECTORY` — when either is set * (`getOutputFilePathFromEnv`/`getOutputFileDirectoryFromEnv`), wrangler's startup @@ -382,7 +382,7 @@ const OUTPUT_FILE_ENV_VARS = [ /** * Resolve the ONE Cloudflare API base URL this provision is pinned to, mirroring - * wrangler 4.107's `getCloudflareApiBaseUrl` EXACTLY (wrangler-dist/cli.js) so the + * wrangler 4.131's `getCloudflareApiBaseUrl` EXACTLY (wrangler-dist/cli.js) so the * Hyperdrive REST lister and the spawned wrangler agree on the endpoint: * * getCloudflareApiBaseUrl = (complianceConfig) => @@ -436,7 +436,7 @@ export function resolvePinnedApiBaseUrl( } /** - * Mirror wrangler 4.107's `getCloudflareApiEnvironmentFromEnv` (env factory with + * Mirror wrangler 4.131's `getCloudflareApiEnvironmentFromEnv` (env factory with * `choices: ["production", "staging"]`, default `"production"`): an ABSENT variable * defaults to production; a PRESENT value outside the choices — `"stagng"`, an empty * string — makes wrangler's `assertOneOf` throw, so we fail closed identically instead of @@ -457,7 +457,7 @@ function resolveApiEnvironment(env: NodeJS.ProcessEnv): 'production' | 'staging' return raw; } -/** The compliance-region choices wrangler 4.107 accepts (`getCloudflareComplianceRegionFromEnv`). */ +/** The compliance-region choices wrangler 4.131 accepts (`getCloudflareComplianceRegionFromEnv`). */ const COMPLIANCE_REGIONS = ['public', 'fedramp_high'] as const; type ComplianceRegion = (typeof COMPLIANCE_REGIONS)[number]; @@ -473,7 +473,7 @@ function assertComplianceChoice(value: string, source: string): ComplianceRegion } /** - * Reconcile the effective Cloudflare compliance region, mirroring wrangler 4.107's + * Reconcile the effective Cloudflare compliance region, mirroring wrangler 4.131's * `getCloudflareComplianceRegion(complianceConfig)` EXACTLY (evaluated only on the * DERIVED-base branch — with a direct base override wrangler never calls it): * - reads `CLOUDFLARE_COMPLIANCE_REGION` by PRESENCE and validates it against the @@ -514,7 +514,7 @@ function resolveComplianceRegion( export { scanWranglerDotenvKeys }; /** - * Mirror wrangler 4.107's `getDefaultEnvFiles(env)`: base `.env`/`.env.local`, then — + * Mirror wrangler 4.131's `getDefaultEnvFiles(env)`: base `.env`/`.env.local`, then — * when the environment argument is DEFINED (we always pass `--env=`, so wrangler sees * `args.env === ""`, which is `!== undefined`) — the per-env pair `.env.${env}`/`.env.${env}.local`. * With the empty environment those are the empty-env filenames `.env.` and `.env..local`. @@ -525,7 +525,7 @@ function defaultWranglerEnvFiles(env: string): Array { /** * FAIL CLOSED when the project's dotenv files declare a transport-redirecting - * variable. wrangler 4.107 reloads `.env`/`.env.local` (and the dev-vars files) from the + * variable. wrangler 4.131 reloads `.env`/`.env.local` (and the dev-vars files) from the * project root into `process.env` on EVERY command with `override: true`, so a repo `.env` * setting `CLOUDFLARE_API_BASE_URL` / `WRANGLER_API_ENVIRONMENT` / * `CLOUDFLARE_COMPLIANCE_REGION` / `CLOUDFLARE_ENV` would redirect wrangler's authenticated @@ -543,7 +543,7 @@ export function assertNoTransportRedirectInProjectDotenv( root: string, platform: NodeJS.Platform = process.platform, ): void { - // Scan the EXACT files wrangler 4.107 loads for the + // Scan the EXACT files wrangler 4.131 loads for the // provisioner's `--env=` (EMPTY environment string), with wrangler's own per-family // SELECTION and CASE semantics, using a KEY scanner that mirrors wrangler's dotenv // parser (accepts the optional `export ` prefix + quoting): @@ -1910,7 +1910,7 @@ function createDefaultListHyperdriveConfigs(apiToken?: string): ListHyperdriveCo // account at the SAME endpoint the spawned wrangler uses (never a hardcoded prod base // while wrangler was redirected to staging/FedRAMP/an arbitrary endpoint). return async ({ accountId, apiBaseUrl, signal }) => { - // Build every request URL with wrangler 4.107's EXACT concatenation + // Build every request URL with wrangler 4.131's EXACT concatenation // semantics. wrangler's `performApiFetchBase` (wrangler-dist/cli.js) fetches // `${getCloudflareApiBaseUrl(complianceConfig)}${resource}${queryString}` // where the pinned base is used VERBATIM (a direct CLOUDFLARE_API_BASE_URL override @@ -3186,7 +3186,7 @@ function isConfirmedAbsent(result: RunWranglerResult, kind: 'r2' | 'queue', name // CF API error code 10006 is the exact code wrangler treats as "bucket absent" // (its `getR2Bucket`/`isConnectedToExistingResource` branch on `err.code === 10006`). // wrangler renders an `APIError` as `${message} [code: ${err.code}]` (its `renderError` - // in wrangler 4.107), so a genuine 10006 ALWAYS surfaces as the STRUCTURED token + // in wrangler 4.131), so a genuine 10006 ALWAYS surfaces as the STRUCTURED token // `[code: 10006]` in the error output. We require that bracketed token — NOT a bare // `10006` anywhere in output: a bucket NAME that merely contains those digits (e.g. // `archive-10006-prod`) inside an unrelated auth/service/network failure would falsely @@ -3221,7 +3221,7 @@ export function isQueueConfirmedAbsent(result: RunWranglerResult, name: string): * `code === null → exit(0)`), so a KILLED probe/create can surface a clean exit 0 * with empty/partial output. A bare clean 0 is therefore NOT proof the queue exists. * We require BOTH a clean success (numeric exit 0, no signal/spawn error — the - * provenance rule) AND the output to name THIS exact queue: wrangler 4.107's + * provenance rule) AND the output to name THIS exact queue: wrangler 4.131's * `queues info` prints `Queue Name: ` for the queue it fetched by name * (account-unique), so an exact `Queue Name: ` line is the positive signal. * Used for BOTH the reuse probe and the post-create verify probe. @@ -3285,7 +3285,7 @@ interface CreatePostcondition { /** * Backstop — verify a create's POSTCONDITION, RESOURCE-SPECIFICALLY + identity - * checked. wrangler 4.107's `bin.wrangler` is a WRAPPER that spawns + * checked. wrangler 4.131's `bin.wrangler` is a WRAPPER that spawns * `wrangler-dist/cli.js` and maps a signal-killed child (`code === null`) to * `process.exit(0)`, so `spawnSync` can see a `create` report "success" though the * real CLI was killed and `--update-config` wrote NO id. After every config-writing @@ -3322,7 +3322,7 @@ function assertCreateWroteRealId( // --- Option-injection guard SEPARATE from per-resource service grammar ----- // The old single regex `^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$` was applied to EVERY -// resource, but wrangler 4.107 forwards the KV namespace positional VERBATIM as the KV +// resource, but wrangler 4.131 forwards the KV namespace positional VERBATIM as the KV // TITLE, and Cloudflare permits titles like "My Own Namespace" (spaces) — so valid KV // namespaces were REJECTED before discovery. It was also wrong for R2 (whose real // grammar is lowercase DNS-like). We therefore split the two concerns: @@ -3441,7 +3441,7 @@ export function assertValidResourceName(kind: ResourceNameKind, name: string): v * ONCE so no call site can forget them: * - `-c ` pins the ONE resolved config (no split-brain reads/writes); * - `--env=` (an explicit EMPTY environment) FORCES wrangler onto the top-level - * config environment. wrangler 4.107 selects its config env as + * config environment. wrangler 4.131 selects its config env as * `args.env ?? getCloudflareEnv()`; passing an explicit empty `args.env` * short-circuits the `??` so a project `.env`/`.env.local` that sets * `CLOUDFLARE_ENV` (reloaded into `process.env` on every wrangler command) @@ -4139,7 +4139,7 @@ async function provisionCloudflareResourcesLocked( // --- Resolve + pin the ONE effective Cloudflare account --------------- // Precedence: the resolved config's `account_id` > `opts.accountId` > the ambient - // `CLOUDFLARE_ACCOUNT_ID`. wrangler 4.107 gives the resolved config's account_id + // `CLOUDFLARE_ACCOUNT_ID`. wrangler 4.131 gives the resolved config's account_id // PRECEDENCE over the env var, so if the config pins X while `opts.accountId` // requests Y, wrangler create/list would target X while the Hyperdrive REST check // targets Y — a cross-account create/orphan. Fail closed on a real mismatch; @@ -4224,7 +4224,7 @@ async function provisionCloudflareResourcesLocked( // --- Hyperdrive blockers that are knowable BEFORE any account mutation --- // Unlike D1/KV/R2/queues — each of which has a machine-readable wrangler probe that // works under ANY wrangler credential, including a `wrangler login` OAuth session — - // Hyperdrive has none (wrangler 4.107 prints a human table and offers no get-by-name; + // Hyperdrive has none (wrangler 4.131 prints a human table and offers no get-by-name; // see the Hyperdrive block below). Its existence check is therefore a direct Cloudflare // REST call, which needs an API token of its own. Without one there is no way to tell // "config absent" from "cannot look", and `hyperdrive create` silently mints a DUPLICATE @@ -4730,7 +4730,7 @@ async function provisionCloudflareResourcesLocked( } // --- Hyperdrive ---------------------------------------------------------- - // wrangler 4.107 has NO machine-readable Hyperdrive list (`hyperdrive list` + // wrangler 4.131 has NO machine-readable Hyperdrive list (`hyperdrive list` // prints a cli-table3 box table; there is no `--json`, and `hyperdrive get` // takes an id, not a name). `hyperdrive create` on a NON-UNIQUE name silently // mints a DUPLICATE config (new UUID), so a re-run without the id in config diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 8e6b01d15..c449ac826 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -1031,6 +1031,32 @@ export type ProvisionedWranglerPatch = { /** Remove only stale entries carrying explicit Void-managed provenance. */ reconcileVoidOwned?: boolean; }; + /** + * Email (Mode B — the developer's own Cloudflare account; design doc 0078). + * + * `sendEmail` upserts `{ name }` into the top-level `send_email[]` array, keyed by + * `name` (wrangler's identity for that field — it has no `.binding`). A matched + * entry is left exactly as it is (its `destination_address` / + * `allowed_destination_addresses` are user-owned); a missing one is appended and + * the array is created when absent. Other entries are never touched. + */ + sendEmail?: { name: string }; + /** + * The top-level `addresses` string array wrangler ≥ 4.113 reconciles Email + * Routing rules from on `wrangler deploy`. Void OWNS the whole array in Mode B + * (decision 13), so this is a whole-array write, not a merge: + * - `string[]` → set the array to exactly this value (rewritten every run); + * - `null` → delete the key (routing is not ready — an EMPTY array would + * delete every rule wrangler owns, so the key must go instead); + * - `undefined`→ untouched. + */ + addresses?: Array | null; + /** + * Top-level `vars` to upsert by key (e.g. `__VOID_EMAIL_FROM`). Existing keys + * with a different value are overwritten; other keys are untouched; the `vars` + * object is created when absent. + */ + vars?: Record; }; type ManagedQueueProvenance = { @@ -1156,7 +1182,9 @@ export type WriteProvisionedWranglerResult = { /** * Fill the parts of the root wrangler config that `wrangler create * --update-config` leaves blank: top-level `account_id`, `name`, - * `compatibility_date`, and `migrations_dir` on a specific D1 entry. + * `compatibility_date`, and `migrations_dir` on a specific D1 entry — plus the + * Mode B email fields (`send_email`, `addresses`, `vars`) that have no wrangler + * command at all. * * Comment-preserving: uses `jsonc-parser` `modify()` + `applyEdits()` so * comments and unrelated content survive (unlike `syncWranglerBindings`, which @@ -1292,10 +1320,15 @@ export function writeProvisionedWranglerConfig( // var, a secret, a QUEUE PRODUCER, … — whether we are APPENDING a new entry // OR UPDATING an existing matched one → fail closed (a cross-type duplicate // binding name wrangler rejects at deploy). + // + // `identityKey` is the member property wrangler keys the field on: `binding` for + // the id-bearing resources, `name` for `send_email` (which has no `.binding` — + // see `nameKeyedBindingNames`). const upsertAdoptedBinding = ( field: string, binding: string, properties: Record, + identityKey: 'binding' | 'name' = 'binding', ): void => { // Re-parse the CURRENT text (not the initial `parsed`) so this sees any adopt // appended earlier in THIS call — a stale map would let a multi-field patch @@ -1313,15 +1346,15 @@ export function writeProvisionedWranglerConfig( const matches: Array = []; for (let i = 0; i < currentList.length; i++) { const entry = currentList[i]; - if (isRecord(entry) && entry.binding === binding) { + if (isRecord(entry) && entry[identityKey] === binding) { matches.push(i); } } if (matches.length > 1) { throw new Error( `deploy: cannot update "${configPath}" — binding ${JSON.stringify(binding)} is declared ` + - `${matches.length} times in "${field}". Adoption cannot choose which entry to write the ` + - `real id into, and wrangler rejects duplicate binding names. Remove the duplicate and retry.`, + `${matches.length} times in "${field}". Void cannot choose which entry to update, and ` + + `wrangler rejects duplicate binding names. Remove the duplicate and retry.`, ); } // Worker-wide cross-type uniqueness — checked for BOTH the matched (existing @@ -1355,7 +1388,7 @@ export function writeProvisionedWranglerConfig( text = applyMemberEdit( text, [field, currentList.length], - { binding, ...properties }, + { [identityKey]: binding, ...properties }, { formattingOptions, isArrayInsertion: true, @@ -1378,6 +1411,12 @@ export function writeProvisionedWranglerConfig( if (patch.hyperdrive) { upsertAdoptedBinding('hyperdrive', patch.hyperdrive.binding, { id: patch.hyperdrive.id }); } + // send_email — the same name-keyed upsert: `{ name }` only, so a matched entry + // keeps whatever destination restrictions the user put on it, and the worker-wide + // uniqueness guard rejects a `SEND_EMAIL` name already held by another binding. + if (patch.sendEmail) { + upsertAdoptedBinding('send_email', patch.sendEmail.name, {}, 'name'); + } // migrations_dir — upsert onto the D1 entry matched by binding. Indices are // stable because we only set a property (never insert/reorder array entries). @@ -1703,6 +1742,50 @@ export function writeProvisionedWranglerConfig( } } + // vars — per-key upsert into the top-level `vars` object (created when absent). + // Only the keys this patch names are written, and only when their value differs, + // so sibling vars and their comments stay byte-identical. A var name is a worker + // binding name too (wrangler folds `vars` into `validateBindingsHaveUniqueNames`), + // so a key already held by ANOTHER binding type fails closed like the adopt path. + if (patch.vars) { + const currentConfig = parseJsonc(text); + // Validates the outer shape: a present-but-non-object `vars` throws (fail closed) + // rather than being replaced wholesale. + bindingNamesForField('vars', currentConfig.vars); + const currentVars = isRecord(currentConfig.vars) ? currentConfig.vars : {}; + const owners = collectWorkerBindingNames(configWithoutField(currentConfig, 'vars')); + addQueueProducerOwners(owners, currentConfig); + for (const [key, value] of Object.entries(patch.vars)) { + const owner = owners.get(key); + if (owner !== undefined) { + throw new Error( + `deploy: cannot update "${configPath}" — cannot write var ${JSON.stringify(key)} because ` + + `that binding name is already used by a ${owner} binding. Wrangler requires every ` + + `binding name to be unique across the whole worker, so this would make the config ` + + `undeployable. Rename the conflicting binding and retry.`, + ); + } + if (!jsonEqual(currentVars[key], value)) { + set(['vars', key], value); + } + } + } + + // addresses — Void owns the WHOLE top-level array in Mode B, so a `string[]` + // replaces the value outright (skipped when already equal, so a re-run is + // byte-identical) and `null` removes the key. Neither goes through `set()`: + // the array is composite by design, so the final gate's "expected a string" + // diagnosis would be wrong for it — a comment nested inside a user-edited + // `addresses` still trips the generic drop check below and fails closed. + if (patch.addresses !== undefined) { + const currentAddresses = parseJsonc(text).addresses; + if (patch.addresses === null) { + text = removeMember(text, ['addresses'], { formattingOptions }); + } else if (!jsonEqual(currentAddresses, patch.addresses)) { + text = applyMemberEdit(text, ['addresses'], patch.addresses, { formattingOptions }); + } + } + // FINAL GATE — global comment-text-preservation invariant. The writer's // contract is that every comment's TEXT survives a write. Relocation only // MOVES a comment (identical text, new position), so the multiset of comment @@ -2091,6 +2174,102 @@ function applyMemberEdit( return relocated; } +/** + * Remove one object member (the property at `path`), keeping every comment. + * + * jsonc-parser's `modify(text, path, undefined)` deletes from the END of the + * previous member to the end of the removed one — so the previous sibling's + * trailing comment, a full-line comment above the member, and the member's own + * trailing comment all vanish (measured on 4.x). The final comment gate would + * then refuse the write for the common `"name": "app", // worker` shape. Here + * the property node's exact source range is spliced out instead, plus ONE + * separating comma (the following one when present, else the preceding one so + * a strict `.json` file never gains a trailing comma) and the now-blank line it + * sat on. Comments are never inside those ranges, so they all stay put; a + * comment that was attached to the removed member is left orphaned on its line. + * + * Validate-before-return backstop: the spliced text must parse and equal the + * plain `modify()` result's data; otherwise the plain edit is returned and the + * final gate decides. An absent member is a no-op. + */ +function removeMember( + text: string, + path: Array, + options: { formattingOptions: FormattingOptions }, +): string { + const root = parseTree(text, undefined, { allowTrailingComma: true }); + const valueNode = root ? findNodeAtLocation(root, path) : undefined; + const property = valueNode?.parent; + if (!root || !valueNode || !property || property.type !== 'property') { + return valueNode ? applyEdits(text, modify(text, path, undefined, options)) : text; + } + const start = property.offset; + const end = property.offset + property.length; + + // Tokenize once so comments and whitespace are skipped by KIND, never by regex. + const scanner = createScanner(text, false); + const tokens: Array<{ kind: SyntaxKind; offset: number; length: number }> = []; + for (let kind = scanner.scan(); kind !== SyntaxKind.EOF; kind = scanner.scan()) { + tokens.push({ kind, offset: scanner.getTokenOffset(), length: scanner.getTokenLength() }); + } + const isTrivia = (kind: SyntaxKind): boolean => + kind === SyntaxKind.Trivia || + kind === SyntaxKind.LineBreakTrivia || + kind === SyntaxKind.LineCommentTrivia || + kind === SyntaxKind.BlockCommentTrivia; + const following = tokens.find((token) => token.offset >= end && !isTrivia(token.kind)); + const preceding = [...tokens] + .reverse() + .find((token) => token.offset + token.length <= start && !isTrivia(token.kind)); + const comma = + following?.kind === SyntaxKind.CommaToken + ? following + : preceding?.kind === SyntaxKind.CommaToken + ? preceding + : undefined; + + // Splice the higher range first so the lower offsets stay valid. + const ranges = [{ offset: start, length: end - start }]; + if (comma) { + ranges.push({ offset: comma.offset, length: comma.length }); + } + ranges.sort((a, b) => b.offset - a.offset); + let spliced = text; + for (const range of ranges) { + spliced = spliced.slice(0, range.offset) + spliced.slice(range.offset + range.length); + } + + // Drop the line the member sat on when nothing but whitespace is left of it + // (its trailing comment, if any, keeps the line alive). Handles LF, CRLF and + // CR-only endings. `cursor` is the member's start in the spliced text — shifted + // left when the removed comma sat before it. + const cursor = comma && comma.offset < start ? start - comma.length : start; + let lineStart = cursor; + while (lineStart > 0 && spliced[lineStart - 1] !== '\n' && spliced[lineStart - 1] !== '\r') { + lineStart--; + } + let lineEnd = cursor; + while (lineEnd < spliced.length && spliced[lineEnd] !== '\n' && spliced[lineEnd] !== '\r') { + lineEnd++; + } + if (spliced.slice(lineStart, lineEnd).trim() === '') { + const terminator = spliced.startsWith('\r\n', lineEnd) ? 2 : lineEnd < spliced.length ? 1 : 0; + spliced = spliced.slice(0, lineStart) + spliced.slice(lineEnd + terminator); + } + + const plainResult = applyEdits(text, modify(text, path, undefined, options)); + let splicedParsed: Record; + try { + splicedParsed = parseJsonc(spliced); + } catch { + return plainResult; + } + if (!jsonEqual(splicedParsed, parseJsonc(plainResult))) { + return plainResult; + } + return spliced; +} + /** * If a trailing inline comment sits on the same line right after `offset`, * return its text and end offset. Only whitespace and an OPTIONAL single @@ -2397,7 +2576,7 @@ function assertValidQueueProducers(producers: unknown): Array, + field: string, + name: string, +): string | undefined { + bindingNamesForField(field, config[field]); + const owners = collectWorkerBindingNames(configWithoutField(config, field)); + addQueueProducerOwners(owners, config); + return owners.get(name); +} + /** * The STRICT canonical reader for a single provisioned binding entry in a wrangler * config field (`d1_databases` / `kv_namespaces` / `r2_buckets` / `hyperdrive`). Replaces @@ -2482,7 +2681,7 @@ export function readProvisionedBindingEntry( * binding NAMES by KNOWN structure instead of GUESSING from a value's content (which * misreads a `vars` value that happens to look like a binding list/object, and lets a * required-but-missing binding pass). Each variant + its required-ness is derived - * DIRECTLY from wrangler 4.107's config-schema.json (`RawConfig.properties`): + * DIRECTLY from wrangler 4.131's config-schema.json (`RawConfig.properties`): * * | shape | schema example | binding name is | required | * | ---------------- | -------------------------------------- | --------------- | --------------- | @@ -2491,6 +2690,15 @@ export function readProvisionedBindingEntry( * | namespace-array | kv_namespaces/d1_databases/… `[{ binding }]` | member `.binding` | `.binding` req | * | singleton | ai/browser/…/version_metadata `{ binding }` | `.binding` | req (assets: optional) | * | binding-list | durable_objects/logfwdr/unsafe `{ bindings: [{ name }] }` | member `.name` | `bindings` req (unsafe: optional) | + * | whole-object | addresses `string[]` | none | — | + * + * `whole-object` is a top-level field Void WRITES (`writeProvisionedWranglerConfig`) + * that is NOT a binding — it declares no name wrangler folds into + * `validateBindingsHaveUniqueNames`, so it is absent from `FRIENDLY_BINDING_NAMES` + * and contributes nothing here. `addresses` reconciles Email Routing rules on + * `wrangler deploy` (≥ 4.113), so the deploy verifier pins it WHOLE against the + * pre-build root (`DEPLOY_WHOLE_OBJECT_PINNED_FIELDS` in `deploy.ts`) instead of + * per entry: a build that injects one redirects the account's inbound mail. * * `namespace-array` is the DEFAULT for every `FRIENDLY_BINDING_NAMES` field not listed * in {@link BINDING_FIELD_SHAPES}. `secrets.required` (a `string[]`) is folded in @@ -2502,11 +2710,12 @@ type BindingFieldShape = | { kind: 'name-keyed-array' } | { kind: 'namespace-array' } | { kind: 'singleton'; bindingRequired: boolean } - | { kind: 'binding-list'; bindingsRequired: boolean }; + | { kind: 'binding-list'; bindingsRequired: boolean } + | { kind: 'whole-object' }; /** * Explicit per-field shape metadata (see {@link BindingFieldShape}), transcribed - * from wrangler 4.107's config schema. Any `FRIENDLY_BINDING_NAMES` field absent here is + * from wrangler 4.131's config schema. Any `FRIENDLY_BINDING_NAMES` field absent here is * a `namespace-array` (`[{ binding }]`) — the shape wrangler's `getBindingNames` reads * via its `isNamespaceList` branch. */ @@ -2521,6 +2730,9 @@ const BINDING_FIELD_SHAPES: Record = { // these; `validateSendEmailBinding`/`validateRateLimitBinding` require `.name`). send_email: { kind: 'name-keyed-array' }, ratelimits: { kind: 'name-keyed-array' }, + // Whole-object — no per-entry binding identity; the deploy verifier compares the + // whole value against the pre-build root (see the shape table). + addresses: { kind: 'whole-object' }, // Singletons `{ binding }` — `binding` REQUIRED per schema for all except `assets` // (`validateAssetsConfig`, where a binding-less assets-only-directory is legal). ai: { kind: 'singleton', bindingRequired: true }, @@ -2562,6 +2774,8 @@ export function bindingNamesForField(field: string, value: unknown): Array; build?: string; @@ -81,6 +83,16 @@ export type AiConfig = { gateway?: string; }; +/** + * Email configuration. `from` is the default sender for `sendEmail()` — one + * address, optionally with a display name (`Acme `) — + * and, on `void deploy --platform cloudflare`, its host is the domain the CLI + * sets Email Routing and Email Sending up on. + */ +export type EmailConfig = { + from?: string; +}; + /** Shape of the `void.json` config file. All fields optional. */ export type VoidConfig = { $schema?: string; @@ -96,6 +108,7 @@ export type VoidConfig = { worker?: WranglerConfig; database?: 'pg' | 'mysql'; ai?: AiConfig; + email?: EmailConfig; }; const CONFIG_FILE = 'void.json'; @@ -757,12 +770,12 @@ export function readConfig(root: string): VoidConfig { } const b = inf.bindings as Record; for (const key of Object.keys(b)) { - if (!['db', 'kv', 'storage', 'ai'].includes(key)) { + if (!['db', 'kv', 'storage', 'ai', 'email'].includes(key)) { throw new Error( - `config: Unknown binding '${key}' in ${CONFIG_FILE}. Valid: db, kv, storage, ai.`, + `config: Unknown binding '${key}' in ${CONFIG_FILE}. Valid: db, kv, storage, ai, email.`, ); } - if (key === 'ai') { + if (key === 'ai' || key === 'email') { if (typeof b[key] !== 'boolean') { throw new Error( `config: "inference.bindings.${key}" in ${CONFIG_FILE} must be a boolean.`, @@ -974,9 +987,59 @@ export function readConfig(root: string): VoidConfig { } } + // Validate email + if (obj.email !== undefined) { + if (typeof obj.email !== 'object' || obj.email === null || Array.isArray(obj.email)) { + throw new Error(`config: "email" in ${CONFIG_FILE} must be an object.`); + } + + const emailCfg = obj.email as Record; + for (const key of Object.keys(emailCfg)) { + if (key !== 'from') { + throw new Error(`config: Unknown key 'email.${key}' in ${CONFIG_FILE}. Valid: from.`); + } + } + + if (emailCfg.from !== undefined) { + parseEmailFrom(emailCfg.from); + } + } + return obj as VoidConfig; } +/** + * Parse `email.from` with the runtime's own address parser, so the config + * accepts exactly what `sendEmail()` accepts: one address, optionally with a + * display name, and nothing that could split into two recipients or smuggle a + * header. A sender that passes here cannot later fail `INVALID_FROM` on shape. + */ +function parseEmailFrom(value: unknown): { email: string; name?: string } { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`config: "email.from" in ${CONFIG_FILE} must be a non-empty string.`); + } + try { + return parseAddress(value, 'email.from'); + } catch (error) { + const detail = error instanceof MimeError ? ` ${error.message}` : ''; + throw new Error( + `config: "email.from" in ${CONFIG_FILE} must be a single email address, optionally with a display name (e.g. "Acme ").${detail}`, + ); + } +} + +/** + * The host of `email.from`, lowercased — the domain `void deploy --platform + * cloudflare` sets email up on. `undefined` when `email.from` is not set. + */ +export function getEmailFromHost(config: VoidConfig): string | undefined { + if (config.email?.from === undefined) { + return undefined; + } + const { email } = parseEmailFrom(config.email.from); + return email.slice(email.indexOf('@') + 1).toLowerCase(); +} + /** Returns true if the target is a non-Cloudflare runtime (Node.js, Bun, Deno). */ export function isNodeTarget(target: VoidConfig['target']): boolean { return target === 'node' || target === 'bun' || target === 'deno'; diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 8f332d5bf..2e443ba42 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -1,4 +1,13 @@ -import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeSync } from 'node:fs'; +import { + chmodSync, + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + statSync, + writeSync, +} from 'node:fs'; import { fileURLToPath } from 'node:url'; import { cloudflare, type PluginConfig, type WorkerConfig } from '@cloudflare/vite-plugin'; import { appendSqliteClassMigration } from '@void/deploy-core'; @@ -13,6 +22,7 @@ import { } from './asset-routing.ts'; import { findVoidAuthConfig } from './auth/config.ts'; import { getApiUrl, getProxyUrl, getToken, isStagingMode } from './cli/auth.ts'; +import { SEND_EMAIL_BINDING } from './cli/email-cloudflare.ts'; import { readProjectConfig } from './cli/project.ts'; import { ensureWranglerCompat, @@ -46,6 +56,7 @@ import { pagesDevCssAssetsPlugin } from './pages/dev-css-assets.ts'; import { bindingRuntimePlugin } from './plugin-binding-runtime.ts'; import { devTriggersHintPlugin } from './plugin-dev-triggers-hint.ts'; import { drizzlePlugin } from './plugin-drizzle.ts'; +import { emailDevInboxPlugin } from './plugin-email-dev-inbox.ts'; import { envClientFoldPlugin } from './plugin-env-client-fold.ts'; import { envClientGuardPlugin } from './plugin-env-client-guard.ts'; import { envSchemaPlugin } from './plugin-env-schema.ts'; @@ -72,9 +83,11 @@ import { routingPlugin } from './plugin-routing.ts'; import { assertNoRootSourceConventionConflicts, resolveProjectPaths } from './project-paths.ts'; import { scanQueuesSync } from './queues/scan.ts'; import { compileAssetFallbackHelper } from './router/compile.ts'; +import { hasEmailHandlers, scanEmailHandlersSync } from './router/email.ts'; import { isRouteEnabledForEnvironment, type RouteEnvironment } from './router/parse-filename.ts'; import { scanMiddlewareSync, scanRoutePatternsSync } from './router/scan.ts'; import { scanWebSocketRoutesSync } from './router/websocket.ts'; +import { DEV_INBOX_ENV_VAR, DEV_INBOX_TOKEN_ENV_VAR } from './runtime/email/dev-inbox.ts'; import { SANDBOX_MIGRATION_TAG, isSandboxEnabled, resolveSandboxConfig } from './sandbox/config.ts'; import { mergeAndExpand, parseDotenvFile } from './shared/dotenv.ts'; import { @@ -101,6 +114,13 @@ import { validateSsrEntry } from './ssr/entry.ts'; * The token is local to the project and only honored by a worker bound to * localhost; treat it like the rest of `.void/` — disposable scratch state, * safe to remove manually if desired. + * + * The file is owner-only (0600). It is the one credential the dev-inbox + * routes accept, and loopback is reachable by every account on the host, + * so a default-umask 0644 file would hand any other local user the raw MIME, + * the DELETE, and the capture endpoint. A file persisted by an earlier + * release is tightened in place, never rotated. Windows has no group/other + * bits — `mode` and `chmod` only toggle write there — so it is left alone. */ /** * Best-effort detection of `vite build` (and `vite preview`) so we can skip @@ -116,6 +136,8 @@ function isViteBuildOrPreview(): boolean { return subcommand === 'build' || subcommand === 'preview'; } +const DEV_TRIGGER_TOKEN_FILE_MODE = 0o600; + function getOrCreateDevTriggerToken(root: string): string { const tokenPath = join(root, '.void', 'dev-trigger-token'); const readExisting = (): string | undefined => { @@ -126,9 +148,24 @@ function getOrCreateDevTriggerToken(root: string): string { return undefined; } }; + // Best-effort: a token we can read but not chmod (foreign owner, read-only + // mount) must still start the dev server. + const tightenExisting = (): void => { + if (process.platform === 'win32') { + return; + } + try { + if ((statSync(tokenPath).mode & 0o077) !== 0) { + chmodSync(tokenPath, DEV_TRIGGER_TOKEN_FILE_MODE); + } + } catch { + // ignore + } + }; const existing = readExisting(); if (existing) { + tightenExisting(); return existing; } @@ -139,7 +176,7 @@ function getOrCreateDevTriggerToken(root: string): string { mkdirSync(join(root, '.void'), { recursive: true }); const fresh = crypto.randomUUID(); try { - const fd = openSync(tokenPath, 'wx'); + const fd = openSync(tokenPath, 'wx', DEV_TRIGGER_TOKEN_FILE_MODE); try { writeSync(fd, fresh); } finally { @@ -394,6 +431,40 @@ export function shouldBindWorkersAi( return needsAI && command === 'build' && !resultAi && !resolvedAi; } +/** + * Decide whether to inject the `void dev` email-inbox loopback var. + * + * The dev inbox buffer lives in the Vite dev server's Node process; + * `sendEmail` runs in workerd and reaches it by fetching the inbox capture + * endpoint through the worker's assets binding, which + * `@cloudflare/vite-plugin` wires straight into the dev server's middleware + * stack. The var carries that binding's NAME (`ASSETS` for a Void app, or + * whatever a Class A framework's own wrangler config declares) and its + * presence is what arms the dev path at runtime — so it must never reach a + * production build's `wrangler.json`, exactly like the `__VOID_TOKEN` + * credential gate. + * + * Gated on `routeEnvironment`, not on `command`: `vite preview` also reports + * `command: 'serve'`, but it runs the BUILT worker whose assets binding is a + * real asset directory rather than a dev-server loopback. `routeEnvironment` + * is the existing `command === 'serve' && !isPreview` distinction. + * + * Without an assets binding there is no loopback, so injecting the var would + * only arm a path that must then fail; gate on it instead. + */ +export function shouldInjectEmailDevInboxVar( + routeEnvironment: RouteEnvironment, + needsEmail: boolean | undefined, + assetsBinding: string | undefined, +): boolean { + return ( + routeEnvironment === 'dev' && + !!needsEmail && + typeof assetsBinding === 'string' && + !!assetsBinding + ); +} + const CLOUDFLARE_BUILTIN_IMPORT = /^cloudflare:/; const SANDBOX_SDK_IMPORT = '@cloudflare/sandbox'; const SANDBOX_SDK_IMPORT_FILTER = /^@cloudflare\/sandbox$/; @@ -712,6 +783,21 @@ export function voidPlugin(options?: { if (detected && bindings.needsLive) { throw new Error('live: void/live is currently supported in Void apps only.'); } + // Inbound handlers are compiled into the worker's `email()` export by + // `routingPlugin`, which no framework branch installs — the framework + // wrapper wires crons and queues only. Without this guard the build + // succeeds, every file in `email/` is dead code, and inbound mail bounces + // at the platform with a message telling the user to redeploy. + // + // Gated on the handler DIRECTORY, not on `bindings.needsEmail`. Outbound + // `sendEmail` is a binding rather than a compiled export, so it keeps + // working in every framework and must not be caught here. + const hasInboundEmail = hasEmailHandlers(scanEmailHandlersSync(paths.sourceRoot)); + if (detected && hasInboundEmail) { + throw new Error( + 'email: inbound email handlers are only supported in Void apps. Remove the email/ directory — `sendEmail` from void/email keeps working.', + ); + } // Server-only void/* imports (db, kv, ai, etc.) must not pull in // cloudflare:workers or node: builtins when bundled for the browser. @@ -742,6 +828,38 @@ export function voidPlugin(options?: { } export const sandbox = { get: getSandbox }; `, + // Server-only for the same reason as the rest: `void/email` reaches + // `runtime/env-raw.ts`, whose top-level `node:async_hooks` import and + // module-scope `new AsyncLocalStorage()` have no browser equivalent. + // Without a stub the client build SUCCEEDS with only an externalization + // warning and ships `new (emptyStub).AsyncLocalStorage` — the page then + // white-screens on load, which is worse than a build failure. + // `void/email/testing` needs its own entry (the filter is anchored) for + // a different reason: it pulls `router/email.ts`, dragging node:path, + // node:fs and node:module plus ~24 KB of build-time router machinery + // into the browser bundle. + // + // Throwing rather than returning `{}`, following `void/sandbox`: an + // accidental client import should fail legibly instead of silently + // no-oping a send the caller believes happened. + 'void/email': ` + const serverOnly = (name) => () => { + throw new Error(name + ' is server-only and cannot be imported by browser code.'); + }; + export const sendEmail = serverOnly('sendEmail'); + export const createEmail = serverOnly('createEmail'); + export const defineEmail = serverOnly('defineEmail'); + export const parseEmail = serverOnly('parseEmail'); + export const replyEmail = serverOnly('replyEmail'); + export const createMessageProxy = serverOnly('createMessageProxy'); + `, + 'void/email/testing': ` + const serverOnly = (name) => () => { + throw new Error(name + ' is server-only and cannot be imported by browser code.'); + }; + export const createEmailTestHarness = serverOnly('createEmailTestHarness'); + export const createInboundTestHarness = serverOnly('createInboundTestHarness'); + `, 'void/live': '', 'void/durable': '', }; @@ -1148,6 +1266,74 @@ export function voidPlugin(options?: { result.vars = vars; } + // Email — no send binding is emitted on purpose. Outbound mail routes + // through __VOID_PROXY/email/send (the proxy worker holds the + // platform-level send_email binding), so `bindings.needsEmail` only gates + // the dev-inbox plugin and the deploy manifest flag. + // + // Dev only: name the assets binding so `sendEmail` can loop back into the + // Vite dev server and reach the `/__void/inbox` buffer, which lives in the + // Node process rather than in this worker's isolate — and hand it the + // dev-trigger token the inbox middleware requires on every route. + const emailDevInboxBinding = + (workerConfig.assets as { binding?: string } | undefined)?.binding ?? + (resolved.assets as { binding?: string } | undefined)?.binding; + if (shouldInjectEmailDevInboxVar(routeEnvironment, bindings.needsEmail, emailDevInboxBinding)) { + const vars = (result.vars as Record) ?? {}; + vars[DEV_INBOX_ENV_VAR] = emailDevInboxBinding as string; + vars[DEV_INBOX_TOKEN_ENV_VAR] = devTriggerToken; + result.vars = vars; + } + + // Serve never carries Void's own `send_email` binding, so the dev inbox + // stays the only sink. `void deploy --platform cloudflare` writes + // `send_email: [{ name: "SEND_EMAIL" }]` into the root wrangler.jsonc, and + // `sendEmail` falls through to `env.SEND_EMAIL` whenever the inbox is not + // armed (`sendInDev: true`, or no assets binding to reach the dev server + // through) — under miniflare that binding writes .eml files to disk, or + // with `remote: true` sends real mail. Only the entry under the reserved + // `SEND_EMAIL` name is removed: a user's own `send_email` binding under + // another name is theirs — nothing in Void reads it, and the root config + // takes precedence — so it reaches miniflare exactly as declared. Gated on + // `needsEmail`, the same signal that arms the inbox: without `void/email` + // in the app there is no `sendEmail` to guard, and a `SEND_EMAIL` entry is + // then either a stale leftover nothing reads or the user's own choice of + // name; `void deploy` warns about the former and deploys as-is, and dev + // must not be stricter about the root config than deploy is. Mutate + // `resolved`, as the assets policy above does: the return value is + // defu-merged over it (arrays concatenate, `undefined` is skipped), so + // this is the only way to remove an entry. An emptied array drops the key + // — the Cloudflare plugin treats `[]` and absent alike. Build passes the + // root entries through untouched. + if (viteCommand === 'serve' && bindings.needsEmail) { + const sendEmail = (resolved as { send_email?: unknown }).send_email; + if (Array.isArray(sendEmail)) { + const kept = sendEmail.filter( + (entry) => (entry as { name?: unknown } | null)?.name !== SEND_EMAIL_BINDING, + ); + if (kept.length > 0) { + (resolved as Record).send_email = kept; + } else { + delete (resolved as Record).send_email; + } + } + } + + // Default sender for `sendEmail()` from void.json `email.from`. Serve-only, + // the same gate as `__VOID_TOKEN` — not because it is a credential, but + // because on a build the root wrangler.jsonc already owns the var: + // `void deploy --platform cloudflare` writes the same `__VOID_EMAIL_FROM` + // there once the domain's routing or sending is ready, and that entry + // reaches `dist/ssr/wrangler.json` through `resolved.vars`. Injecting it on + // build too would advertise a sender on an account nothing was set up on. + // The managed deploy ignores the generated wrangler config and injects its + // own sender. + if (viteCommand === 'serve' && config.email?.from) { + const vars = (result.vars as Record) ?? {}; + vars.__VOID_EMAIL_FROM = config.email.from; + result.vars = vars; + } + // Queues — add producer + consumer bindings for local dev if (queues.length > 0) { (result as Record).queues = { @@ -1507,7 +1693,7 @@ export function voidPlugin(options?: { // Runs before the Cloudflare plugin so vars are available as worker bindings. // // The `{ mode, envDir }` this hook OBSERVES (raw, root-relative) is exposed via `api` so the - // self-host `void deploy --backend cloudflare` secret preflight can validate the SAME `.env*` + // self-host `void deploy --platform cloudflare` secret preflight can validate the SAME `.env*` // set the build ships. It must be captured HERE, at this hook's position, not read off the // final `resolved.envDir`: config hooks run in sequence, and a LATER plugin's `config` hook // that returns a different `envDir` would change `resolved.envDir` while this hook already @@ -1727,6 +1913,9 @@ export function voidPlugin(options?: { if (fileBindings.needsSandbox && !bindings.needsSandbox) { newBindings.push('Sandbox (void/sandbox)'); } + if (fileBindings.needsEmail && !bindings.needsEmail) { + newBindings.push('Email (void/email)'); + } } if (autoDetectAuth && fileBindings.needsAuth) { newBindings.push('Auth (void/auth)'); @@ -1873,6 +2062,7 @@ export function voidPlugin(options?: { createWranglerCompatPersistPlugin(root, config), restartPlugin(), bindingWatchPlugin, + ...(bindings.needsEmail ? [emailDevInboxPlugin({ devTriggerToken })] : []), envPlugin, envSchemaPlugin(root, { onDefaults: applySchemaDefaultsToWorkerVars, @@ -1936,6 +2126,14 @@ export function voidPlugin(options?: { `durable: durable-objects/ requires target: "cloudflare". It is not available with target: '${config.target}'.`, ); } + // `compileNodeApp` imports only the Hono app from the route table; the + // `__emailHandler` the table exports for `email/` is never invoked, so + // without this the build succeeds and every inbound handler is dead code. + if (hasInboundEmail) { + throw new Error( + `email: inbound email handlers (email/) require target: "cloudflare". They are not available with target: '${config.target}'.`, + ); + } if (config.routing?.revalidate) { voidWarn( '"routing.revalidate" in void.json is ignored with target: "' + @@ -2045,6 +2243,7 @@ export function voidPlugin(options?: { createWranglerCompatPersistPlugin(root, config), restartPlugin(), bindingWatchPlugin, + ...(bindings.needsEmail ? [emailDevInboxPlugin({ devTriggerToken })] : []), envPlugin, envSchemaPlugin(root, { onDefaults: applySchemaDefaultsToWorkerVars, diff --git a/packages/void/src/inference/walk.ts b/packages/void/src/inference/walk.ts index 4e7707c31..b034a8bd7 100644 --- a/packages/void/src/inference/walk.ts +++ b/packages/void/src/inference/walk.ts @@ -12,8 +12,13 @@ export type InferredBindings = { needsAI: boolean; needsSandbox: boolean; needsLive?: boolean; + needsEmail?: boolean; }; +// SEND_EMAIL is intentionally absent from this regex: outbound email routes +// through __VOID_PROXY/email/send, so the user worker never has a SEND_EMAIL +// binding to detect. The `void/email` import source (tiers 1-2 below) is the +// sole driver of `needsEmail`. const ENV_BINDING_RE = /\benv\s*\.\s*(DB|KV|STORAGE|QUEUE_\w+|AI|SANDBOX)\b/; /** @@ -34,6 +39,7 @@ export function inferBindingsFromSource(code: string, filename: string): Inferre needsAI: false, needsSandbox: false, needsLive: false, + needsEmail: false, }; // Tier 1: Fast import detection via es-module-lexer @@ -67,6 +73,11 @@ export function inferBindingsFromSource(code: string, filename: string): Inferre case 'void/sandbox': result.needsSandbox = true; break; + case 'void/email': + if (!isTypeOnlyImportStatement(code.slice(imp.importStart, imp.importEnd + 1))) { + result.needsEmail = true; + } + break; case 'void/live': if (!isTypeOnlyImportStatement(code.slice(imp.importStart, imp.importEnd + 1))) { result.needsLive = true; @@ -109,6 +120,9 @@ export function inferBindingsFromSource(code: string, filename: string): Inferre if (/\bfrom\s+["']void\/sandbox["']/.test(code)) { result.needsSandbox = true; } + if (hasRuntimeImportFrom(code, 'void/email')) { + result.needsEmail = true; + } if (hasRuntimeImportFrom(code, 'void/live')) { result.needsLive = true; } @@ -120,7 +134,10 @@ export function inferBindingsFromSource(code: string, filename: string): Inferre } } - // If imports resolved all bindings, we're done + // If imports resolved every binding the AST tier could still discover, we're + // done. `needsEmail` is deliberately not part of this predicate: it has no + // env-binding form (see ENV_BINDING_RE above), so it is already final here + // and requiring it would only suppress an otherwise-valid early exit. if ( result.needsD1 && result.needsKV && @@ -251,5 +268,6 @@ export function mergeBindings(bindings: Array): InferredBindin needsAI: bindings.some((b) => b.needsAI), needsSandbox: bindings.some((b) => b.needsSandbox), needsLive: bindings.some((b) => b.needsLive), + needsEmail: bindings.some((b) => b.needsEmail), }; } diff --git a/packages/void/src/plugin-email-dev-inbox.ts b/packages/void/src/plugin-email-dev-inbox.ts new file mode 100644 index 000000000..9da0e9a6d --- /dev/null +++ b/packages/void/src/plugin-email-dev-inbox.ts @@ -0,0 +1,372 @@ +// Dev-only Vite plugin: owns the in-memory email buffer and serves +// `/__void/inbox` routes for browsing captured messages. Activated when +// binding inference detected `void/email` usage. +// +// Two ways a message lands in the buffer: +// * `POST /__void/inbox/_capture` — the real `void dev` path. `sendEmail` +// runs in workerd (a separate OS process), so it cannot reach this +// process's sink registry; it POSTs the captured message here through its +// dev assets binding, which @cloudflare/vite-plugin wires directly into +// this middleware stack. +// * the in-process sink — only fires for a `sendEmail` that happens to run +// inside this Node process (e.g. a prerender pass). Kept so that path is +// still captured; it is not the dev-server transport. +// +// Every route requires the project's dev-trigger token — the same one +// `/__void/scheduled` and `/__void/queue` check. Vite's own host validation +// only rejects DNS-rebinding hostnames: any IP-literal `Host` passes, so with +// `--host` (or a shared port) every peer on the network could otherwise read +// raw MIME (Bcc, attachments), clear the buffer, or plant captures. + +import { timingSafeEqual } from 'node:crypto'; +import type { Plugin } from 'vite'; +import { voidLog } from './log.ts'; +import { + clearDevBuffer, + DEV_INBOX_ACK_HEADER, + DEV_INBOX_ACK_VALUE, + DEV_INBOX_CAPTURE_PATH, + DEV_INBOX_MAX_CAPTURE_BYTES, + DEV_INBOX_PREFIX, + DEV_INBOX_TOKEN_HEADER, + DEV_INBOX_TOKEN_QUERY, + findDevMessage, + listDevMessages, + parseCapturedMessage, + pushToDevBuffer, + registerEmailSink, + type CapturedMessage, +} from './runtime/email/dev-inbox.ts'; + +const INBOX_PREFIX = DEV_INBOX_PREFIX; + +const SECURITY_HEADERS = { + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'no-referrer', + // The detail page already escapes HTML and renders the email body inside a + // sandboxed iframe (no allow-scripts). CSP enforces it for everything else. + 'Content-Security-Policy': + "default-src 'none'; style-src 'unsafe-inline'; frame-src data:; img-src data:; connect-src 'none'", +} as const; + +export interface EmailDevInboxPluginOptions { + /** + * The per-project dev-trigger token (`.void/dev-trigger-token`), shared with + * the cron/queue dev routes and injected into the worker as + * `__VOID_EMAIL_DEV_INBOX_TOKEN` so its capture POST can present it. + */ + devTriggerToken: string; +} + +export function emailDevInboxPlugin(options: EmailDevInboxPluginOptions): Plugin { + const { devTriggerToken } = options; + let devMode = false; + let unregister: (() => void) | undefined; + + /** `href` for an inbox path that keeps the browser session authorized. */ + const inboxHref = (path: string): string => + `${INBOX_PREFIX}${path}?${DEV_INBOX_TOKEN_QUERY}=${encodeURIComponent(devTriggerToken)}`; + + return { + name: 'void:email-dev-inbox', + apply: 'serve', + config() { + devMode = true; + }, + buildStart() { + if (!devMode || unregister) { + return; + } + unregister = registerEmailSink((m) => pushToDevBuffer(m)); + }, + buildEnd() { + if (unregister) { + unregister(); + unregister = undefined; + } + }, + configureServer(server) { + if (server.config.command !== 'serve') { + return; + } + + // The browser cannot add a header, so the inbox URL is printed with the + // token in its query, the same way the cron/queue hint prints its curl. + const printInboxUrl = () => { + const address = server.httpServer?.address(); + const port = typeof address === 'object' && address ? address.port : null; + if (port == null) { + return; + } + voidLog(`Email inbox: http://localhost:${port}${inboxHref('')}`); + }; + if (server.httpServer) { + server.httpServer.once('listening', () => setTimeout(printInboxUrl, 100)); + } + + server.middlewares.use(INBOX_PREFIX, (req, res) => { + if (server.config.command !== 'serve') { + res.writeHead(404, SECURITY_HEADERS); + res.end(); + return; + } + const url = new URL(req.url ?? '/', 'http://localhost'); + const path = url.pathname; + + if (!isAuthorized(req.headers[DEV_INBOX_TOKEN_HEADER], url, devTriggerToken)) { + // No ack header here: the worker must not read a refusal as a capture. + res.writeHead(401, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain; charset=utf-8' }); + res.end( + `Unauthorized: the void dev inbox requires the dev-trigger token — send it as the ${DEV_INBOX_TOKEN_HEADER} header or the ?${DEV_INBOX_TOKEN_QUERY}= query (printed as the inbox URL when void dev starts; stored in .void/dev-trigger-token).`, + ); + return; + } + + if (req.method === 'POST' && path === DEV_INBOX_CAPTURE_PATH) { + void handleCapture(req, res); + return; + } + if (req.method === 'DELETE' && path === '/') { + clearDevBuffer(); + res.writeHead(204, SECURITY_HEADERS); + res.end(); + return; + } + if (req.method !== 'GET') { + res.writeHead(405, SECURITY_HEADERS); + res.end(); + return; + } + + if (path === '/' || path === '') { + res.writeHead(200, { ...SECURITY_HEADERS, 'Content-Type': 'text/html; charset=utf-8' }); + res.end(renderListHtml(listDevMessages(), inboxHref)); + return; + } + const match = path.match(/^\/([^/]+)(\/raw)?$/); + if (!match) { + res.writeHead(404, SECURITY_HEADERS); + res.end(); + return; + } + const id = match[1]; + const wantsRaw = Boolean(match[2]); + const message = findDevMessage(id); + if (!message) { + res.writeHead(404, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' }); + res.end('Email not found.'); + return; + } + if (wantsRaw) { + // The id is a UUID generated by the dev sink so it's already safe + // for a header value, but defense-in-depth: strip any quote chars. + const safeId = id.replace(/["\r\n]/g, ''); + res.writeHead(200, { + ...SECURITY_HEADERS, + 'Content-Type': 'message/rfc822', + 'Content-Disposition': `attachment; filename="${safeId}.eml"`, + }); + res.end(Buffer.from(message.raw)); + return; + } + res.writeHead(200, { ...SECURITY_HEADERS, 'Content-Type': 'text/html; charset=utf-8' }); + res.end(renderDetailHtml(message, inboxHref)); + }); + }, + }; +} + +/** + * Constant-time check of the presented token — header first (worker, curl), + * then the query (browser). Both are compared against the one project token. + */ +function isAuthorized( + headerValue: string | Array | undefined, + url: URL, + expected: string, +): boolean { + const presented = + typeof headerValue === 'string' ? headerValue : url.searchParams.get(DEV_INBOX_TOKEN_QUERY); + if (typeof presented !== 'string') { + return false; + } + const a = Buffer.from(presented); + const b = Buffer.from(expected); + return a.byteLength === b.byteLength && timingSafeEqual(a, b); +} + +// Structural shapes rather than `node:http` classes: the same handler is +// exercised in unit tests against a synthesized req/res pair. +interface CaptureRequest extends AsyncIterable { + headers: Record | undefined>; +} +interface CaptureResponse { + writeHead(status: number, headers?: Record): unknown; + end(chunk?: string | Buffer): unknown; +} + +/** + * `POST /__void/inbox/_capture` — the worker's dev-inbox hop. + * + * Fails with a status the worker can distinguish, and only a success carries + * the ack header, so the worker never mistakes an ordinary asset 200 for a + * capture. Never throws: an exception here would surface as a dev-server 500 + * in the middle of the user's `sendEmail`. + */ +async function handleCapture(req: CaptureRequest, res: CaptureResponse): Promise { + try { + const contentType = String(req.headers['content-type'] ?? ''); + if (!contentType.includes('application/json')) { + res.writeHead(415, SECURITY_HEADERS); + res.end(); + return; + } + const chunks: Array = []; + let size = 0; + for await (const chunk of req) { + const buf = typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk); + size += buf.byteLength; + if (size > DEV_INBOX_MAX_CAPTURE_BYTES) { + res.writeHead(413, SECURITY_HEADERS); + res.end(); + return; + } + chunks.push(buf); + } + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + res.writeHead(400, SECURITY_HEADERS); + res.end(); + return; + } + const message = parseCapturedMessage(parsed); + if (!message) { + res.writeHead(400, SECURITY_HEADERS); + res.end(); + return; + } + pushToDevBuffer(message); + res.writeHead(200, { + ...SECURITY_HEADERS, + [DEV_INBOX_ACK_HEADER]: DEV_INBOX_ACK_VALUE, + 'Content-Type': 'application/json', + }); + res.end(JSON.stringify({ ok: true, id: message.id })); + } catch { + res.writeHead(500, SECURITY_HEADERS); + res.end(); + } +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function formatAddress(a: { email: string; name?: string }): string { + return a.name ? `${a.name} <${a.email}>` : a.email; +} + +function formatTime(ts: number): string { + return new Date(ts).toLocaleString(); +} + +const STYLE = ` + :root { color-scheme: light dark; } + body { font: 14px/1.5 ui-sans-serif, system-ui, sans-serif; margin: 0; padding: 24px; max-width: 1024px; margin: 0 auto; } + h1 { font-size: 18px; margin: 0 0 16px; } + h2 { font-size: 14px; margin: 24px 0 8px; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; } + table { border-collapse: collapse; width: 100%; } + th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent); vertical-align: top; } + th { font-weight: 600; opacity: 0.7; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; } + a { color: #5b8def; text-decoration: none; } + a:hover { text-decoration: underline; } + .meta { font-size: 12px; opacity: 0.7; } + .empty { padding: 48px; text-align: center; opacity: 0.6; } + .row { display: flex; gap: 12px; align-items: baseline; } + .row .subject { font-weight: 600; } + iframe { width: 100%; min-height: 480px; border: 1px solid color-mix(in srgb, currentColor 15%, transparent); border-radius: 8px; background: white; } + .actions { display: flex; gap: 12px; margin-bottom: 16px; } + .button { display: inline-block; padding: 6px 12px; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 6px; cursor: pointer; background: transparent; color: inherit; font: inherit; } + pre { white-space: pre-wrap; word-break: break-word; padding: 12px; background: color-mix(in srgb, currentColor 5%, transparent); border-radius: 6px; } + .header-table th { width: 160px; } +`; + +function renderListHtml(messages: Array, href: (path: string) => string): string { + const rows = messages + .map( + (m) => ` + + ${escapeHtml(m.subject || '(no subject)')} + ${escapeHtml(formatAddress(m.from))} + ${escapeHtml(m.to.map(formatAddress).join(', '))} + ${escapeHtml(formatTime(m.capturedAt))} + .eml + `, + ) + .join(''); + + return ` +Void — Email inbox + +

Email inbox (${messages.length} message${messages.length === 1 ? '' : 's'})

+ ${ + messages.length === 0 + ? `
No messages captured yet. Call sendEmail() from your app to populate.
` + : ` + + ${rows} +
SubjectFromToCaptured
` + } +`; +} + +function renderDetailHtml(m: CapturedMessage, href: (path: string) => string): string { + const headerRows = Object.entries(m.headers) + .map(([k, v]) => `${escapeHtml(k)}${escapeHtml(v)}`) + .join(''); + const attachmentRows = m.attachments + .map( + (a) => + `${escapeHtml(a.filename)}${escapeHtml(a.contentType)}${a.size} B${a.disposition}`, + ) + .join(''); + const bodyHtml = m.html + ? `` + : `
${escapeHtml(m.text ?? '')}
`; + + return ` +${escapeHtml(m.subject || '(no subject)')} — Void inbox + + +

${escapeHtml(m.subject || '(no subject)')}

+ + + + ${m.cc.length > 0 ? `` : ''} + ${m.bcc.length > 0 ? `` : ''} + ${m.replyTo ? `` : ''} + + +
From${escapeHtml(formatAddress(m.from))}
To${escapeHtml(m.to.map(formatAddress).join(', '))}
Cc${escapeHtml(m.cc.map(formatAddress).join(', '))}
Bcc${escapeHtml(m.bcc.map(formatAddress).join(', '))}
Reply-To${escapeHtml(formatAddress(m.replyTo))}
Captured${escapeHtml(formatTime(m.capturedAt))}
Message-ID${escapeHtml(m.messageId)}
+ ${headerRows ? `

Custom headers

${headerRows}
` : ''} + ${ + attachmentRows + ? `

Attachments

${attachmentRows}
FilenameTypeSizeDisposition
` + : '' + } +

Body

+ ${bodyHtml} +`; +} diff --git a/packages/void/src/plugin-framework-triggers.ts b/packages/void/src/plugin-framework-triggers.ts index f71f6c77f..739dc0958 100644 --- a/packages/void/src/plugin-framework-triggers.ts +++ b/packages/void/src/plugin-framework-triggers.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'pathslash'; import { normalizePath, type Plugin } from 'vite'; import { scanJobsSync, type JobDefinition } from './crons/scan.ts'; @@ -10,6 +10,8 @@ type FrameworkTriggersOptions = { devTriggerToken: string; }; +const DISPATCH_FILE_MODE = 0o600; + /** Absolute path of the generated dispatch file. Stable across the dev process. */ export function frameworkTriggersDispatchPath(root: string): string { return join(root, '.void', 'dev-triggers.ts'); @@ -27,10 +29,23 @@ export function writeFrameworkTriggersDispatch(root: string, devTriggerToken: st const queues = scanQueuesSync(root, paths.queuesDir); const target = frameworkTriggersDispatchPath(root); mkdirSync(dirname(target), { recursive: true }); + // Owner-only, like `.void/dev-trigger-token`: this module embeds the same + // token, and it is the inbox's whole credential (see `getOrCreateDevTriggerToken`). + // `mode` applies only when the file is created, so a file left by an + // earlier release is tightened after the rewrite — best-effort, and not on + // Windows, where `chmod` has no group/other bits to clear. writeFileSync( target, generateModule(paths.cronsDir, paths.queuesDir, jobs, queues, devTriggerToken), + { mode: DISPATCH_FILE_MODE }, ); + if (process.platform !== 'win32') { + try { + chmodSync(target, DISPATCH_FILE_MODE); + } catch { + // ignore + } + } } /** diff --git a/packages/void/src/plugin-inference.ts b/packages/void/src/plugin-inference.ts index 77b163404..4d7810f93 100644 --- a/packages/void/src/plugin-inference.ts +++ b/packages/void/src/plugin-inference.ts @@ -29,13 +29,20 @@ export const FRAMEWORK_SCAN_DIRS = [ 'queues', ] as const; -/** Directories to scan for binding inference in Void app mode. */ +/** + * Directories to scan for binding inference in Void app mode. + * + * `email` is included so a `sendEmail()` call from inside an inbound handler + * (`email/.ts`) registers `needsEmail = true`. Otherwise the manifest + * flag and the dev-inbox plugin would miss those callsites. + */ export const DEFAULT_SCAN_DIRS = [ 'routes', 'middleware', 'queues', 'pages', 'crons', + 'email', 'src', ] as const; @@ -89,6 +96,7 @@ export type InferenceOptions = { kv?: boolean | string; storage?: boolean | string; ai?: boolean; + email?: boolean; }; paths?: ProjectPaths; }; @@ -134,6 +142,7 @@ export function inferProjectBindings( needsAI: Boolean(options.bindings.ai), needsSandbox: false, needsLive: false, + needsEmail: Boolean(options.bindings.email), }; } diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index ececdd7be..9441cdbed 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -109,6 +109,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const middlewareDir = paths.middlewareDir; const jobsDir = paths.cronsDir; const queuesDir = paths.queuesDir; + const emailDir = paths.emailDir; const pagesDir = paths.pagesDir; const ssrDir = paths.ssrDir; const authConfigPaths = new Set(paths.authConfigCandidates.map((path) => normalizePath(path))); @@ -117,6 +118,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const normalizedMiddlewareDir = normalizePath(middlewareDir); const normalizedJobsDir = normalizePath(jobsDir); const normalizedQueuesDir = normalizePath(queuesDir); + const normalizedEmailDir = normalizePath(emailDir); const normalizedPagesDir = normalizePath(pagesDir); const normalizedSsrDir = normalizePath(ssrDir); const ssrEntryAbsPaths = new Set( @@ -125,6 +127,11 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug ), ); + function hasEmailHandlers(): boolean { + const email = scan?.email; + return Boolean(email && (email.addresses.length > 0 || email.defaultHandler)); + } + function isInDirectory(path: string, directory: string): boolean { return path === directory || path.startsWith(directory + '/'); } @@ -202,6 +209,8 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug responsePath: resolveFile('#self/response'), handlerPath: resolveFile('#self/handler'), runtimeEnvPath: resolveFile('#self/_env'), + runtimeEmailPath: resolveFile('#self/email'), + emailDir, projectRoot: root, homeDir: homedir(), webSocketRuntimePath: resolveFile('#self/runtime/ws-server'), @@ -435,26 +444,22 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } else { writeFileIfChanged( join(outDir, 'entry.ts'), - compileWorkerEntry( - false, + compileWorkerEntry({ remote, - undefined, - options.websocketTombstones, - compileOptions.runtimeEnvPath, - getEnvSchemaModule(), - options.hasSandbox + webSocketClasses: options.websocketTombstones, + runtimeEnvPath: compileOptions.runtimeEnvPath, + runtimeEmailPath: compileOptions.runtimeEmailPath, + envSchemaModule: getEnvSchemaModule(), + sandbox: options.hasSandbox ? { bindingName: options.sandboxBindingName, className: options.sandboxClassName, runtimePath: sandboxRuntimePath, } : false, - undefined, - options.live, - undefined, - undefined, - options.durableObjects, - ), + live: options.live, + durableObjects: options.durableObjects, + }), ); } @@ -584,29 +589,31 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug : undefined; writeFileIfChanged( join(outDir, 'entry.ts'), - compileWorkerEntry( - scan!.queues.length > 0, + compileWorkerEntry({ + hasQueues: scan!.queues.length > 0, remote, - resolvedConfig?.build.assetsDir, - [ + assetsDir: resolvedConfig?.build.assetsDir, + webSocketClasses: [ ...(scan!.websockets ?? []).map((route) => route.className), ...(options.websocketTombstones ?? []), ], - compileOptions.runtimeEnvPath, - getEnvSchemaModule(), - options.hasSandbox + runtimeEnvPath: compileOptions.runtimeEnvPath, + runtimeEmailPath: compileOptions.runtimeEmailPath, + envSchemaModule: getEnvSchemaModule(), + sandbox: options.hasSandbox ? { bindingName: options.sandboxBindingName, className: options.sandboxClassName, runtimePath: sandboxRuntimePath, } : false, - shouldServeSpaFallback(), - options.live, - isr, - currentNotFoundHandling(), - options.durableObjects, - ), + hasEmail: hasEmailHandlers(), + serveSpaFallback: shouldServeSpaFallback(), + live: options.live, + isr: isr, + notFound: currentNotFoundHandling(), + durableObjects: options.durableObjects, + }), ); } }, @@ -682,6 +689,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug isInDirectory(normalizedPath, normalizedMiddlewareDir) || isInDirectory(normalizedPath, normalizedJobsDir) || isInDirectory(normalizedPath, normalizedQueuesDir) || + isInDirectory(normalizedPath, normalizedEmailDir) || isInDirectory(normalizedPath, normalizedPagesDir) || isPotentialAuthConfigPath(normalizedPath) || isPotentialEnvFilePath(normalizedPath); @@ -725,29 +733,31 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } else { writeFileIfChanged( join(outDir, 'entry.ts'), - compileWorkerEntry( - scan!.queues.length > 0, + compileWorkerEntry({ + hasQueues: scan!.queues.length > 0, remote, - resolvedConfig?.build.assetsDir, - [ + assetsDir: resolvedConfig?.build.assetsDir, + webSocketClasses: [ ...(scan!.websockets ?? []).map((route) => route.className), ...(options.websocketTombstones ?? []), ], - compileOptions.runtimeEnvPath, - getEnvSchemaModule(), - options.hasSandbox + runtimeEnvPath: compileOptions.runtimeEnvPath, + runtimeEmailPath: compileOptions.runtimeEmailPath, + envSchemaModule: getEnvSchemaModule(), + sandbox: options.hasSandbox ? { bindingName: options.sandboxBindingName, className: options.sandboxClassName, runtimePath: sandboxRuntimePath, } : false, - shouldServeSpaFallback(), - options.live, - undefined, - currentNotFoundHandling(), - options.durableObjects, - ), + hasEmail: hasEmailHandlers(), + serveSpaFallback: shouldServeSpaFallback(), + live: options.live, + isr: undefined, + notFound: currentNotFoundHandling(), + durableObjects: options.durableObjects, + }), ); } diff --git a/packages/void/src/prerender.ts b/packages/void/src/prerender.ts index c4eca67df..1efd87554 100644 --- a/packages/void/src/prerender.ts +++ b/packages/void/src/prerender.ts @@ -301,11 +301,18 @@ async function createCloudflarePrerenderRunner(options: { const wranglerUrl = import.meta.resolve('wrangler/package.json'); const require = createRequire(wranglerUrl); const miniflarePath = require.resolve('miniflare'); - const { Miniflare } = (await import(pathToFileURL(miniflarePath).href)) as { + // miniflare 5 (bundled with wrangler >= 4.131) only accepts its new multi-worker + // config shape; the flat single-worker options below are the v4 shape, which it + // still exports a converter for. `@cloudflare/vite-plugin` 1.54 builds its own + // Miniflare instances the same way (`new Miniflare(convertV4MiniflareOptions(...))`). + const { Miniflare, convertV4MiniflareOptions } = (await import( + pathToFileURL(miniflarePath).href + )) as { Miniflare: new (options: Record) => { dispatchFetch(input: RequestInfo | URL, init?: RequestInit): Promise; dispose(): Promise; }; + convertV4MiniflareOptions: (options: Record) => Record; }; const mfOptions: Record = { @@ -315,7 +322,6 @@ async function createCloudflarePrerenderRunner(options: { compatibilityFlags: wranglerCompat.compatibilityFlags ?? [], bindings, }; - if (clientDir) { mfOptions.serviceBindings = { [wranglerConfig.assets?.binding ?? 'ASSETS'](request: Request) { @@ -338,7 +344,6 @@ async function createCloudflarePrerenderRunner(options: { databases[db.binding] = db.database_id; } mfOptions.d1Databases = databases; - mfOptions.d1Persist = persistDir; } const kv = wranglerConfig.kv_namespaces; @@ -348,7 +353,6 @@ async function createCloudflarePrerenderRunner(options: { namespaces[ns.binding] = ns.id; } mfOptions.kvNamespaces = namespaces; - mfOptions.kvPersist = persistDir; } const r2 = wranglerConfig.r2_buckets; @@ -358,10 +362,22 @@ async function createCloudflarePrerenderRunner(options: { buckets[bucket.binding] = bucket.bucket_name; } mfOptions.r2Buckets = buckets; - mfOptions.r2Persist = persistDir; } - const mf = new Miniflare(mfOptions); + // One persistence root for every local resource: miniflare 5 replaced the + // per-product `d1Persist`/`kvPersist`/`r2Persist` with `resourcePersistencePath`, + // which it splits into `/d1`, `/kv`, `/r2`. The root is + // `/v3`, the same one `@cloudflare/vite-plugin` derives from + // `persistState: { path: '.void' }` for the dev server and the one + // `getMiniflareDatabasePath` (migration runner, drizzle plugin, auth bootstrap) + // addresses: `.void/v3/d1/miniflare-D1DatabaseObject/.sqlite`. Passing + // `persistDir` itself would open a second, empty database under `.void/d1`. + // Set only when a resource is bound, as the per-product options were. + if (mfOptions.d1Databases || mfOptions.kvNamespaces || mfOptions.r2Buckets) { + mfOptions.resourcePersistencePath = join(persistDir, 'v3'); + } + + const mf = new Miniflare(convertV4MiniflareOptions(mfOptions)); const allBindings = mfOptions.bindings as Record | undefined; const proxyToken = allBindings?.__VOID_PROXY_TOKEN as string | undefined; diff --git a/packages/void/src/project-paths.ts b/packages/void/src/project-paths.ts index d24b6b410..230dca525 100644 --- a/packages/void/src/project-paths.ts +++ b/packages/void/src/project-paths.ts @@ -8,6 +8,7 @@ const SOURCE_CONVENTION_DIRECTORIES = [ 'middleware', 'crons', 'queues', + 'email', 'db', ] as const; @@ -36,6 +37,7 @@ export type ProjectPaths = { middlewareDir: string; cronsDir: string; queuesDir: string; + emailDir: string; pagesDir: string; dbDir: string; migrationsDir: string; @@ -101,6 +103,7 @@ export function resolveProjectPaths(root: string, config?: VoidConfig): ProjectP middlewareDir: join(sourceRoot, 'middleware'), cronsDir: join(sourceRoot, 'crons'), queuesDir: join(sourceRoot, 'queues'), + emailDir: join(sourceRoot, 'email'), pagesDir: join(sourceRoot, 'pages'), dbDir: join(sourceRoot, 'db'), migrationsDir: join(sourceRoot, 'db', 'migrations'), diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 2f84f86bd..a8f911497 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -649,6 +649,15 @@ export type CompileOptions = { handlerPath: string; /** Absolute path to the void/_env module */ runtimeEnvPath: string; + /** Absolute path to the void/email module (inbound `createMessageProxy`) */ + runtimeEmailPath?: string; + /** + * Absolute path to the project's top-level `email/` directory. Set when the + * app declares inbound email handlers; the route table then emits + * `__emailHandler`. Lives here rather than in `compileRouteTable`'s + * positional list so the ~68 existing call sites keep binding correctly. + */ + emailDir?: string; /** Absolute path to the project root */ projectRoot: string; /** Absolute path to the current user's home directory */ @@ -1551,6 +1560,122 @@ window.__vite_plugin_react_preamble_installed__ = true; lines.push(''); } + // Inbound email — generate __emailHandler that dispatches by recipient + // local-part. Patterns are tried in the order the scanner sorted them + // (`compareEmailRoutes` in router/email.ts: static-tag > static > + // dynamic-tag > dynamic, a literal tag before a captured one, then + // alphabetical) and the first match wins; captured groups become `info.params` + // and the handler is invoked with (message, env, ctx, info). Falls through + // to email/_default.ts when no per-address pattern matches. + // + // Slug stripping: the shared platform sender rewrites every recipient to + // `+@`, where `` is the + // project slug. User-defined handlers are written against the `` only + // (the slug is invisible to them — see design-doc 0078). When a slug is + // configured (`__VOID_PROJECT_SLUG`) the dispatcher strips everything up to + // and including the first `+` from the local-part before running it through + // the pattern table. Without one — a worker on the developer's own zone — the + // full local part is matched, so `support+T-42@zone` reaches + // `email/support+[ticket].ts` there too. + const email = scan.email; + const emailDir = options.emailDir; + if (emailDir && email && (email.addresses.length > 0 || email.defaultHandler)) { + if (email.defaultHandler) { + lines.push( + `const __emailDefault = () => import(${JSON.stringify(`${emailDir}/${email.defaultHandler}`)});`, + ); + } + for (let i = 0; i < email.addresses.length; i++) { + const def = email.addresses[i]; + lines.push( + `const __emailAddr${i} = () => import(${JSON.stringify(`${emailDir}/${def.filePath}`)});`, + ); + } + if (email.addresses.length > 0) { + lines.push('const __emailPatterns = ['); + for (let i = 0; i < email.addresses.length; i++) { + const def = email.addresses[i]; + const path = `${emailDir}/${def.filePath}`; + lines.push( + ` { re: new RegExp(${JSON.stringify(def.pattern.regexSource)}, "i"), params: ${JSON.stringify(def.pattern.paramNames)}, path: ${JSON.stringify(path)}, load: __emailAddr${i} },`, + ); + } + lines.push('];'); + } + lines.push(''); + lines.push('export async function __emailHandler(message, env, ctx, __opts) {'); + // Route on the PLATFORM (wire) recipient, not `message.to`. + // `createMessageProxy` has already removed the `+` prefix from + // `message.to` so handler authors see the address they wrote, and it + // preserves the wire form as `__voidPlatformTo`. Reading `message.to` + // here would strip the slug a SECOND time, leaving an empty tag that no + // anchored pattern can match. When no slug was injected the two are + // identical, so the fallback is exact. + // Case is PRESERVED here and the patterns carry the `i` flag instead. + // Lowercasing the address first would lowercase the captured groups too, + // silently corrupting a case-sensitive tag such as a ticket id or a token + // (`support+T-42@` reached the handler as `t-42`). + lines.push(' const __platformTo = message ? message.__voidPlatformTo : undefined;'); + lines.push(' const __to = String((__platformTo ?? (message && message.to)) || "");'); + lines.push(' const __localFull = __to.split("@")[0] || "";'); + // Strip the platform-injected `+` prefix exactly when a slug is + // CONFIGURED (`__VOID_PROJECT_SLUG`, passed in by `handleEmail`), not when + // `createMessageProxy` happened to remove one: the platform lane is + // `[+]`, so a bare `@domain` (no `+`) carries no + // `__voidPlatformTo` yet must still read as an EMPTY tag, not as the + // slug itself — otherwise `email/[user].ts` would run with `user=`. + // With no slug configured (a worker on the developer's own zone) the wire + // address IS the address the handler author wrote: match all of it. With + // the prefix present and no `+` after it there is no tag, and the empty + // string falls through to `_default`. + lines.push(' let __local = __localFull;'); + lines.push(' if (__opts && __opts.slug) {'); + lines.push(' const __plus = __localFull.indexOf("+");'); + lines.push(' __local = __plus === -1 ? "" : __localFull.slice(__plus + 1);'); + lines.push(' }'); + if (email.addresses.length > 0) { + lines.push(' for (const __p of __emailPatterns) {'); + lines.push(' const __m = __p.re.exec(__local);'); + lines.push(' if (!__m) continue;'); + lines.push(' const __params = {};'); + lines.push(' for (let __i = 0; __i < __p.params.length; __i++) {'); + lines.push(' __params[__p.params[__i]] = __m[__i + 1];'); + lines.push(' }'); + lines.push(' const __mod = await __p.load();'); + lines.push(' if (typeof __mod.default !== "function") {'); + lines.push( + ' throw new Error("email handler at " + __p.path + " has no default export");', + ); + lines.push(' }'); + lines.push(' return await __mod.default(message, env, ctx, { params: __params });'); + lines.push(' }'); + } + if (email.defaultHandler) { + const defaultPath = `${emailDir}/${email.defaultHandler}`; + lines.push(' const __dmod = await __emailDefault();'); + lines.push(' if (typeof __dmod.default !== "function") {'); + lines.push( + ` throw new Error("email handler at " + ${JSON.stringify(defaultPath)} + " has no default export");`, + ); + lines.push(' }'); + lines.push(' return await __dmod.default(message, env, ctx, { params: {} });'); + } else { + // No-match without _default — explicitly reject so the remote MTA + // gets a non-delivery report. Silent acceptance would mark the + // message accepted on our end while never invoking any handler, + // which is invisible to the sender (no NDR) and to the recipient + // (no delivery). The reject reason is generic so user-handler + // authors can add `_default.ts` to customize. + lines.push(' if (typeof message.setReject === "function") {'); + lines.push( + ' message.setReject("no handler matched recipient \\"" + __to + "\\" — add email/_default.ts to handle unmatched mail");', + ); + lines.push(' }'); + } + lines.push('}'); + lines.push(''); + } + // /__void/migrate handler — runs bundled DB + auth migrations in-worker if (options.migrationHandlerPath && (options.migrations?.length || auth)) { const d1BindingName = options.bindingNames?.d1; @@ -2472,38 +2597,86 @@ ${fallthroughCode} * remote mode (`__VOID_REMOTE`) and replace local bindings with * proxy-backed versions. */ -export function compileWorkerEntry( - hasQueues = false, +// Both modules are siblings under `runtime/`, so `_env` -> `email` is the only +// transform needed. Works for bare specifiers (`void/_env` -> `void/email`) and +// for resolved file paths (`/.../runtime/env.mjs` -> `/.../runtime/email.mjs`). +function deriveEmailRuntimePath(runtimeEnvPath: string): string { + return runtimeEnvPath.replace(/_?env(\.[^/]+)?$/, (_m, ext) => 'email' + (ext ?? '')); +} + +export interface WorkerEntryOptions { + hasQueues?: boolean; remote?: { remotePath: string; envPath: string; bindingNames?: { d1?: string; kv?: string; r2?: string }; - }, - assetsDir = 'assets', - webSocketClasses: Array = [], - runtimeEnvPath = 'void/_env', - envSchemaModule?: { importPath: string; helperPath: string }, - sandbox?: boolean | { bindingName?: string; className?: string; runtimePath?: string }, - serveSpaFallback = false, - live?: boolean | { bindingName?: string; className?: string }, + }; + assetsDir?: string; + webSocketClasses?: Array; + runtimeEnvPath?: string; + /** + * Path to the `void/email` runtime, used for `createMessageProxy` in the + * generated `email()` handler. Defaults to a sibling-of-`runtimeEnvPath` + * derivation, which covers both bare specifiers (`void/_env` -> `void/email`) + * and resolved file paths. + */ + runtimeEmailPath?: string; + envSchemaModule?: { importPath: string; helperPath: string }; + sandbox?: boolean | { bindingName?: string; className?: string; runtimePath?: string }; + /** + * Emit an SPA-style fallback in the asset-fallthrough path: when + * `assetConfig.not_found_handling = "none"`, the worker returns + * `/index.html` for HTML navigations after user routes have run. Pages + * and SSR apps leave this disabled so intentional 404s stay 404. + */ + serveSpaFallback?: boolean; + /** + * Emit the `email()` RPC method and the inbound dispatch glue. The route + * table must export `__emailHandler` when this is set. + */ + hasEmail?: boolean; + live?: boolean | { bindingName?: string; className?: string }; /** * When present, wraps the render path with the self-host ISR cache ladder. - * `runtimePath` is the import specifier for `handleIsr` (Task 10 export). The - * deployment id is NOT passed here — the single source of truth is the - * compile-time `__VOID_ISR_DEPLOYMENT_ID` const imported from the route table. - * When absent, the render path is emitted byte-identical to today (zero + * `runtimePath` is the import specifier for `handleIsr`. The deployment id is + * NOT passed here - the single source of truth is the compile-time + * `__VOID_ISR_DEPLOYMENT_ID` const imported from the route table. When + * absent, the render path is emitted byte-identical to today (zero * behavioral change for managed/dev/no-ISR builds). */ - isr?: { runtimePath: string; queryAllowlist?: Record> }, + isr?: { runtimePath: string; queryAllowlist?: Record> }; /** * Resolved `assetConfig.not_found_handling` for this build. Threaded to * `compileAssetFallbackHelper` so `routing.notFound: '404-page'` is honored * on the worker-first path instead of being discarded. Defaults to `'none'`, * which emits the pre-existing helper byte-for-byte. */ - notFound: NotFoundHandling = 'none', - durableObjects: Array<{ bindingName: string; className: string; filePath: string }> = [], -): string { + notFound?: NotFoundHandling; + /** + * User-defined Durable Object classes (`durable/` directory). Each entry is + * imported from `filePath`, bound to `bindingName`, and re-exported as + * `className` so wrangler's `durable_objects.bindings` can find it. + */ + durableObjects?: Array<{ bindingName: string; className: string; filePath: string }>; +} + +export function compileWorkerEntry(options: WorkerEntryOptions = {}): string { + const { + hasQueues = false, + remote, + assetsDir = 'assets', + webSocketClasses = [], + runtimeEnvPath = 'void/_env', + runtimeEmailPath, + envSchemaModule, + sandbox, + serveSpaFallback = false, + hasEmail = false, + live, + isr, + notFound = 'none', + durableObjects = [], + } = options; const remotePath = remote?.remotePath; const remoteBindingNames = JSON.stringify(remote?.bindingNames); const createRemoteEnvCall = `createRemoteEnv(env${ @@ -2698,12 +2871,76 @@ ${envValidateCall}${isrDeploymentStamp} const userEnv = __filterInternalBinding return withRuntimeEnv(env, () => scheduled(controller, userEnv, ctx)); }`; + // `ForwardableEmailMessage` is not in workerd's RPC BaseType allowlist, so the + // platform router serializes the message to an `EmailEnvelope`, this worker's + // `email()` method rebuilds a proxy through `createMessageProxy`, runs the user + // handler, and returns the recorded `EmailAction[]` for the router to enact. + // + // On the developer's own account Cloudflare calls `email()` with the real + // message and ignores the return value, so the actions would be lost: the + // message is accepted, then silently discarded. The same proxy runs the + // handler there (the native message satisfies the envelope shape, and the + // proxy makes `raw` replayable), and `enactEmailActions` replays what was + // recorded on the real message before returning. + const emailImport = hasEmail ? ', __emailHandler' : ''; + const emailRuntimePath = runtimeEmailPath ?? deriveEmailRuntimePath(runtimeEnvPath); + const emailProxyImport = hasEmail + ? `import { createMessageProxy as __createMessageProxy, enactEmailActions as __enactEmailActions, isForwardableEmailMessage as __isForwardableEmailMessage } from ${JSON.stringify(emailRuntimePath)};\n` + : ''; + // `__VOID_PROJECT_SLUG` is the same binding the outbound `sendEmail` reads to + // default `from`. It is passed to `createMessageProxy` so the user-visible + // `message.to` is the bare `@` rather than the wire form, and to + // the generated `__emailHandler`, which routes on the wire address + // (`__voidPlatformTo`) and strips the `+` prefix iff a slug is set. + const emailHandler = hasEmail + ? remotePath + ? `async function handleEmail(envelope, env, ctx) { +${envValidateCall} if (env.__VOID_REMOTE === "1") env = ${createRemoteEnvCall}; + const userEnv = __filterInternalBindings(env); + const __slug = typeof env.__VOID_PROJECT_SLUG === "string" && env.__VOID_PROJECT_SLUG.length > 0 ? env.__VOID_PROJECT_SLUG : undefined; + const { message, getActions } = __createMessageProxy(envelope, { slug: __slug }); + await __withRawEnv(env, () => withRuntimeEnv(env, () => __emailHandler(message, userEnv, ctx, { slug: __slug }))); + const __actions = getActions(); + if (__isForwardableEmailMessage(envelope)) await __enactEmailActions(envelope, __actions); + return __actions; +}` + : `async function handleEmail(envelope, env, ctx) { +${envValidateCall} const userEnv = __filterInternalBindings(env); + const __slug = typeof env.__VOID_PROJECT_SLUG === "string" && env.__VOID_PROJECT_SLUG.length > 0 ? env.__VOID_PROJECT_SLUG : undefined; + const { message, getActions } = __createMessageProxy(envelope, { slug: __slug }); + await __withRawEnv(env, () => withRuntimeEnv(env, () => __emailHandler(message, userEnv, ctx, { slug: __slug }))); + const __actions = getActions(); + if (__isForwardableEmailMessage(envelope)) await __enactEmailActions(envelope, __actions); + return __actions; +}` + : ''; + + // The default export is a `WorkerEntrypoint` subclass. A plain default object + // only exposes `.fetch()` through a Workers-for-Platforms dispatch stub, so + // `email()` would be unreachable; class methods on a `WorkerEntrypoint` + // subclass become RPC provider methods on that stub. `fetch()` stays + // HTTP-compatible inside the class. + const entryImport = `import { WorkerEntrypoint } from "cloudflare:workers";\n`; + const fetchDeclaration = `const __voidFetch = ${fetchHandler};`; + const queueMethodBody = hasQueues + ? ` async queue(batch) { return __voidQueue(batch, this.env, this.ctx); }\n` + : ''; + const emailMethodBody = hasEmail + ? ` async email(envelope) { return handleEmail(envelope, this.env, this.ctx); }\n` + : ''; + const entryClass = `export default class extends WorkerEntrypoint { + async fetch(request) { return __voidFetch(request, this.env, this.ctx); } + async scheduled(controller) { return handleScheduled(controller, this.env, this.ctx); } +${queueMethodBody}${emailMethodBody}}`; + if (hasQueues) { - return `${sandboxImport}${liveRuntimeImport}import app, { scheduled, __voidCollectPrerenderPaths, __voidHandleInternalRoute, __voidHandleWebSocket, __withRawEnv, __processQueue${webSocketClasses.length > 0 ? `, ${webSocketClasses.join(', ')}` : ''}${liveRouteImport} } from 'virtual:void-routes'; -${durableObjectImports}\n${runtimeEnvImport}${envSchemaImport}${remoteImport}${isrImport}${filterBindingsHelper}${assetHelper} + return `${sandboxImport}${entryImport}${liveRuntimeImport}import app, { scheduled, __voidCollectPrerenderPaths, __voidHandleInternalRoute, __voidHandleWebSocket, __withRawEnv, __processQueue${emailImport}${webSocketClasses.length > 0 ? `, ${webSocketClasses.join(', ')}` : ''}${liveRouteImport} } from 'virtual:void-routes'; +${durableObjectImports}\n${runtimeEnvImport}${emailProxyImport}${envSchemaImport}${remoteImport}${isrImport}${filterBindingsHelper}${assetHelper} ${internalRouteHelper} +${fetchDeclaration} ${scheduledHandler} -async function queue(batch, env, ctx) { +${emailHandler} +async function __voidQueue(batch, env, ctx) { ${envValidateCall}${remotePath ? ` if (env.__VOID_REMOTE === "1") env = ${createRemoteEnvCall};\n` : ''}${isrDeploymentStamp} const userEnv = __filterInternalBindings(env); const messages = [...batch.messages].map(m => ({ id: m.id, timestamp: m.timestamp.toISOString(), @@ -2728,17 +2965,19 @@ ${webSocketClasses.map((name) => `export { ${name} };`).join('\n')} ${liveExport} ${sandboxExport} ${durableObjectExports} -export default { fetch: ${fetchHandler}, scheduled: handleScheduled, queue };`; +${entryClass}`; } - return `${sandboxImport}${liveRuntimeImport}import app, { scheduled, __voidCollectPrerenderPaths, __voidHandleInternalRoute, __voidHandleWebSocket, __withRawEnv${webSocketClasses.length > 0 ? `, ${webSocketClasses.join(', ')}` : ''}${liveRouteImport} } from 'virtual:void-routes'; -${durableObjectImports}\n${runtimeEnvImport}${envSchemaImport}${remoteImport}${isrImport}${filterBindingsHelper}${assetHelper} + return `${sandboxImport}${entryImport}${liveRuntimeImport}import app, { scheduled, __voidCollectPrerenderPaths, __voidHandleInternalRoute, __voidHandleWebSocket, __withRawEnv${emailImport}${webSocketClasses.length > 0 ? `, ${webSocketClasses.join(', ')}` : ''}${liveRouteImport} } from 'virtual:void-routes'; +${durableObjectImports}\n${runtimeEnvImport}${emailProxyImport}${envSchemaImport}${remoteImport}${isrImport}${filterBindingsHelper}${assetHelper} ${internalRouteHelper} +${fetchDeclaration} ${scheduledHandler} +${emailHandler} ${webSocketClasses.map((name) => `export { ${name} };`).join('\n')} ${liveExport} ${sandboxExport} ${durableObjectExports} -export default { fetch: ${fetchHandler}, scheduled: handleScheduled };`; +${entryClass}`; } /** diff --git a/packages/void/src/router/email.ts b/packages/void/src/router/email.ts new file mode 100644 index 000000000..2e4814147 --- /dev/null +++ b/packages/void/src/router/email.ts @@ -0,0 +1,278 @@ +/** + * Scan `email/` for inbound email handlers. + * + * The `email/` directory sits at the project root alongside `crons/` and + * `queues/` — non-HTTP handlers all live at the top level, since each is + * wired into a separate worker export (`scheduled`, `queue`, `email`). + * + * email/.ts per-recipient handler matched against the + * local-part of the To address + * email/+[tag].ts same, with a `+suffix` capture exposed as + * `info.params.` + * email/[name].ts dynamic local-part — captures the whole + * local-part as `info.params.` + * email/[name]+[tag].ts dynamic + tag + * email/_default.ts fallback when no per-address pattern matches + * + * Match precedence (most specific wins): + * static + tag → static → dynamic + tag → dynamic → _default + * Within a kind a literal tag beats a captured tag (`support+vip` before + * `support+[tag]`, `[user]+vip` before `[user]+[tag]`); the same shape sorts + * alphabetically. See `compareEmailRoutes`. + * + * Local-part matching is case-insensitive: the compiled patterns carry the `i` + * flag and run against the recipient in its original case, so a captured tag + * such as a ticket id keeps the case it arrived with. Nested folders are + * intentionally ignored — the recipient address is a single string, not a path. + */ + +import { join } from 'pathslash'; +import { glob, globSync } from 'tinyglobby'; + +const EMAIL_EXTENSIONS = ['ts', 'mts', 'js', 'mjs']; + +/** Token identifies a single segment of the local-part pattern (before/after the `+`). */ +type Segment = { kind: 'literal'; value: string } | { kind: 'param'; name: string }; + +export type EmailPatternKind = 'static' | 'static-tag' | 'dynamic' | 'dynamic-tag'; + +export interface CompiledEmailPattern { + kind: EmailPatternKind; + /** Regex source (anchored). Apply with the `i` flag; see the file header. */ + regexSource: string; + /** Capture-group names in order; empty for `static`. */ + paramNames: Array; +} + +export interface EmailHandlerDefinition { + /** Original basename without extension, e.g. "support" or "support+[ticket]". */ + address: string; + /** Path relative to the email directory, e.g. "support.ts". */ + filePath: string; + /** Compiled match pattern. */ + pattern: CompiledEmailPattern; +} + +export interface EmailHandlersScan { + /** Per-address handlers under email/, sorted by specificity then name. */ + addresses: Array; + /** email/_default. — fires when no per-address pattern matches. */ + defaultHandler?: string; +} + +const PARAM_RE = /^\[([a-z][a-z0-9]*)\]$/; +const LITERAL_RE = /^[a-z0-9._-]+$/; + +function compileSegment(part: string): Segment | null { + const param = PARAM_RE.exec(part); + if (param) { + return { kind: 'param', name: param[1] }; + } + if (!LITERAL_RE.test(part)) { + return null; + } + return { kind: 'literal', value: part }; +} + +function escapeLiteral(value: string): string { + // Of the allowed literal chars `[a-z0-9._-]`, only `.` is regex-meaningful. + return value.replace(/\./g, '\\.'); +} + +function segmentRegex(s: Segment): string { + return s.kind === 'literal' ? escapeLiteral(s.value) : '([^+@]+)'; +} + +function patternKind(prefix: Segment, tag?: Segment): EmailPatternKind { + const prefixDynamic = prefix.kind === 'param'; + if (tag) { + return prefixDynamic ? 'dynamic-tag' : 'static-tag'; + } + return prefixDynamic ? 'dynamic' : 'static'; +} + +/** + * Compile a basename like `support`, `support+[ticket]`, `[user]`, or + * `[user]+[tag]` into a match pattern, or return `null` if the basename + * uses characters or shapes outside the supported grammar. + */ +export function compileEmailPattern(basename: string): CompiledEmailPattern | null { + const lower = basename.toLowerCase(); + // We allow at most one `+` separator (subaddress tag). Anything else is invalid. + const parts = lower.split('+'); + if (parts.length > 2) { + return null; + } + const prefix = compileSegment(parts[0] ?? ''); + if (!prefix) { + return null; + } + let tag: Segment | undefined; + if (parts.length === 2) { + const compiled = compileSegment(parts[1] ?? ''); + if (!compiled) { + return null; + } + tag = compiled; + } + + const paramNames: Array = []; + if (prefix.kind === 'param') { + paramNames.push(prefix.name); + } + if (tag?.kind === 'param') { + paramNames.push(tag.name); + } + if (new Set(paramNames).size !== paramNames.length) { + const dupes = paramNames.filter((n, i) => paramNames.indexOf(n) !== i); + throw new Error( + `email handler "${basename}" has duplicate param names: ${dupes.join(', ')}. Each [name] segment must be unique within a single handler filename.`, + ); + } + + const segments: Array = [segmentRegex(prefix)]; + if (tag) { + segments.push('\\+', segmentRegex(tag)); + } + return { + kind: patternKind(prefix, tag), + regexSource: `^${segments.join('')}$`, + paramNames, + }; +} + +/** + * Kind order: most specific first. static-tag beats static (more + * constraints), dynamic-tag beats dynamic. Matches the precedence list in + * the file header. + */ +const DISPATCH_ORDER: Record = { + 'static-tag': 0, + static: 1, + 'dynamic-tag': 2, + dynamic: 3, +}; + +/** The two fields the dispatch order is computed from. */ +export interface EmailRouteOrderKey { + /** Handler name as written, e.g. "support+[ticket]" (a basename or a harness route key). */ + address: string; + pattern: CompiledEmailPattern; +} + +/** + * The ONE dispatch comparator. The generated `__emailHandler` and the + * `createInboundTestHarness` route table both return on the first pattern + * that matches, so whatever sorts first owns every address it matches. + * + * 1. kind, per `DISPATCH_ORDER` + * 2. fewer captures first — `support+vip` and `support+[tag]` are both + * `static-tag` and both match `support+vip@`; the literal tag must be + * tried first or the exact handler is dead code. Same for + * `[user]+vip` over `[user]+[tag]`. + * 3. alphabetical, so the table is deterministic across filesystems. + * + * Two patterns with the same regex source share a kind and a capture count, + * so the collision check below can still name the alphabetically-first one. + */ +export function compareEmailRoutes(a: EmailRouteOrderKey, b: EmailRouteOrderKey): number { + const kind = DISPATCH_ORDER[a.pattern.kind] - DISPATCH_ORDER[b.pattern.kind]; + if (kind !== 0) { + return kind; + } + const captures = a.pattern.paramNames.length - b.pattern.paramNames.length; + if (captures !== 0) { + return captures; + } + return a.address.localeCompare(b.address); +} + +function parseEmailFiles(files: Array): { + addresses: Array; + defaultHandler?: string; +} { + const addresses: Array = []; + let defaultHandler: string | undefined; + + for (const filePath of files) { + if (filePath.includes('/')) { + continue; + } + const basename = filePath.replace(/\.[^.]+$/, ''); + // `_default` is a reserved filename and is matched exactly: `_Default.ts` + // or `_default.test.ts` fall through to the `_`-prefix skip below like + // any other private helper. Two `_default.` files (e.g. `.ts` next + // to `.js` mid-migration) would otherwise resolve to whichever the glob + // returned last — readdir order, not stable across filesystems — with + // the other silently unbundled. Reject, mirroring the regex-collision + // check on per-address handlers below. + if (basename === '_default') { + if (defaultHandler !== undefined) { + throw new Error( + `email handler "${filePath}" is a second default handler next to "${defaultHandler}" — keep exactly one email/_default.* file.`, + ); + } + defaultHandler = filePath; + continue; + } + if (basename.startsWith('_')) { + continue; + } + const pattern = compileEmailPattern(basename); + if (!pattern) { + continue; + } + addresses.push({ + address: basename.toLowerCase(), + filePath, + pattern, + }); + } + + addresses.sort(compareEmailRoutes); + + // Two handlers can compile to the same regex source — the obvious case + // is `[user].ts` and `[other].ts` both producing `^([^+@]+)$`. After + // alphabetical sort, the first one wins for every delivery and the + // others are dead code. Reject at scan time with a clear error rather + // than letting the deployment ship a silent footgun. + const seenRegex = new Map(); + for (const def of addresses) { + const prior = seenRegex.get(def.pattern.regexSource); + if (prior !== undefined) { + throw new Error( + `email handler "${def.filePath}" matches the same address shape as "${prior}" (both compile to ${def.pattern.regexSource}). Rename one of them — only the alphabetically-first would ever fire.`, + ); + } + seenRegex.set(def.pattern.regexSource, def.filePath); + } + + return { addresses, defaultHandler }; +} + +export async function scanEmailHandlers( + root: string, + emailDir = 'email', +): Promise { + const pattern = `*.{${EMAIL_EXTENSIONS.join(',')}}`; + const files = await glob(pattern, { + cwd: join(root, emailDir), + absolute: false, + }).catch(() => [] as Array); + return parseEmailFiles(files); +} + +export function scanEmailHandlersSync(root: string, emailDir = 'email'): EmailHandlersScan { + const pattern = `*.{${EMAIL_EXTENSIONS.join(',')}}`; + let files: Array; + try { + files = globSync(pattern, { cwd: join(root, emailDir), absolute: false }); + } catch { + files = []; + } + return parseEmailFiles(files); +} + +export function hasEmailHandlers(scan: EmailHandlersScan): boolean { + return scan.addresses.length > 0 || Boolean(scan.defaultHandler); +} diff --git a/packages/void/src/router/scan.ts b/packages/void/src/router/scan.ts index 8d245c04b..2266a0a65 100644 --- a/packages/void/src/router/scan.ts +++ b/packages/void/src/router/scan.ts @@ -6,6 +6,7 @@ import type { PageScanResult } from '../pages/types.ts'; import { readProjectPaths, type ProjectPaths } from '../project-paths.ts'; import { scanQueues, type QueueDefinition } from '../queues/scan.ts'; import { detectHttpExports } from './detect-exports.ts'; +import { scanEmailHandlers, type EmailHandlersScan } from './email.ts'; import { isRouteEnabledForEnvironment, parseRouteFilename, @@ -25,6 +26,7 @@ export type ScanResult = { jobs: Array; queues: Array; pages: PageScanResult; + email?: EmailHandlersScan; }; export type MiddlewareDefinition = { @@ -85,7 +87,7 @@ export async function scanRoutes( }, ): Promise { const paths = options?.paths ?? readProjectPaths(root); - const [routeFiles, websockets, middlewareFiles, jobs, queues, pages] = await Promise.all([ + const [routeFiles, websockets, middlewareFiles, jobs, queues, pages, email] = await Promise.all([ glob(ROUTE_PATTERN, { cwd: paths.routesDir, absolute: false }), scanWebSocketRoutes(root, paths, options?.environment), glob(ROUTE_PATTERN, { cwd: paths.middlewareDir, absolute: false }).catch( @@ -94,6 +96,11 @@ export async function scanRoutes( scanJobs(root, paths.cronsDir), scanQueues(root, paths.queuesDir), scanPages(root, { output: options?.output, paths }), + // `email/` is a source convention like `crons/` and `queues/`, so it lives + // under `sourceRoot` — which is `root` when no `sourceDir` is configured. + // `scanEmailHandlers` joins its own `email` segment, so this resolves to + // `paths.emailDir`. + scanEmailHandlers(paths.sourceRoot), ]); const routesDir = paths.routesDir; @@ -114,7 +121,7 @@ export async function scanRoutes( // Parse middleware, sorted by numeric prefix const middleware = parseMiddlewareDefinitions(middlewareFiles); - return { routes, websockets, middleware, jobs, queues, pages }; + return { routes, websockets, middleware, jobs, queues, pages, email }; } export function scanMiddlewareSync( diff --git a/packages/void/src/runtime/email.ts b/packages/void/src/runtime/email.ts new file mode 100644 index 000000000..d6fdac4d3 --- /dev/null +++ b/packages/void/src/runtime/email.ts @@ -0,0 +1,962 @@ +// void/email — outbound email via the platform proxy or a direct binding. +// +// On the managed platform, user worker code never holds a `send_email` +// binding directly. The framework builds MIME locally, then POSTs to +// `__VOID_PROXY/email/send`. The proxy worker holds the platform-level +// `send_email` binding and enforces per-project quotas, suspend kill-switch, +// and verified-destinations allowlist (mirrored from `email_destinations` +// into KV by the API on every CRUD). +// +// On the developer's own Cloudflare account (`void deploy --platform +// cloudflare`) there is no proxy: the deploy provisions a `send_email` +// binding named `SEND_EMAIL` on the worker itself and the same MIME goes +// straight through `cloudflare:email`, one `EmailMessage` per recipient. +// Transports are tried in order: dev inbox, `SEND_EMAIL`, `__VOID_PROXY`. +// +// In `void dev` (no `__VOID_PROXY`) sends are captured by the dev inbox; +// pass `sendInDev: true` for a single call to bypass. The dev inbox buffer +// lives in the Vite dev server's NODE process while this code runs in +// workerd, so the capture is an explicit HTTP hop over the dev assets +// binding — see `resolveDevInbox` below and `plugin-email-dev-inbox.ts`. + +import { + captureMessage, + DEV_INBOX_ACK_HEADER, + DEV_INBOX_ACK_VALUE, + DEV_INBOX_CAPTURE_URL, + DEV_INBOX_ENV_VAR, + DEV_INBOX_TOKEN_ENV_VAR, + DEV_INBOX_TOKEN_HEADER, + dispatchToSinks, + hasActiveSink, + serializeCapturedMessage, + type CapturedMessage, +} from './email/dev-inbox.ts'; +import { buildMime, MimeError, normalizeEmail } from './email/mime.ts'; +import type { + NormalizedEmail, + SendEmailDelivery, + SendEmailErrorCode, + SendEmailError, + SendEmailOptions, + SendEmailRecipientResult, + SendEmailResult, +} from './email/types.ts'; +import { getRawRuntimeEnv } from './env-raw.ts'; + +export type { + Address, + Attachment, + NormalizedEmail, + SendEmailDelivery, + SendEmailError, + SendEmailErrorCode, + SendEmailOptions, + SendEmailRecipientResult, + SendEmailResult, +} from './email/types.ts'; + +export { + createMessageProxy, + defineEmail, + enactEmailActions, + isForwardableEmailMessage, + parseEmail, + ParseEmailError, + replyEmail, +} from './email/inbound.ts'; +export type { + EmailAction, + EmailEnvelope, + EmailHandler, + EmailHandlerInfo, + MessageProxy, + ParsedEmail, + ParseEmailErrorCode, + ReplyEmailOptions, +} from './email/inbound.ts'; + +// `createEmail` below throws `MimeError`, and `sendEmail` reports its `code`. +// The barrel is the only supported entry point (`package.json` exposes +// `./email` and `./email/testing`, with no wildcard subpath), so the error +// classes have to ship with the functions that throw them. +export { MimeError } from './email/mime.ts'; +export type { MimeErrorCode } from './email/mime.ts'; + +// Slug shape we'll accept when substituting into the default `from`. The +// platform owns slug allocation and validates at create-time, but we don't +// trust the value blindly — a slug containing `@`, `+`, or whitespace +// would corrupt the resulting address. RFC 5321/2 atom char-class +// restricted further to lowercase + digits + hyphen, matching the typical +// project-slug shape. +// +// Length cap: 56 chars. The default `from` we build is +// `+noreply@<__VOID_EMAIL_DOMAIN>`; the SMTP local-part limit +// (RFC 5321 § 4.5.3.1.1) is 64 octets, and `+noreply` already eats 8. +// Capping the slug at `64 - 8 = 56` keeps the assembled local-part +// within spec. The cap is domain-independent — only the local-part is +// bounded here. +const VALID_SLUG = /^[a-z0-9](?:[a-z0-9-]{0,54}[a-z0-9])?$/; + +function makeError(code: SendEmailErrorCode, message: string, cause?: unknown): SendEmailError { + return cause === undefined ? { code, message } : { code, message, cause }; +} + +// `getRawRuntimeEnv()` throws outside a worker/SSR context. `sendEmail` needs +// graceful degradation in those settings (local Node tools, tests), so resolve +// once via this helper. +function tryGetRawRuntimeEnv(): Record | undefined { + try { + return getRawRuntimeEnv(); + } catch { + return undefined; + } +} + +function readProjectSlug(): string | undefined { + const raw = tryGetRawRuntimeEnv()?.__VOID_PROJECT_SLUG; + if (typeof raw !== 'string' || raw.length === 0) { + return undefined; + } + // Defense-in-depth: refuse to substitute an obviously malformed slug + // into the address. Returning undefined causes the caller to surface + // `INVALID_FROM` rather than build a corrupt envelope. + return VALID_SLUG.test(raw) ? raw : undefined; +} + +// The platform mail domain for this environment (production uses +// `mail.void.cloud`, staging its own zone). It is injected per +// deployment rather than baked in, so a staging send is not rejected by +// the proxy's sender check. There is deliberately no built-in default: +// silently falling back to the production zone is the bug this binding +// exists to prevent. +function readEmailDomain(): string | undefined { + const raw = tryGetRawRuntimeEnv()?.__VOID_EMAIL_DOMAIN; + return typeof raw === 'string' && raw.length > 0 ? raw : undefined; +} + +// The project's own default sender, written into the worker's `vars` by +// `void deploy --platform cloudflare` from `email.from` in void.json. It is a +// full address on a zone the developer owns, so there is no slug to +// substitute; `normalizeEmail` validates it like any explicit `from`. +function readEmailFrom(): string | undefined { + const raw = tryGetRawRuntimeEnv()?.__VOID_EMAIL_FROM; + return typeof raw === 'string' && raw.length > 0 ? raw : undefined; +} + +interface ProxyEnv { + __VOID_PROXY?: { fetch(req: Request | string, init?: RequestInit): Promise }; + __VOID_PROXY_TOKEN?: string; + __PROJECT_ID?: string; +} + +// The `send_email` binding `void deploy --platform cloudflare` provisions +// (`cli/email-cloudflare.ts` writes the same name into wrangler.jsonc). +const SEND_EMAIL_BINDING_NAME = 'SEND_EMAIL'; + +// The legacy `EmailMessage` overload of `SendEmail.send`. Only `messageId` +// is read, and defensively: some builds have returned nothing. +interface SendEmailBinding { + send(message: EmailMessage): Promise<{ messageId?: unknown } | undefined | void>; +} + +function resolveSendEmailBinding( + env: Record | undefined, +): SendEmailBinding | undefined { + const binding = env?.[SEND_EMAIL_BINDING_NAME]; + if (typeof binding !== 'object' || binding === null) { + return undefined; + } + return typeof (binding as { send?: unknown }).send === 'function' + ? (binding as SendEmailBinding) + : undefined; +} + +/** + * Map an error thrown by the `send_email` binding to a framework code. + * + * Email Service throws `Error`s carrying a `code` (the `E_*` table in + * Cloudflare's Workers API reference); the older Email Routing sender + * throws message-only errors, so the destination-verification phrasing the + * platform proxy already keys on is kept as a fallback. Anything else is + * `UPSTREAM_ERROR` with the original error as `cause` — the framework has + * no way to act on a delivery failure or a service outage beyond + * reporting it. + * + * The platform proxy's `classifyUpstream` mirrors this table for its own + * binding, except the two sender codes: there `from` is pinned to the + * project sender, so a sender failure is the platform's to fix and stays + * `UPSTREAM_ERROR`. Keep the recipient and limit rows in lockstep. + */ +function classifyBindingError(e: unknown): SendEmailError { + const message = e instanceof Error ? e.message : String(e); + const code = + typeof (e as { code?: unknown })?.code === 'string' ? (e as { code: string }).code : ''; + switch (code) { + case 'E_RECIPIENT_NOT_ALLOWED': + return makeError('UNVERIFIED_DESTINATION', message, e); + case 'E_SENDER_NOT_VERIFIED': + case 'E_SENDER_DOMAIN_NOT_AVAILABLE': + return makeError('INVALID_FROM', message, e); + case 'E_RATE_LIMIT_EXCEEDED': + case 'E_DAILY_LIMIT_EXCEEDED': + return makeError('QUOTA_EXCEEDED', message, e); + default: + break; + } + if (UNVERIFIED_DESTINATION_RE.test(message)) { + return makeError('UNVERIFIED_DESTINATION', message, e); + } + return makeError('UPSTREAM_ERROR', message, e); +} + +// Anchored to Cloudflare's own phrasing ("destination address … not (yet) +// verified"), with bounded runs so a long preamble or a multi-line stack +// cannot drag an unrelated mention of the address into a false positive. +// Mirrors `classifyUpstream` in the platform proxy. +const UNVERIFIED_DESTINATION_RE = + /^[^\n]{0,200}destination address[^\n]{0,200}not (?:yet )?verified/i; + +function bytesToReadableStream(bytes: Uint8Array): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +/** + * Deliver through the worker's own `SEND_EMAIL` binding. One `EmailMessage` + * per recipient, every recipient attempted, and the same `deliveries` shape + * the proxy returns so a caller retries the failed ones the same way in both + * modes. The stream is rebuilt per send because a `ReadableStream` is + * one-shot. + */ +async function sendViaBinding( + binding: SendEmailBinding, + from: string, + recipients: ReadonlyArray, + raw: Uint8Array, + fallbackId: string, +): Promise { + let EmailMessage: (typeof import('cloudflare:email'))['EmailMessage']; + try { + ({ EmailMessage } = await import('cloudflare:email')); + } catch (e) { + return err( + 'UPSTREAM_ERROR', + `email: SEND_EMAIL is bound but cloudflare:email could not be loaded: ${e instanceof Error ? e.message : String(e)}`, + e, + ); + } + const deliveries: Array = []; + for (const recipient of recipients) { + try { + const result = await binding.send( + new EmailMessage(from, recipient, bytesToReadableStream(raw)), + ); + const mid = typeof result?.messageId === 'string' ? result.messageId : ''; + deliveries.push({ recipient, ok: true, messageId: mid || fallbackId }); + } catch (e) { + deliveries.push({ recipient, ok: false, error: classifyBindingError(e) }); + } + } + if (deliveries.some((d) => !d.ok)) { + return { ok: false, deliveries }; + } + return { + ok: true, + ids: deliveries.map((d) => ({ + recipient: d.recipient, + messageId: (d as { messageId: string }).messageId, + })), + }; +} + +/** + * Dedup recipients by lowercased email (CF won't collapse duplicate + * envelopes for us, and double-billing the quota for the same recipient + * is worse than the rare case where a user actually wanted Cc + Bcc to + * the same address — which doesn't change delivery semantics anyway). + */ +function uniqueRecipients(normalized: NormalizedEmail): Array { + const seen = new Set(); + const recipients: Array = []; + for (const r of [...normalized.to, ...normalized.cc, ...normalized.bcc]) { + const lower = r.email.toLowerCase(); + if (seen.has(lower)) { + continue; + } + seen.add(lower); + recipients.push(lower); + } + return recipients; +} + +function newRequestId(): string { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + let hex = ''; + for (const b of bytes) { + hex += b.toString(16).padStart(2, '0'); + } + return `req_${Date.now().toString(36)}${hex}`; +} + +function resolveDefaultFrom(opts: SendEmailOptions): SendEmailOptions { + const supplied = opts.from; + // Treat literal `null` the same as undefined here. Passing `null` + // through to `normalizeEmail` makes `parseAddress` throw a TypeError + // that gets surfaced as the opaque MIME_ERROR code; callers expect + // INVALID_FROM for "from is missing/empty". + const isEmpty = + supplied === undefined || + supplied === null || + (typeof supplied === 'string' && supplied.trim() === '') || + (typeof supplied === 'object' && supplied !== null && !('email' in supplied)); + if (!isEmpty) { + return opts; + } + // `__VOID_EMAIL_FROM` (own-account deploys) wins over the platform's + // `+noreply@`: a project that carries both is one whose + // void.json names a sender on purpose. + const configured = readEmailFrom(); + if (configured) { + return { ...opts, from: configured }; + } + const slug = readProjectSlug(); + const domain = readEmailDomain(); + if (!slug || !domain) { + // Strip the empty value so the downstream `from === undefined` check + // surfaces a clean INVALID_FROM rather than passing `null` along. + if (supplied === null) { + const next = { ...opts }; + delete (next as { from?: unknown }).from; + return next; + } + return opts; + } + return { ...opts, from: `${slug}+noreply@${domain}` }; +} + +function err(code: SendEmailErrorCode, message: string, cause?: unknown): SendEmailResult { + return { ok: false, error: makeError(code, message, cause) }; +} + +function bytesToBase64(bytes: Uint8Array): string { + // 8KB chunks keep us well under the typical V8 fromCharCode arg limit. + const CHUNK = 0x2000; + let binary = ''; + for (let i = 0; i < bytes.byteLength; i += CHUNK) { + const slice = bytes.subarray(i, Math.min(i + CHUNK, bytes.byteLength)); + binary += String.fromCharCode(...slice); + } + return btoa(binary); +} + +interface ProxySendBody { + projectId: string; + /** + * HMAC of `projectId` under the shared `JWT_SECRET`. The proxy currently + * reads this under the legacy `aiToken` key (auth was originally written + * for the AI proxy). New `proxyToken` field below ships the same value + * under the renamed key so the proxy can migrate without a coordinated + * release; once every deployed proxy reads `proxyToken`, drop `aiToken`. + */ + aiToken: string; + proxyToken: string; + from: string; + recipients: Array; + mimeBase64: string; + byteSize: number; + requestId: string; + fromDomain: string; +} + +interface ProxyOkResponse { + ok: true; + ids: Array; +} +interface ProxyPreflightFailure { + ok: false; + error: { code: SendEmailErrorCode | string; message: string }; +} +interface ProxyMidBatchFailure { + ok: false; + deliveries: Array<{ + recipient: string; + ok: boolean; + messageId?: string; + error?: { code: SendEmailErrorCode | string; message: string }; + }>; +} +type ProxyResponse = ProxyOkResponse | ProxyPreflightFailure | ProxyMidBatchFailure; + +function coerceErrorCode(code: unknown): SendEmailErrorCode { + const known: Array = [ + 'BINDING_MISSING', + 'INVALID_FROM', + 'INVALID_TO', + 'UNVERIFIED_DESTINATION', + 'MIME_ERROR', + 'QUOTA_EXCEEDED', + 'UPSTREAM_ERROR', + ]; + return known.includes(code as SendEmailErrorCode) + ? (code as SendEmailErrorCode) + : 'UPSTREAM_ERROR'; +} + +/** + * The text of a refusal the proxy's middleware sent instead of a send + * response: `{ error: string, message?: string }`. `message` is the + * sentence written for a person (the capability gate's "This Void platform + * does not enable email."); `error` is the short code every such refusal + * carries. `null` when the body is not that shape. + */ +function readProxyRefusal(body: unknown): string | null { + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return null; + } + const { error, message } = body as { error?: unknown; message?: unknown }; + if (typeof error !== 'string' || error === '') { + return null; + } + return typeof message === 'string' && message !== '' ? message : error; +} + +// Discriminator + shape check that runs before we destructure. The proxy +// response shape is well-defined, but treating the parsed body as +// `unknown` here lets us short-circuit to a clean UPSTREAM_ERROR for any +// malformed payload (null, primitive, missing discriminator, wrong +// array types) instead of throwing a TypeError mid-map. +function isValidProxyResponse(b: unknown): b is ProxyResponse { + if (typeof b !== 'object' || b === null) { + return false; + } + const r = b as Record; + if (r.ok === true && Array.isArray(r.ids)) { + return true; + } + if (r.ok === false && typeof r.error === 'object' && r.error !== null) { + return true; + } + if (r.ok === false && Array.isArray(r.deliveries)) { + return true; + } + return false; +} + +type RecipientListRead = { ok: true; entries: Array } | { ok: false; reason: string }; + +/** + * Validate a per-recipient list from the proxy against the recipients this + * send asked for. Both list-bearing responses go through here — `ids` on + * `ok: true` and `deliveries` on a mid-batch `ok: false` — so the two cannot + * drift: the proxy builds both from the same per-recipient list, one entry + * per requested address, and a caller following the documented retry + * pattern (retry only `ok: false` entries) can act on an outcome only if + * every recipient has one. + * + * The rules shared by both shapes: the count equals the recipients sent; + * every entry is an object naming a requested recipient exactly once. The + * list is compared as a set against the unique, lowercased recipients the + * request carried and returned in the order the proxy gave it — order is + * not part of the contract (the proxy lists allowlist rejections before the + * sends it attempted). `readEntry` decides the rest of the entry's shape and + * returns `undefined` when it does not hold; `shape` names that shape in the + * reason. + * + * Anything else is a schema-skewed or corrupt proxy and is refused as a + * whole rather than trimmed to the entries that parse: a dropped entry is a + * recipient whose outcome the caller can neither trust nor retry. Entries + * are named by index only — the body is attached as `cause`, and a recipient + * string from a corrupt response is not a value to interpolate into an + * error message. + */ +function readRecipientList( + raw: Array, + recipients: ReadonlyArray, + shape: string, + readEntry: (entry: Record, recipient: string) => T | undefined, +): RecipientListRead { + if (raw.length !== recipients.length) { + return { ok: false, reason: `expected ${recipients.length} deliveries, got ${raw.length}` }; + } + const requested = new Set(recipients); + const seen = new Set(); + const entries: Array = []; + for (const [i, d] of raw.entries()) { + const entry = typeof d === 'object' && d !== null ? (d as Record) : null; + const recipient = entry?.recipient; + if (entry === null || typeof recipient !== 'string') { + return { ok: false, reason: `entry ${i} is not ${shape}` }; + } + const value = readEntry(entry, recipient); + if (value === undefined) { + return { ok: false, reason: `entry ${i} is not ${shape}` }; + } + if (!requested.has(recipient)) { + return { ok: false, reason: `entry ${i} names a recipient that was not requested` }; + } + if (seen.has(recipient)) { + return { ok: false, reason: `entry ${i} repeats a recipient` }; + } + seen.add(recipient); + entries.push(value); + } + return { ok: true, entries }; +} + +/** + * The `ids` list of an `ok: true` response. The proxy answers `ok: true` + * only once every requested recipient passed its allowlist and + * `SEND_EMAIL.send()` confirmed each one, so every entry is + * `{ recipient, messageId }` with `messageId` always a string (`''` when + * Cloudflare returned none; that is the proxy's explicit convention, and the + * framework's own Message-ID stands in for it). An `ok: true` with a + * delivery missing would tell the caller a send was confirmed when it never + * was, and a success carries no per-recipient failure to retry from. + */ +function readDeliveryList( + raw: Array, + recipients: ReadonlyArray, + fallbackId: string, +): RecipientListRead { + return readRecipientList( + raw, + recipients, + '{ recipient: string, messageId: string }', + (entry, recipient) => + typeof entry.messageId === 'string' + ? { recipient, messageId: entry.messageId || fallbackId } + : undefined, + ); +} + +/** + * The `deliveries` list of a mid-batch `ok: false` response. Each entry is + * either a confirmed send (`ok: true`, `messageId` a string with the same + * `''` convention as above) or a failure (`ok: false`, `error` with a string + * `message`; the code is coerced, unknown values become `UPSTREAM_ERROR`). + * Both are the proxy's own outcome for that recipient; `ok` is what the + * caller retries on, so an entry that does not state it is not an outcome + * at all — treating it as failed would retry a recipient that may have been + * delivered, and treating it as delivered would silently lose one. + */ +function readFailureDeliveries( + raw: Array, + recipients: ReadonlyArray, + fallbackId: string, +): RecipientListRead { + return readRecipientList( + raw, + recipients, + '{ recipient: string, ok: true, messageId: string } | { recipient: string, ok: false, error: { message: string } }', + (entry, recipient) => { + if (entry.ok === true) { + return typeof entry.messageId === 'string' + ? { recipient, ok: true, messageId: entry.messageId || fallbackId } + : undefined; + } + if (entry.ok === false) { + const error = entry.error as { code?: unknown; message?: unknown } | null | undefined; + return typeof error === 'object' && error !== null && typeof error.message === 'string' + ? { recipient, ok: false, error: makeError(coerceErrorCode(error.code), error.message) } + : undefined; + } + return undefined; + }, + ); +} + +async function readResponseAsResult( + res: Response, + recipients: ReadonlyArray, + fallbackId: string, +): Promise { + try { + let body: unknown; + try { + body = await res.json(); + } catch { + return err('UPSTREAM_ERROR', `email: proxy returned ${res.status} with non-JSON body`); + } + if (!isValidProxyResponse(body)) { + // The proxy's own middleware — auth, platform lifecycle, capability + // gates — answers ahead of the email handler with a flat + // `{ error: string, message?: string }` and a non-2xx status. That + // is a well-formed refusal, not a malformed send response, so carry + // its text through the way the handler's `{ ok: false, error }` is + // carried above; a platform without the email module otherwise reads + // as "malformed response (status 501)" with the reason buried in + // `cause`. + const refusal = readProxyRefusal(body); + if (!res.ok && refusal !== null) { + return err('UPSTREAM_ERROR', `email: proxy returned ${res.status}: ${refusal}`, body); + } + return err( + 'UPSTREAM_ERROR', + `email: proxy returned malformed response (status ${res.status})`, + body, + ); + } + // Both list-bearing shapes are validated as a whole against the + // recipients this send asked for (`ids` / `deliveries` are `unknown[]` + // from the typeguard's perspective). + const malformedList = (reason: string): SendEmailResult => + err('UPSTREAM_ERROR', `email: proxy returned a malformed delivery list (${reason})`, body); + if (body.ok === true) { + const list = readDeliveryList(body.ids as Array, recipients, fallbackId); + return list.ok ? { ok: true, ids: list.entries } : malformedList(list.reason); + } + // Treat the body as a flat record from here — the discriminated + // union narrowing fights the `unknown`-derived branches and adds + // little value once we've validated the shape via isValidProxyResponse. + const failure = body as unknown as Record; + if ('error' in failure && failure.error) { + const e = failure.error as { code?: unknown; message?: unknown }; + return err( + coerceErrorCode(e.code), + typeof e.message === 'string' ? e.message : 'email: upstream send failed', + ); + } + // Mid-batch — per-recipient outcomes, one per requested recipient, so + // the caller can retry exactly the failed ones. + const list = readFailureDeliveries( + failure.deliveries as Array, + recipients, + fallbackId, + ); + return list.ok ? { ok: false, deliveries: list.entries } : malformedList(list.reason); + } catch (e) { + // Defense-in-depth: any unexpected error parsing the proxy response + // becomes a clean UPSTREAM_ERROR rather than escaping `sendEmail` as + // an unhandled rejection. + return err( + 'UPSTREAM_ERROR', + `email: failed to parse proxy response (status ${res.status}): ${e instanceof Error ? e.message : String(e)}`, + e, + ); + } +} + +interface DevInboxFetcher { + fetch(req: Request | string, init?: RequestInit): Promise; +} + +interface DevInbox { + fetcher: DevInboxFetcher; + /** + * The dev-trigger token the inbox middleware requires. Injected under the + * same gate as the binding var; when it is somehow absent the POST still + * goes out and the dev server's 401 is reported verbatim — the inbox is + * never skipped in favour of a real send transport. + */ + token: string | undefined; +} + +/** + * Resolve the loopback into the `void dev` inbox, or `undefined` outside dev. + * + * `DEV_INBOX_ENV_VAR` is injected only when `viteCommand === 'serve'` and it + * names the worker's assets binding. In dev, `@cloudflare/vite-plugin` binds + * that name to a miniflare service whose implementation is the Vite dev + * server's Node middleware stack, so a fetch through it lands on the inbox + * middleware in the dev-server process — the only place the buffer exists. + * In a production build neither the var nor that wiring is present. + */ +function resolveDevInbox(env: Record | undefined): DevInbox | undefined { + const bindingName = env?.[DEV_INBOX_ENV_VAR]; + if (typeof bindingName !== 'string' || bindingName.length === 0) { + return undefined; + } + const binding = env?.[bindingName]; + if (typeof binding !== 'object' || binding === null) { + return undefined; + } + if (typeof (binding as { fetch?: unknown }).fetch !== 'function') { + return undefined; + } + const token = env?.[DEV_INBOX_TOKEN_ENV_VAR]; + return { + fetcher: binding as DevInboxFetcher, + token: typeof token === 'string' && token.length > 0 ? token : undefined, + }; +} + +// A local loopback answers immediately; this only exists so a wedged dev +// server degrades into an error instead of hanging the user's request. +const DEV_INBOX_TIMEOUT_MS = 5000; + +/** Bound on the error text a failed capture echoes from the dev server. */ +const DEV_INBOX_ERROR_EXCERPT_CHARS = 200; +const DEV_INBOX_ERROR_EXCERPT_TIMEOUT_MS = 1000; + +/** + * First line of a refusal, so a 401 names the header it wanted. Reads ONE + * chunk under its own deadline: the main 5s race has already settled by the + * time the status is known, and a body that never arrives must not turn a + * clean error into a hung `sendEmail`. + */ +async function readErrorExcerpt(res: Response): Promise { + let timer: ReturnType | undefined; + try { + const reader = res.body?.getReader(); + if (!reader) { + return ''; + } + const first = await Promise.race([ + reader.read().then(({ value }) => value), + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), DEV_INBOX_ERROR_EXCERPT_TIMEOUT_MS); + }), + ]); + void reader.cancel().catch(() => {}); + if (!first) { + return ''; + } + return new TextDecoder() + .decode(first) + .trim() + .split('\n')[0] + .slice(0, DEV_INBOX_ERROR_EXCERPT_CHARS); + } catch { + return ''; + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} + +async function postToDevInbox( + inbox: DevInbox, + captured: CapturedMessage, +): Promise<{ ok: true } | { ok: false; reason: string }> { + let timer: ReturnType | undefined; + try { + const attempt = Promise.resolve( + inbox.fetcher.fetch(`http://localhost${DEV_INBOX_CAPTURE_URL}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(inbox.token !== undefined && { [DEV_INBOX_TOKEN_HEADER]: inbox.token }), + }, + body: serializeCapturedMessage(captured), + }), + ).then( + (res) => ({ kind: 'response' as const, res }), + (cause: unknown) => ({ kind: 'error' as const, cause }), + ); + const settled = await Promise.race([ + attempt, + new Promise<{ kind: 'timeout' }>((resolve) => { + timer = setTimeout(() => resolve({ kind: 'timeout' }), DEV_INBOX_TIMEOUT_MS); + }), + ]); + if (settled.kind === 'timeout') { + return { ok: false, reason: `no response within ${DEV_INBOX_TIMEOUT_MS}ms` }; + } + if (settled.kind === 'error') { + const cause = settled.cause; + return { ok: false, reason: cause instanceof Error ? cause.message : String(cause) }; + } + const { res } = settled; + if (!res.ok) { + // A 401 names the header it wanted; surface that instead of a bare + // status so a missing token is diagnosable from the worker's console. + const excerpt = await readErrorExcerpt(res); + return { + ok: false, + reason: `dev server responded ${res.status}${excerpt ? `: ${excerpt}` : ''}`, + }; + } + // A 200 alone is not proof: if the assets binding ever stops being a + // dev-server loopback, a static/SPA response is also a 200. + if (res.headers.get(DEV_INBOX_ACK_HEADER) !== DEV_INBOX_ACK_VALUE) { + return { ok: false, reason: 'response did not come from the dev inbox' }; + } + return { ok: true }; + } catch (e) { + return { ok: false, reason: e instanceof Error ? e.message : String(e) }; + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} + +/** + * Shared result shape for both capture paths (in-process sink and dev inbox). + * + * `ids` is built from the same deduplicated, lowercased list the two + * production transports send to, so code written against a captured result + * sees exactly what a deploy returns: one entry per unique address, keyed the + * way `sendViaBinding` and the proxy report it. Mapping every `to`/`cc`/`bcc` + * occurrence instead handed a test or `void dev` handler one success per + * occurrence in its original case — deliveries that never happen once + * deployed. The captured message itself keeps the original fields; the + * inbox displays what was asked for, the result reports what would be sent. + */ +function capturedResult(normalized: NormalizedEmail, captureId: string): SendEmailResult { + const recipients = uniqueRecipients(normalized); + if (recipients.length === 0) { + return err('INVALID_TO', "email: 'to' must include at least one recipient."); + } + return { + ok: true, + ids: recipients.map((recipient) => ({ recipient, messageId: captureId })), + }; +} + +/** + * Send an email via the Void platform proxy. + * + * During `void dev` the message is captured by the local inbox + * (`/__void/inbox`) and never reaches the proxy; an in-process sink + * (`void/email/testing`) takes precedence over it. Pass `sendInDev: true` to + * override for a single call. + */ +export async function sendEmail(opts: SendEmailOptions): Promise { + const resolvedOpts = resolveDefaultFrom(opts); + if (resolvedOpts.from === undefined) { + return err( + 'INVALID_FROM', + "email: 'from' is required. Set it explicitly or deploy via Void so the platform-injected __VOID_PROJECT_SLUG and __VOID_EMAIL_DOMAIN can default it to +noreply@.", + ); + } + + // `from` is now guaranteed defined by the check above; the cast keeps + // normalizeEmail's stricter signature without widening it. + const optsWithFrom = resolvedOpts as SendEmailOptions & { + from: NonNullable; + }; + + let normalized: NormalizedEmail; + try { + normalized = await normalizeEmail(optsWithFrom); + } catch (e) { + if (e instanceof MimeError) { + return err(e.code, e.message, e); + } + return err('MIME_ERROR', e instanceof Error ? e.message : String(e), e); + } + + let raw: Uint8Array; + let messageId: string; + try { + const built = buildMime(normalized); + raw = built.raw; + messageId = built.messageId; + } catch (e) { + if (e instanceof MimeError) { + return err(e.code, e.message, e); + } + return err('MIME_ERROR', e instanceof Error ? e.message : String(e), e); + } + + const rawEnv = tryGetRawRuntimeEnv(); + + // Each sink owns its storage; sendEmail must NOT also push to the dev + // buffer or every dev send ends up captured twice. This path only fires + // for a send that runs in the SAME isolate as the sink — the test harness, + // or a prerender pass inside the dev server's own process. + if (!opts.sendInDev && hasActiveSink()) { + const captured = captureMessage(normalized, raw, messageId); + dispatchToSinks(captured); + return capturedResult(normalized, captured.id); + } + + // `void dev`: the inbox buffer lives in the Vite dev server's Node process, + // which this isolate has no shared memory with. Hand the message over. + const devInbox = opts.sendInDev ? undefined : resolveDevInbox(rawEnv); + if (devInbox) { + const captured = captureMessage(normalized, raw, messageId); + const delivered = await postToDevInbox(devInbox, captured); + if (delivered.ok) { + return capturedResult(normalized, captured.id); + } + const message = `email: the void dev inbox at ${DEV_INBOX_CAPTURE_URL} is unreachable (${delivered.reason}), so nothing was captured and nothing was sent.`; + console.warn(`[void/email] ${message}`); + return err('UPSTREAM_ERROR', message); + } + + const recipients = uniqueRecipients(normalized); + if (recipients.length === 0) { + return err('INVALID_TO', "email: 'to' must include at least one recipient."); + } + + // Own-account deploy: the worker holds the binding itself, so there is no + // proxy hop and no platform quota — Cloudflare's own limits apply. + const sendBinding = resolveSendEmailBinding(rawEnv); + if (sendBinding) { + return sendViaBinding(sendBinding, normalized.from.email, recipients, raw, messageId); + } + + const env = rawEnv as ProxyEnv | undefined; + const proxy = env?.__VOID_PROXY; + const projectId = env?.__PROJECT_ID; + const proxyToken = env?.__VOID_PROXY_TOKEN; + if (!proxy || !projectId || !proxyToken) { + return err( + 'BINDING_MISSING', + 'email: neither SEND_EMAIL nor __VOID_PROXY is available, so nothing was sent. Deploy via Void to bind the platform proxy, ' + + 'or set `email.from` in void.json so `void deploy --platform cloudflare` provisions the SEND_EMAIL binding. ' + + 'Under `void dev` messages are captured by the local inbox at /__void/inbox instead — reaching this error there means ' + + 'that path was bypassed (`sendInDev: true`, which does not deliver: `void dev` binds no send transport at all, so there is ' + + 'nothing to fall through to) or unavailable (the worker has no assets binding to reach the dev server through).', + ); + } + + const requestId = newRequestId(); + const fromDomain = normalized.from.email.split('@')[1] ?? ''; + const body: ProxySendBody = { + projectId, + aiToken: proxyToken, + proxyToken, + from: normalized.from.email, + recipients, + mimeBase64: bytesToBase64(raw), + byteSize: raw.byteLength, + requestId, + fromDomain, + }; + + let res: Response; + try { + res = await proxy.fetch('https://email-proxy/email/send', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch (e) { + return err( + 'UPSTREAM_ERROR', + `email: proxy unreachable: ${e instanceof Error ? e.message : String(e)}`, + e, + ); + } + return readResponseAsResult(res, recipients, messageId); +} + +/** + * Build a normalized email + raw MIME for advanced callers who want to feed + * the message into a custom transport. Throws on invalid input. + */ +export async function createEmail( + opts: SendEmailOptions, +): Promise<{ email: NormalizedEmail; raw: Uint8Array; messageId: string }> { + if (opts.from === undefined) { + throw new MimeError( + 'INVALID_FROM', + "createEmail: 'from' is required (this entrypoint does not apply __VOID_PROJECT_SLUG defaulting).", + ); + } + const email = await normalizeEmail( + opts as SendEmailOptions & { from: NonNullable }, + ); + const built = buildMime(email); + return { email, ...built }; +} diff --git a/packages/void/src/runtime/email/dev-inbox.ts b/packages/void/src/runtime/email/dev-inbox.ts new file mode 100644 index 000000000..e60fcf7f2 --- /dev/null +++ b/packages/void/src/runtime/email/dev-inbox.ts @@ -0,0 +1,410 @@ +/** + * In-memory capture sink for emails during local dev / tests. + * + * Two consumers: + * 1. The Vite dev-server middleware (`/__void/inbox`) — owns the buffer that + * backs the browsable inbox during `void dev`. + * 2. The test harness (`void/email/testing`) — registers a sink so + * assertions like `expect(harness.messages).toHaveLength(1)` work without + * mocking the SEND_EMAIL binding. + * + * The sink registry lives on globalThis, but it is only ever an IN-ISOLATE + * channel. It is NOT a bridge between the dev server and user code: under + * `void dev` the Vite plugin runs in Node while user code runs in workerd — a + * separate OS process started by miniflare — so each has its own globalThis and + * its own (empty) registry. A `sendEmail` call inside the worker therefore + * never sees the plugin's sink. + * + * The cross-process hop is explicit instead: the worker POSTs the captured + * message to {@link DEV_INBOX_CAPTURE_URL} through its dev assets binding + * (which `@cloudflare/vite-plugin` wires straight into the Vite dev server's + * Node middleware stack), and the plugin's middleware pushes it into the + * buffer. See `runtime/email.ts` and `plugin-email-dev-inbox.ts`. + */ + +import { MAX_MESSAGE_BYTES } from './mime.ts'; +import type { NormalizedEmail, NormalizedAttachment } from './types.ts'; + +export interface CapturedMessage { + id: string; + capturedAt: number; + raw: Uint8Array; + messageId: string; + from: { email: string; name?: string }; + to: Array<{ email: string; name?: string }>; + cc: Array<{ email: string; name?: string }>; + bcc: Array<{ email: string; name?: string }>; + replyTo?: { email: string; name?: string }; + subject: string; + text?: string; + html?: string; + headers: Record; + attachments: Array<{ + filename: string; + contentType: string; + size: number; + contentId?: string; + disposition: 'attachment' | 'inline'; + }>; +} + +export type EmailSink = (message: CapturedMessage) => void; + +// --------------------------------------------------------------------------- +// Cross-process capture protocol (workerd -> Vite dev server) +// --------------------------------------------------------------------------- + +/** Mount point of the dev inbox on the Vite dev server. */ +export const DEV_INBOX_PREFIX: string = '/__void/inbox'; +/** Capture endpoint, relative to {@link DEV_INBOX_PREFIX} (connect strips the mount). */ +export const DEV_INBOX_CAPTURE_PATH: string = '/_capture'; +/** Absolute path the worker POSTs a captured message to. */ +export const DEV_INBOX_CAPTURE_URL: string = `${DEV_INBOX_PREFIX}${DEV_INBOX_CAPTURE_PATH}`; + +/** + * Serve-only var naming the fetcher binding that loops back to the Vite dev + * server. Its presence is also the dev gate: `sendEmail` only attempts the dev + * inbox when this var is set, and it is injected exclusively when + * `viteCommand === 'serve'` (see `shouldInjectEmailDevInboxVar` in index.ts), + * so a production build never carries it. + */ +export const DEV_INBOX_ENV_VAR: string = '__VOID_EMAIL_DEV_INBOX'; + +/** + * Serve-only var carrying the project's dev-trigger token — the same + * per-project secret `/__void/scheduled` and `/__void/queue` require — so the + * worker can authenticate its capture POST. Injected together with + * {@link DEV_INBOX_ENV_VAR} under the same gate. The compiled router embeds + * the token as a module-local const the `void/email` runtime cannot import, + * so a var is the only transport into `sendEmail`. + */ +export const DEV_INBOX_TOKEN_ENV_VAR: string = '__VOID_EMAIL_DEV_INBOX_TOKEN'; + +/** + * How a caller proves it holds the dev-trigger token. Every `/__void/inbox` + * route — browse, raw download, clear, and the worker's capture POST — + * requires one of these, mirroring the `x-void-dev-trigger` check on the + * cron/queue dev routes. The query form exists for the browser: a link + * printed in the terminal cannot carry a header, and the inbox pages render + * their own links with it. Responses send `Referrer-Policy: no-referrer`, so + * the token never leaves the inbox through a Referer. + */ +export const DEV_INBOX_TOKEN_HEADER: string = 'x-void-dev-trigger'; +export const DEV_INBOX_TOKEN_QUERY: string = 'token'; + +/** + * Acknowledgement marker on the capture response. The transport rides the + * assets binding, so a 200 alone proves nothing — if that binding ever stops + * being a dev-server loopback, a plain static/SPA response would also be 200. + * The worker treats a capture as delivered only when this header comes back. + */ +export const DEV_INBOX_ACK_HEADER: string = 'x-void-email-inbox'; +export const DEV_INBOX_ACK_VALUE: string = 'captured'; + +/** + * Upper bound on a capture body. Dev-only, but an unbounded read is still a + * footgun. + * + * Derived from `MAX_MESSAGE_BYTES`, not chosen: a message `buildMime` accepts + * must never be refused here, or `void dev` fails a send that production + * delivers. `serializeCapturedMessage` carries the raw MIME as base64 (4/3 of + * its octets) AND every caller string a second time as JSON, where the + * expansion over the MIME rendering of the same string is at most 3.75x. That + * worst case is a control character in a value `encodeWord` renders — the + * subject, a display name, a custom header value: JSON writes it as `\u0001` + * (6 bytes) while the encoded-word spends at least 1.6 octets per UTF-8 byte + * (4/3 of base64 plus 12 octets of `=?UTF-8?B?…?=` per 45-byte word). A body + * expands at most 2x: the same control is `=01` (3) in quoted-printable, a + * CRLF (2) is `\r\n` (4), and a `"`, `\` or HTAB (1) is 2. An attachment's + * filename, media type and Content-ID refuse controls (`rejectControls`), so + * their worst is `"` or `\` at 2x (verbatim in the media type and Content-ID; + * escaped, and twice, in the quoted filename), as is a custom header NAME, + * which is printable ASCII. The MIME holds at most + * `MAX_MESSAGE_BYTES` of those strings, so the JSON is under + * `(4/3 + 3.75) * MAX_MESSAGE_BYTES` (5.09x) plus what has no MIME footprint of + * its own — the JSON framing, ids, timestamps, and the `bcc` addresses + * (`MAX_RECIPIENTS * MAX_ADDRESS_LENGTH`, 32 KB). 6x is the smallest integer + * multiple that covers it. At 32 MiB this refused a legal 10.3 MB message of + * control-character text (33.6 MB serialized) with a 413 that `sendEmail` + * reported as `UPSTREAM_ERROR`. Measured: that message serializes to 3.26x + * `MAX_MESSAGE_BYTES`, a legal 10 MiB one of CRLF text to 3.33x, and one of a + * control-character subject (or display name, or custom header value) to + * 4.93x — the body-only figure would not have covered it. + * + * Three caller strings have NO MIME footprint, so no multiple of + * `MAX_MESSAGE_BYTES` bounds them: a `bcc` display name (never rendered), a + * custom header whose name is reserved (dropped), and a whitespace run inside + * a long foldable header value (collapsed by folding). They are captured as + * given, and a message that pads one of them past this cap is still refused. + */ +export const DEV_INBOX_MAX_CAPTURE_BYTES: number = 6 * MAX_MESSAGE_BYTES; + +function bytesToBase64(bytes: Uint8Array): string { + // 8KB chunks keep us well under the typical V8 fromCharCode arg limit. + const CHUNK = 0x2000; + let binary = ''; + for (let i = 0; i < bytes.byteLength; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + CHUNK, bytes.byteLength))); + } + return btoa(binary); +} + +function base64ToBytes(value: string): Uint8Array | undefined { + try { + const binary = atob(value); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; + } catch { + return undefined; + } +} + +/** Encode a captured message for the worker -> dev-server hop (`raw` as base64). */ +export function serializeCapturedMessage(message: CapturedMessage): string { + return JSON.stringify({ ...message, raw: bytesToBase64(message.raw) }); +} + +function asAddress(value: unknown): { email: string; name?: string } | undefined { + if (typeof value !== 'object' || value === null) { + return undefined; + } + const v = value as { email?: unknown; name?: unknown }; + if (typeof v.email !== 'string') { + return undefined; + } + return typeof v.name === 'string' ? { email: v.email, name: v.name } : { email: v.email }; +} + +function asAddressList(value: unknown): Array<{ email: string; name?: string }> | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const out: Array<{ email: string; name?: string }> = []; + for (const entry of value) { + const address = asAddress(entry); + if (!address) { + return undefined; + } + out.push(address); + } + return out; +} + +/** + * Decode a capture payload. Returns `undefined` for anything malformed — the + * endpoint is reachable from the dev server's own origin, so it must reject + * junk rather than throw inside the middleware. + */ +export function parseCapturedMessage(value: unknown): CapturedMessage | undefined { + if (typeof value !== 'object' || value === null) { + return undefined; + } + const v = value as Record; + if (typeof v.id !== 'string' || typeof v.raw !== 'string') { + return undefined; + } + const raw = base64ToBytes(v.raw); + const from = asAddress(v.from); + const to = asAddressList(v.to); + const cc = asAddressList(v.cc); + const bcc = asAddressList(v.bcc); + if (!raw || !from || !to || !cc || !bcc) { + return undefined; + } + const attachments: CapturedMessage['attachments'] = []; + if (!Array.isArray(v.attachments)) { + return undefined; + } + for (const entry of v.attachments) { + if (typeof entry !== 'object' || entry === null) { + return undefined; + } + const a = entry as Record; + attachments.push({ + filename: typeof a.filename === 'string' ? a.filename : '', + contentType: typeof a.contentType === 'string' ? a.contentType : 'application/octet-stream', + size: typeof a.size === 'number' ? a.size : 0, + ...(typeof a.contentId === 'string' && { contentId: a.contentId }), + disposition: a.disposition === 'inline' ? 'inline' : 'attachment', + }); + } + const headers: Record = {}; + if (typeof v.headers === 'object' && v.headers !== null) { + for (const [k, hv] of Object.entries(v.headers as Record)) { + if (typeof hv === 'string') { + headers[k] = hv; + } + } + } + const replyTo = asAddress(v.replyTo); + return { + id: v.id, + capturedAt: typeof v.capturedAt === 'number' ? v.capturedAt : Date.now(), + raw, + messageId: typeof v.messageId === 'string' ? v.messageId : '', + from, + to, + cc, + bcc, + ...(replyTo && { replyTo }), + subject: typeof v.subject === 'string' ? v.subject : '', + ...(typeof v.text === 'string' && { text: v.text }), + ...(typeof v.html === 'string' && { html: v.html }), + headers, + attachments, + }; +} + +const SINK_KEY = '__voidEmailSinks__'; +const BUFFER_KEY = '__voidEmailDevBuffer__'; +const DEFAULT_BUFFER_LIMIT = 100; + +/** + * Retained-byte budget for the buffer, alongside the count cap. + * + * A count cap alone does not bound the buffer: every item holds its full raw + * MIME plus the decoded `text`/`html`, and a single message may reach + * `MAX_MESSAGE_BYTES` (10 MiB), so 100 html-heavy messages is on the order of + * gigabytes. This buffer lives in the Vite dev server's Node process — not in + * workerd — so that is the dev server's own heap. + */ +const DEFAULT_BUFFER_BYTES = 64 * 1024 * 1024; + +interface DevBuffer { + limit: number; + /** Running total of {@link messageBytes} over `items`. */ + bytes: number; + items: Array; +} + +interface SinkRegistry { + sinks: Set; +} + +function registry(): SinkRegistry { + const g = globalThis as unknown as Record; + let r = g[SINK_KEY]; + if (!r) { + r = { sinks: new Set() }; + g[SINK_KEY] = r; + } + return r; +} + +export function registerEmailSink(sink: EmailSink): () => void { + const r = registry(); + r.sinks.add(sink); + return () => r.sinks.delete(sink); +} + +export function dispatchToSinks(message: CapturedMessage): boolean { + const r = registry(); + if (r.sinks.size === 0) { + return false; + } + for (const sink of r.sinks) { + try { + sink(message); + } catch (err) { + // Sinks are best-effort observers — never let one break send. + // Surface the error so test/dev-server authors notice misbehaving + // sinks (silent swallow hid genuine bugs in test-harness setup). + console.warn('[email-dev-inbox] sink error:', err); + } + } + return true; +} + +export function hasActiveSink(): boolean { + return registry().sinks.size > 0; +} + +/** Get or create the shared dev-server ring buffer. */ +export function getDevBuffer(limit: number = DEFAULT_BUFFER_LIMIT): DevBuffer { + const g = globalThis as unknown as Record; + let b = g[BUFFER_KEY]; + if (!b) { + b = { limit, bytes: 0, items: [] }; + g[BUFFER_KEY] = b; + } + return b; +} + +/** What a retained message costs: the raw MIME plus both decoded bodies. */ +function messageBytes(message: CapturedMessage): number { + return message.raw.byteLength + (message.text?.length ?? 0) + (message.html?.length ?? 0); +} + +export function pushToDevBuffer(message: CapturedMessage): void { + const b = getDevBuffer(); + b.items.push(message); + b.bytes += messageBytes(message); + // Oldest-first on either bound. The `items.length > 1` guard keeps the + // newest message even when it alone blows the budget: a send the developer + // just made must always be visible in the inbox. + while (b.items.length > b.limit || (b.bytes > DEFAULT_BUFFER_BYTES && b.items.length > 1)) { + const evicted = b.items.shift(); + if (evicted) { + b.bytes -= messageBytes(evicted); + } + } +} + +export function listDevMessages(): Array { + return [...getDevBuffer().items].reverse(); +} + +export function findDevMessage(id: string): CapturedMessage | undefined { + return getDevBuffer().items.find((m) => m.id === id); +} + +export function clearDevBuffer(): void { + const b = getDevBuffer(); + b.items.length = 0; + // Without this the counter goes stale and the buffer silently collapses to a + // single item on every later push. + b.bytes = 0; +} + +export function captureMessage( + email: NormalizedEmail, + raw: Uint8Array, + messageId: string, +): CapturedMessage { + return { + id: generateCaptureId(), + capturedAt: Date.now(), + raw, + messageId, + from: email.from, + to: email.to, + cc: email.cc, + bcc: email.bcc, + replyTo: email.replyTo, + subject: email.subject, + text: email.text, + html: email.html, + headers: { ...email.headers }, + attachments: email.attachments.map((a: NormalizedAttachment) => ({ + filename: a.filename, + contentType: a.contentType, + size: a.bytes.byteLength, + contentId: a.contentId, + disposition: a.disposition, + })), + }; +} + +function generateCaptureId(): string { + // Compact ULID-like id: timestamp + 8 random bytes hex. + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + let hex = ''; + for (const b of bytes) { + hex += b.toString(16).padStart(2, '0'); + } + return `dev_${Date.now().toString(36)}${hex}`; +} diff --git a/packages/void/src/runtime/email/inbound.ts b/packages/void/src/runtime/email/inbound.ts new file mode 100644 index 000000000..75a2f9029 --- /dev/null +++ b/packages/void/src/runtime/email/inbound.ts @@ -0,0 +1,1315 @@ +// `ForwardableEmailMessage` is not in workerd's RPC BaseType allowlist, so the +// platform router serializes it into an `EmailEnvelope` and the worker returns +// `EmailAction[]` describing what the handler decided. The router enacts the +// actions on the real message. +// +// On the developer's own Cloudflare account there is no router: Cloudflare +// calls the worker's `email()` with the real message. The same proxy runs the +// handler, and `enactEmailActions` replays the recorded actions on the native +// message, whose return value Cloudflare ignores. + +import type { Email as PostalMimeEmail } from 'postal-mime'; +import { getRawRuntimeEnv } from '../env-raw.ts'; +import { + buildMime, + MAX_MESSAGE_BYTES, + MimeError, + normalizeEmail, + validateAddressParts, +} from './mime.ts'; +import type { Address, Attachment } from './types.ts'; + +// Mirror the slug-validity rule from runtime/email.ts so a malformed +// __VOID_PROJECT_SLUG injection can't smuggle invalid characters into +// the default reply `from` address. +const VALID_SLUG = /^[a-z0-9](?:[a-z0-9-]{0,54}[a-z0-9])?$/; + +/** + * Guardrails on what one handler may queue. + * + * These are NOT the security boundary. Recording happens inside the tenant's + * own worker, and on the platform a Workers-for-Platforms tenant can ship any + * `WorkerEntrypoint` whose `email()` returns any array at all, bypassing this + * file entirely — so the load-bearing caps are the matching ones in the + * platform router (`MAX_ACTIONS` / `MAX_ACTION_ADDRESS_LENGTH` in + * `email-router.ts`). What these buy is a fast, precise failure at the call + * that went wrong, instead of a whole action list silently dropped at the + * router with only a platform-side log to show for it. + */ +const MAX_ACTIONS = 100; +const MAX_ACTION_ADDRESS_LENGTH = 320; + +/** + * Bounds on one forward's header bag: how many, and how long a name or a + * value may be. Restated from the router's `MAX_FORWARD_HEADERS` / + * `MAX_FORWARD_HEADER_LENGTH` (`email-router.ts` — the platform depends on + * the framework, never the reverse, so the numbers cannot be imported; a + * proxy-side test pins them equal). The router drops the WHOLE action list + * on a violation, so a bag that passed here and failed there was a handler + * whose test reported the forward and whose managed deployment silently + * performed no actions. Exported for that parity test only. + */ +export const MAX_FORWARD_HEADERS: number = 32; +export const MAX_FORWARD_HEADER_LENGTH: number = 1024; + +/** + * Cap on a reject reason, restated from the router's + * `MAX_REJECT_REASON_LENGTH` in `email-router.ts` for the same reason as + * above, and pinned by the same test. See `normalizeRejectReason`. + */ +export const MAX_REJECT_REASON_LENGTH: number = 1000; + +/** + * The router's ADMISSION bounds — what it refuses BEFORE the handler runs + * (`MAX_INBOUND_BYTES` / `MAX_INBOUND_HEADERS` in `email-router.ts`, + * restated for the same reason as above and pinned by the same test). + * `routeInbound` rejects a message over either one and never dispatches it, + * so a handler's behaviour on such a message is unreachable after a managed + * deploy; the test harness applies both before it invokes the handler. + * + * `MAX_INBOUND_BYTES` is NOT `MAX_MESSAGE_BYTES`, though the numbers agree + * today. That one is Cloudflare's cap on an OUTBOUND message (and the + * `parseEmail` guard); this one is the router's own heap headroom on an + * inbound one, set well under Cloudflare's ~25 MB inbound ceiling, and the + * two can move independently. + */ +export const MAX_INBOUND_BYTES: number = 10 * 1024 * 1024; +export const MAX_INBOUND_HEADERS: number = 1024; + +/** + * Cumulative reply-byte budget for ONE dispatch. + * + * `MAX_MESSAGE_BYTES` bounds a single `reply()` call, not the queue: N replies + * at the cap is N x 10 MiB of `Uint8Array` riding one RPC response. workerd + * caps a serialized RPC payload at 32 MiB, so the queue that clears our + * per-reply check still fails at the boundary — opaquely. The router cannot + * tell that failure from any other dispatch error, so the whole message comes + * back to the sender as a generic "project handler error" reject with nothing + * in it a handler author could act on. Two full-size replies is already well + * past any legitimate use, and failing here names the exact call that + * overran. + */ +const MAX_REPLY_BYTES_TOTAL = 2 * MAX_MESSAGE_BYTES; + +function readRawProjectSlug(): string | undefined { + let raw: unknown; + try { + raw = getRawRuntimeEnv()?.__VOID_PROJECT_SLUG; + } catch { + return undefined; + } + return typeof raw === 'string' && raw.length > 0 ? raw : undefined; +} + +function tryReadProjectSlug(): string | undefined { + const raw = readRawProjectSlug(); + return raw !== undefined && VALID_SLUG.test(raw) ? raw : undefined; +} + +// The platform mail domain for this environment, injected per deployment +// (production uses `mail.void.cloud`, staging its own zone). Mirrors +// `readEmailDomain` in runtime/email.ts — no baked-in default, so a +// staging reply is never built against the production zone. +function tryReadEmailDomain(): string | undefined { + let raw: unknown; + try { + raw = getRawRuntimeEnv()?.__VOID_EMAIL_DOMAIN; + } catch { + return undefined; + } + return typeof raw === 'string' && raw.length > 0 ? raw : undefined; +} + +export interface EmailHandlerInfo { + /** + * Captured params from dynamic / tagged route segments. For example, a + * handler at `email/support+[ticket].ts` invoked for `support+abc-123@…` + * receives `{ ticket: "abc-123" }`. Empty `{}` for static handlers and + * `_default`. + */ + params: Record; +} + +export type EmailHandler = ( + message: ForwardableEmailMessage, + env: unknown, + ctx: ExecutionContext, + info?: EmailHandlerInfo, +) => void | Promise; + +/** + * Identity helper that types the inbound email handler. Use as the default + * export of `email/
.ts` or `email/_default.ts`. + */ +export function defineEmail(handler: EmailHandler): EmailHandler { + return handler; +} + +export type ParsedEmail = PostalMimeEmail; + +export type ParseEmailErrorCode = 'INBOUND_TOO_LARGE' | 'PARSE_FAILED'; + +/** + * Typed error thrown by `parseEmail`. Lets handler authors distinguish + * "too big" from "malformed body" without string-matching error messages. + */ +export class ParseEmailError extends Error { + readonly code: ParseEmailErrorCode; + constructor(code: ParseEmailErrorCode, message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'ParseEmailError'; + this.code = code; + } +} + +/** + * Parse a `ForwardableEmailMessage` into structured fields. postal-mime is + * loaded lazily so workers that never call this don't bundle it. + * + * For proxy messages (built via `createMessageProxy`) the raw bytes are + * cached on first read so subsequent `parseEmail`/`message.raw` accesses + * still work. For real CF `ForwardableEmailMessage`s the raw stream is + * one-shot. + */ +export async function parseEmail(message: ForwardableEmailMessage): Promise { + if (message.rawSize > MAX_MESSAGE_BYTES) { + throw new ParseEmailError( + 'INBOUND_TOO_LARGE', + `parseEmail: message size ${message.rawSize} bytes exceeds maximum of ${MAX_MESSAGE_BYTES} bytes.`, + ); + } + const { default: PostalMime } = await import('postal-mime'); + const cached = proxyRawCache.get(message); + try { + // Acquire inside the guard. On an own-account deploy the generated + // handler wraps Cloudflare's native one-shot `raw`, so a mid-delivery + // stream failure rejects here — above the `try` it escaped as the bare + // stream error, past the `ParseEmailError` this function advertises. + const source = cached ? await cached.read() : message.raw; + return await PostalMime.parse(source); + } catch (cause) { + // postal-mime is lenient — binary garbage, truncated parts and bad + // header bytes all parse. What it does reject is a nesting bomb (>256 + // levels), and a one-shot `raw` stream can fail on a real CF message. + // Both used to escape as a bare Error carrying parser internals, which + // left `PARSE_FAILED` declared in the code union but never constructed. + throw new ParseEmailError( + 'PARSE_FAILED', + `parseEmail: could not parse the message body. ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ); + } +} + +/** + * Per-proxy raw-bytes cache. Keyed by the proxy `ForwardableEmailMessage` + * so `parseEmail()` (and any other reader) can replay the bytes without + * consuming the underlying ReadableStream. Real CF messages are not in + * the map, so they fall through to the one-shot stream behaviour. + */ +interface RawCache { + read(): Promise; +} +const proxyRawCache = new WeakMap(); + +/** + * Serialized inbound email message — sent from the platform router to the + * user worker over RPC. Every field is in workerd's RPC `BaseType` + * allowlist (string, plain object, ReadableStream, number). + */ +export interface EmailEnvelope { + from: string; + to: string; + /** Headers serialized as a plain object so they cross RPC. */ + headers: Record; + /** + * Raw RFC 5322 bytes. Pre-buffered by the router so it's replayable on the + * receiving side (`parseEmail` consumes the stream once; if the handler + * also calls `parseEmail` directly, the platform tee'd a fresh stream per + * delivery). + */ + raw: ReadableStream | Uint8Array; + rawSize: number; +} + +/** + * What the user handler decided to do with a message. Returned from the + * worker's `email(envelope)` RPC method as an array (handlers may chain + * multiple actions, e.g. forward then reply); the router enacts each on the + * real `ForwardableEmailMessage` in order. + */ +export type EmailAction = + | { type: 'reject'; reason: string } + | { type: 'forward'; to: string; headers?: Record } + | { type: 'reply'; from: string; to: string; raw: Uint8Array }; + +export interface MessageProxy { + message: ForwardableEmailMessage; + getActions(): Array; +} + +export interface CreateMessageProxyOptions { + /** + * Project slug stripped from the `+` recipient by the + * dispatcher. When set, `message.to` is rewritten to the bare + * `@` form so user handlers see clean addresses (the slug + * is platform-internal). The original `+@` is + * preserved on a non-enumerable `__voidPlatformTo` property for + * diagnostics. + */ + slug?: string; +} + +/** + * Remove the platform-injected `+` prefix from a recipient address. + * The platform's shared sender (e.g. `mail.void.cloud`) rewrites every recipient to + * `+@`; user handlers expect `@`. If the + * slug is unknown, missing, or doesn't match the local-part prefix, the + * address is returned unchanged so callers without slug context (e.g. the + * test harness when no slug is set) don't accidentally mangle the address. + */ +function stripSlugFromAddress(address: string, slug: string | undefined): string { + if (!slug) { + return address; + } + const atIdx = address.indexOf('@'); + if (atIdx <= 0) { + return address; + } + const localPart = address.slice(0, atIdx); + const domain = address.slice(atIdx); + // Match `+...` case-insensitively. If the local-part starts with + // the slug followed by `+`, drop the `+` segment. + const slugLower = slug.toLowerCase(); + const prefix = `${slugLower}+`; + if (localPart.toLowerCase().startsWith(prefix)) { + return localPart.slice(prefix.length) + domain; + } + return address; +} + +function replyTooLarge(size: number): MimeError { + return new MimeError( + 'MIME_ERROR', + `reply: message size ${size} bytes exceeds Cloudflare limit of ${MAX_MESSAGE_BYTES} bytes.`, + ); +} + +function replyBudgetExhausted(size: number): MimeError { + return new MimeError( + 'MIME_ERROR', + `reply: cumulative reply size ${size} bytes exceeds this delivery's budget of ` + + `${MAX_REPLY_BYTES_TOTAL} bytes.`, + ); +} + +function queueFull(): MimeError { + return new MimeError( + 'MIME_ERROR', + `email: handler queued more than ${MAX_ACTIONS} actions for one message.`, + ); +} + +function addressTooLong(field: 'from' | 'to', length: number): MimeError { + return new MimeError( + field === 'from' ? 'INVALID_FROM' : 'INVALID_TO', + `${field} address is ${length} characters, over the ${MAX_ACTION_ADDRESS_LENGTH}-character limit.`, + ); +} + +function invalidForwardHeaders(cause: unknown): MimeError { + // The runtime's own message names the offending name or value. A handler + // may have built that value from the inbound message, so it is bounded. + const detail = cause instanceof Error ? cause.message.slice(0, 320) : 'not a valid header set'; + return new MimeError('MIME_ERROR', `forward: invalid header — ${detail}`); +} + +/** + * The router's `forwardHeadersWithinBounds`, as a throw. It runs on the + * record about to be recorded — the very object the router receives, so the + * count is the router's count: a `Headers` folded its duplicate names before + * it got here, and a record keeps `X-A` and `x-a` as two, both there and + * here. Runs after the `new Headers()` validity check, so every name is a + * token and every value is header-safe; what is left is size. The router + * drops the WHOLE list on any of these — a handler that passed here and + * failed there deployed as one whose managed delivery performed no actions. + */ +function assertForwardHeadersWithinBounds(headers: Record): void { + const names = Object.keys(headers); + if (names.length > MAX_FORWARD_HEADERS) { + throw new MimeError( + 'MIME_ERROR', + `forward: ${names.length} headers, over the ${MAX_FORWARD_HEADERS}-header limit.`, + ); + } + for (const name of names) { + if (name.length > MAX_FORWARD_HEADER_LENGTH) { + throw new MimeError( + 'MIME_ERROR', + `forward: header name is ${name.length} characters, over the ${MAX_FORWARD_HEADER_LENGTH}-character limit.`, + ); + } + // The declared value type is `string`, and the router refuses anything + // else: `new Headers()` coerces an Array with `String()`, so a non-string + // value has no `length` the bound below could measure. + const value: unknown = headers[name]; + if (typeof value !== 'string') { + throw new MimeError( + 'MIME_ERROR', + `forward: header "${name.slice(0, 64)}" value is not a string.`, + ); + } + if (value.length > MAX_FORWARD_HEADER_LENGTH) { + throw new MimeError( + 'MIME_ERROR', + `forward: header "${name.slice(0, 64)}" value is ${value.length} characters, over the ${MAX_FORWARD_HEADER_LENGTH}-character limit.`, + ); + } + } +} + +/** + * The router's rule for a reject reason (`validateActions` in + * `email-router.ts`: `sanitize(raw, MAX_REJECT_REASON_LENGTH)`, and + * `'rejected by handler'` for an empty one), applied when the reason is + * RECORDED so the native replay carries the string the managed one does. + * + * The reason goes into the SMTP reply to the connecting client, and a + * handler may build it from the inbound message: a remote sender's CRLF is + * then a line injected into that reply, and a multi-megabyte reason is a + * transient-heap amplifier. The router already refuses both, but on your + * own account `enactEmailActions` handed the recorded string to the native + * `setReject()` untouched, so the same handler injected there and was + * clean here. NORMALIZED rather than refused, unlike the forward bounds + * above: a reject is the handler's terminal decision, and throwing out of + * it would drop that decision and bounce the message as a dispatch error + * (see the `MAX_ACTIONS` note in `setReject`). Slice FIRST, then replace: a + * global replace materializes ~66 bytes per match, so the slice is what + * bounds the work (see `prefixReplySubject`). The result can be SHORTER + * than the cap: a run of controls inside the window collapses to one space + * and the window is not refilled from beyond it — the router's own trade, + * and it is DSN text. + */ +function normalizeRejectReason(reason: string): string { + // A JS handler can pass anything; the router treats a non-string as empty. + const raw = typeof reason === 'string' ? reason : ''; + if (raw.length === 0) { + return 'rejected by handler'; + } + return ( + raw + .slice(0, MAX_REJECT_REASON_LENGTH) + // eslint-disable-next-line no-control-regex -- C0 controls and DEL are the point + .replace(/[\x00-\x1f\x7f]+/g, ' ') + ); +} + +async function readStreamToBytes( + stream: ReadableStream, + limit = Infinity, + // The caller decides what crossing `limit` MEANS: a single reply over + // `MAX_MESSAGE_BYTES`, or the cumulative budget running out mid-stream. + // Without this the second case would be reported as the first, naming a + // limit the reply never reached. + onOverflow: (total: number) => MimeError = replyTooLarge, +): Promise { + const reader = stream.getReader(); + const chunks: Array = []; + let total = 0; + for (;;) { + const { value, done } = await reader.read(); + if (done) { + break; + } + total += value.byteLength; + if (total > limit) { + // Stop pulling: the producer would otherwise keep filling memory. + await reader.cancel().catch(() => {}); + throw onOverflow(total); + } + chunks.push(value); + } + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.byteLength; + } + return out; +} + +function bytesToReadableStream(bytes: Uint8Array): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +/** + * Build a proxy `ForwardableEmailMessage` from an envelope. The proxy's + * `setReject` / `forward` / `reply` methods don't dispatch directly — they + * record `EmailAction` entries that the router (which holds the real + * message) enacts after the handler returns. + * + * `reply()` accepts a duck-typed `{ from, to, raw }` with `raw` a + * `Uint8Array` or `ReadableStream` — what `replyEmail()` passes. It does NOT + * accept a native `EmailMessage` from `cloudflare:email`: that class exposes + * only `from` and `to` (workers-types declares nothing else, and the body + * has no public accessor), so its bytes cannot be recorded here, and nothing + * could carry them over RPC to the platform router. The reply MIME bytes are + * buffered before being recorded so the action is fully self-contained. + * + * When `opts.slug` is provided, `message.to` is rewritten to remove the + * platform-injected `+` prefix so handlers see `@` + * instead of `+@`. The original wire-form recipient is + * preserved on a non-enumerable `__voidPlatformTo` property. + */ +export function createMessageProxy( + envelope: EmailEnvelope, + opts: CreateMessageProxyOptions = {}, +): MessageProxy { + const actions: Array = []; + const headers = new Headers(envelope.headers); + + // Strip the platform-injected `+` prefix from message.to so handler + // authors see the same address shape they wrote in `email/.ts`. The + // dispatcher strips the same prefix for routing; mirroring it here keeps + // the user-visible envelope consistent with the routing surface. + const userVisibleTo = stripSlugFromAddress(envelope.to, opts.slug); + + // Cache raw bytes lazily so repeated reads (e.g. parseEmail() followed by + // anything touching message.raw) work. Real CF messages are one-shot, but + // the proxy is what user handlers see in tests + the platform router, and + // they reasonably expect raw to be replayable since the envelope is + // already pre-buffered by the router. + let cachedRaw: Uint8Array | undefined; + let bufferingPromise: Promise | undefined; + function readRawOnce(): Promise { + if (cachedRaw) { + return Promise.resolve(cachedRaw); + } + if (bufferingPromise) { + return bufferingPromise; + } + const source = envelope.raw; + if (source instanceof Uint8Array) { + cachedRaw = source; + return Promise.resolve(cachedRaw); + } + bufferingPromise = readStreamToBytes(source).then((bytes) => { + cachedRaw = bytes; + return bytes; + }); + return bufferingPromise; + } + + // If the envelope already gave us bytes, expose them eagerly via the cache + // so the WeakMap lookup in parseEmail short-circuits to a sync resolve. + if (envelope.raw instanceof Uint8Array) { + cachedRaw = envelope.raw; + } + + let rejected = false; + let replyBytesUsed = 0; + + // `actions` only ever grows through forward/reply, and `setReject` purges + // both, so length IS the queue depth. + function assertQueueHasRoom(): void { + if (actions.length >= MAX_ACTIONS) { + throw queueFull(); + } + } + + const message = { + from: envelope.from, + to: userVisibleTo, + headers, + // `raw` is accessed as a property by user code and postal-mime. Return a + // fresh ReadableStream each access, backed by the cached bytes (or + // buffering on first read for stream envelopes). + get raw(): ReadableStream { + if (cachedRaw) { + return bytesToReadableStream(cachedRaw); + } + // Stream envelope, not yet buffered. Tee-style: kick off buffering + // and return a stream that pulls from the eventual bytes. This keeps + // synchronous getter semantics while ensuring later accesses replay. + const pending = readRawOnce(); + return new ReadableStream({ + async start(controller) { + const bytes = await pending; + controller.enqueue(bytes); + controller.close(); + }, + }); + }, + rawSize: envelope.rawSize, + setReject(reason: string) { + if (rejected) { + return; // already rejected; ignore second reject + } + rejected = true; + // Deliberately NOT bounded by MAX_ACTIONS. The purge below makes this + // the one call that SHRINKS the queue, so it can never be what overruns + // it — and throwing out of "refuse this mail" would cost the handler its + // reject and hand the message back to the sender as a dispatch error, + // which is strictly worse than whatever the oversized queue was. + // Walk back through the queue and drop any prior forward/reply: CF + // (and the platform router) treats setReject as terminal — preceding + // actions in this dispatch never execute. Without this purge the + // action queue lies (records a forward the router never enacts), + // which corrupts the audit trail. Warn so handler authors notice + // the misuse. + let dropped = 0; + for (let i = actions.length - 1; i >= 0; i--) { + const a = actions[i]; + if (a.type === 'forward' || a.type === 'reply') { + actions.splice(i, 1); + dropped++; + } + } + if (dropped > 0) { + console.warn( + `[void/email] setReject() after ${dropped} forward/reply call(s) — those actions were dropped. ` + + 'CF rejects are terminal: anything queued before setReject() never runs. ' + + 'Move the reject earlier in the handler.', + ); + } + actions.push({ type: 'reject', reason: normalizeRejectReason(reason) }); + }, + async forward(to: string, fwdHeaders?: Headers | Record) { + if (rejected) { + console.warn( + '[void/email] forward() called after setReject() — ignored. ' + + 'CF rejects are terminal: nothing else runs once setReject fires.', + ); + return; + } + assertQueueHasRoom(); + if (to.length > MAX_ACTION_ADDRESS_LENGTH) { + throw addressTooLong('to', to.length); + } + // Shape, after the length bound above so the scan is over at most 320 + // characters. `forward(rcptTo)` takes a bare address — Cloudflare's + // "verified destination address" — so no display name is parsed; the + // rule is the one `sendEmail` applies to its `to` AND the one the + // platform router drops on (`email-validator`, on both sides), so a + // handler's forward refuses exactly what an outbound send refuses and + // records nothing the router would lose. Left to replay, a malformed + // address in a LATER forward was refused by Cloudflare only after + // earlier forwards and replies had fired (the platform router + // validates the whole list first and drops just that forward). + validateAddressParts('to', to); + // Accept both `Headers` (matches CF's runtime signature) and a plain + // `Record`. `Headers.entries()` lowercases keys per + // spec; users who care about preserving header casing in the wire + // form (e.g. for diagnostic headers like `X-Void-Trace`) can pass + // the record form instead. + let headersObj: Record | undefined; + if (fwdHeaders instanceof Headers) { + headersObj = Object.fromEntries(fwdHeaders.entries()); + } else if (fwdHeaders) { + // A `Headers` instance has already validated its names and values; + // a record has not. Every consumer replays the record through + // `new Headers()` — `enactEmailActions` on a native message, the + // platform router before its own replay — so run that constructor + // here and fail in the handler's own call. Left to replay, a CRLF + // or NUL in a LATER forward surfaced only after earlier forwards and + // replies had fired, leaving Cloudflare a failed invocation for a + // message that was partly delivered. The record itself is what is + // stored: preserving name casing is why this form exists. + try { + void new Headers(fwdHeaders); + } catch (e) { + throw invalidForwardHeaders(e); + } + headersObj = { ...fwdHeaders }; + } + // Size, after validity, on the record that is stored — for both forms. + // The router applies these bounds to that record and drops the whole + // list; here they fail the handler's own call, recording nothing. + if (headersObj) { + assertForwardHeadersWithinBounds(headersObj); + } + actions.push({ type: 'forward', to, ...(headersObj ? { headers: headersObj } : {}) }); + }, + async reply(reply: { from: string; to: string; raw: ReadableStream | Uint8Array }) { + if (rejected) { + console.warn( + '[void/email] reply() called after setReject() — ignored. ' + + 'CF rejects are terminal: nothing else runs once setReject fires.', + ); + return; + } + // Everything cheap first, so a doomed reply never buffers its stream. + assertQueueHasRoom(); + if (reply.from.length > MAX_ACTION_ADDRESS_LENGTH) { + throw addressTooLong('from', reply.from.length); + } + if (reply.to.length > MAX_ACTION_ADDRESS_LENGTH) { + throw addressTooLong('to', reply.to.length); + } + // Same rule as `forward(to)` above, for the hand-built reply form: + // `replyEmail()` already goes through `normalizeEmail`, but a caller + // who passes `{ from, to, raw }` bypasses it, and a malformed address + // here used to be refused only by Cloudflare's native `reply()` — after + // every earlier action had fired. Both are bare addresses. + validateAddressParts('from', reply.from); + validateAddressParts('to', reply.to); + // `replyEmail()` is bounded by `buildMime`; a hand-built reply is not, so + // bound it here before the bytes travel to the proxy (or to native reply). + const remaining = MAX_REPLY_BYTES_TOTAL - replyBytesUsed; + let rawBytes: Uint8Array; + if (reply.raw instanceof Uint8Array) { + if (reply.raw.byteLength > MAX_MESSAGE_BYTES) { + throw replyTooLarge(reply.raw.byteLength); + } + if (reply.raw.byteLength > remaining) { + throw replyBudgetExhausted(replyBytesUsed + reply.raw.byteLength); + } + rawBytes = reply.raw; + } else { + // A native `EmailMessage` lands here: `from` and `to` only, no `raw`. + // Name the problem instead of failing on `undefined.getReader()`, + // which in platform mode is a generic "project handler error" bounce + // with nothing a handler author could act on. + const raw: unknown = reply.raw; + if ( + typeof raw !== 'object' || + raw === null || + typeof (raw as ReadableStream).getReader !== 'function' + ) { + throw new MimeError( + 'MIME_ERROR', + 'reply: expected { from, to, raw } with raw as a Uint8Array or ReadableStream. ' + + 'A native EmailMessage cannot be replayed from a Void handler — its body has ' + + 'no public accessor. Use replyEmail() from void/email, or pass the MIME bytes as raw.', + ); + } + // The REMAINING budget, not the per-reply cap — an earlier reply has + // already spent part of it, and the stream must stop where the budget + // does. Whichever bound is lower is the one that fires, so the + // callback names the one that actually bound this read. + const limit = Math.min(MAX_MESSAGE_BYTES, remaining); + rawBytes = await readStreamToBytes(reply.raw, limit, (total) => + limit === MAX_MESSAGE_BYTES + ? replyTooLarge(total) + : replyBudgetExhausted(replyBytesUsed + total), + ); + } + replyBytesUsed += rawBytes.byteLength; + actions.push({ type: 'reply', from: reply.from, to: reply.to, raw: rawBytes }); + }, + } as unknown as ForwardableEmailMessage; + + // Preserve the original wire-form `+@` recipient for + // platform-side diagnostics without surfacing it to user handlers (most + // handlers should never need this — they read `message.to` and expect the + // clean form they wrote in `email/.ts`). Non-enumerable so JSON + // serialization, structured-clone, and `Object.entries` don't leak it. + if (envelope.to !== userVisibleTo) { + Object.defineProperty(message, '__voidPlatformTo', { + value: envelope.to, + enumerable: false, + writable: false, + configurable: false, + }); + } + + // Register cache entry so parseEmail() can replay bytes without consuming + // the proxy's `raw` getter (which would otherwise hand out a fresh stream + // per access — fine for correctness but wasteful for large messages). + proxyRawCache.set(message, { read: readRawOnce }); + + return { message, getActions: () => [...actions] }; +} + +/** + * Whether `Name: value` can be emitted without breaking RFC 5322 §2.1.1. + * + * Folding only happens at whitespace (§3.2.2), so the widest unbreakable run + * is what decides it — a `References` chain of ordinary ids folds fine however + * long, while one oversized id inside it does not. + */ +function fitsHeaderLine(name: string, value: string | undefined): value is string { + if (!value) { + return false; + } + // A predicate, not a maximum, in one pass that allocates nothing. Spreading + // the token list into `Math.max` throws `RangeError` past ~123k arguments + // (measured on workerd), and `split(/\s+/)` materializes every token before + // `.some()` looks at the first — over 30 bytes of heap each, so 10 MiB of + // two-character ids (3.5M tokens, inside MAX_MESSAGE_BYTES) measured 134 MB + // and terminates a 128 MB isolate before the predicate can answer. This + // guard exists to stop a hostile header making the app's reply throw, so it + // must be neither the thing that throws nor the thing that runs out of + // memory. + // + // A run ends only at SP or HTAB. That is the whitespace `foldHeader` folds + // at (§3.2.2 FWS), and a threading value is emitted verbatim, so treating + // the rest of `\s` (NBSP, FF, VT …) as a break passed a value the folder + // then could not break, and `buildMime` rejected the over-long line — the + // throw this predicate exists to prevent. Counting through them is the + // strict direction: it can only refuse a value, never pass one that fails. + const budget = 998 - (name.length + 2); + let run = 0; + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c === 0x20 || c === 0x09) { + run = 0; + continue; + } + // UTF-8 octets, as `TextEncoder` would count them: a surrogate pair is one + // 4-octet code point, and a lone surrogate encodes as U+FFFD (3 octets). + if (c < 0x80) { + run += 1; + } else if (c < 0x800) { + run += 2; + } else if ( + c >= 0xd800 && + c <= 0xdbff && + i + 1 < value.length && + (value.charCodeAt(i + 1) & 0xfc00) === 0xdc00 + ) { + run += 4; + i++; + } else { + run += 3; + } + if (run > budget) { + return false; + } + } + return true; +} + +/** + * Whether `value` carries a C0 control other than HTAB, or DEL. The + * sibling of `fitsHeaderLine` for the other way a remote-controlled id can + * be unrepresentable: a threading value is emitted verbatim, so a control + * inside it reached the reply's `In-Reply-To` / `References` line raw — + * `\x01` and DEL pass a WHATWG `Headers`, which refuses only CR, LF and + * NUL — and an RFC 5322 §3.6.4 `msg-id` is `dot-atom-text`, which admits no + * control at all. HTAB is not one here: it is the whitespace that separates + * ids (§3.2.2 FWS), the same break `fitsHeaderLine` and `replyReferences` + * cut at. One pass, no allocation, so it costs nothing a hostile header can + * multiply; never a throw, for the same reason as the line guard. + */ +function hasControl(value: string): boolean { + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if ((c < 0x20 && c !== 0x09) || c === 0x7f) { + return true; + } + } + return false; +} + +/** + * `chain` without any id that `hasControl` refuses, the rest in their order + * and joined by single spaces; the chain itself when there is nothing to + * drop, without allocating. Only ONE id is lost — the others still thread — + * whereas an id no line can carry drops the whole chain for the parent's id + * (`replyReferences`), because that guard is about the rendered line and + * this one is about one token. Called only on a chain already cut to + * `MAX_REPLY_REFERENCES_CHARS`, so the per-id slices in the rare bad case + * are bounded by that budget, not by the header a remote sender delivered. + */ +function dropControlIds(chain: string): string { + if (!hasControl(chain)) { + return chain; + } + const kept: Array = []; + let start = -1; + let tainted = false; + for (let i = 0; i <= chain.length; i++) { + const c = i < chain.length ? chain.charCodeAt(i) : 0x20; + if (c === 0x20 || c === 0x09) { + if (start >= 0 && !tainted) { + kept.push(chain.slice(start, i)); + } + start = -1; + tainted = false; + } else { + if (start < 0) { + start = i; + } + if (c < 0x20 || c === 0x7f) { + tainted = true; + } + } + } + return kept.join(' '); +} + +export interface ReplyEmailOptions { + /** + * On the managed platform (a `__VOID_PROJECT_SLUG` is injected) defaults + * to `<__VOID_PROJECT_SLUG>+noreply@<__VOID_EMAIL_DOMAIN>`, and THROWS + * when the domain binding is absent — a reply sender is never guessed. + * + * There, an override must stay on your own slug. The platform accepts a + * reply sender whose local-part before the first `+` equals the slug the + * message was delivered to; anything else is dropped silently. In + * particular `message.to` is the STRIPPED address (`@`), so + * passing it here is exactly the shape the platform rejects. + * + * Without a slug (your own Cloudflare account, local harnesses) the + * default is the address the message was delivered to, `message.to` — + * a real address on your own zone. + */ + from?: Address; + /** + * Defaults to the original `message.from` envelope address. A reply is a + * single-recipient message — for fan-out, build a fresh `sendEmail()` call. + */ + to?: Address; + /** + * Defaults to `Re: ` (no double-prefix). The original is + * remote-sender controlled, so the default is best-effort: control + * characters become a space, and a subject over 4096 characters, or an + * ASCII one too long to fold, falls back to a bare `Re:`. Pass `subject` + * to control it exactly. + */ + subject?: string; + text?: string; + html?: string; + /** + * Custom headers; threading headers (`In-Reply-To`, `References`) are + * added automatically, best-effort: an inbound id too long for a header + * line is dropped, and a `References` chain over 8 KiB keeps only its + * newest ids. + */ + headers?: Record; + attachments?: Array; +} + +/** + * Longest decoded inbound `Subject` the default reply subject is built from; + * anything longer falls back to a bare `Re:`. + * + * RFC 5322 §2.1.1 caps a header LINE at 998 octets, and only folding lets a + * header run past it — so a subject is something that fits on a line or + * two. Real ones are tens to hundreds of characters; the longest automated + * ones (a ticket title under a stack of list tags) reach a few hundred, and + * Exchange rejects past 255. Four thousand keeps every one of those with an + * order of magnitude to spare and refuses the shape that is only a subject + * in name. What the bound buys is proportional cost: the control-character + * replacements below cost 66 bytes of heap per match, so at this size they + * cost at most 135 KB, and the reply's own `Subject` line is at most ~20 KB + * — 4096 code units x 3 UTF-8 octets, base64'd in 45-octet encoded-words + * with their framing — against a `MAX_MESSAGE_BYTES` of 10 MiB, so the + * subject alone can never push the reply over the limit. + */ +const MAX_REPLY_SUBJECT_CHARS = 4096; + +function prefixReplySubject(original: string): string { + // Decode RFC 2047 encoded-words first so we (a) detect existing + // `Re:` prefixes inside encoded subjects (avoids `Re: =?UTF-8?B?...?=` + // becoming `Re: Re: foo` after the recipient's mailer decodes), and + // (b) re-encode cleanly via buildMime's encodeWord on the prefixed + // string instead of stacking encoded-words. + // + // The inbound subject is remote-sender controlled, and two things + // downstream throw on it: `normalizeEmail` rejects CR, LF and NUL, and + // `buildMime` rejects a header line over 998 octets or a message over + // `MAX_MESSAGE_BYTES`. A throw here is not the app's fault, and in + // production it rejects the whole delivery — the reply AND any forward + // the handler queued. So, as with the threading headers below, the + // subject is best-effort: C0 controls (a decoded `=00`, `=0A`, or a + // B-encoded NUL) become a single space, and three shapes fall back to a + // bare `Re:` — an empty subject, one over `MAX_REPLY_SUBJECT_CHARS`, and + // an ASCII one no line can carry. + // + // The bound is checked BEFORE the replacements, on the decoded string. + // A global `replace` materializes one part per match before it builds the + // result — about 66 bytes each — and the inbound header can be the whole + // 10 MiB message: 5 MiB of alternating tab and letter measured 165 MB on + // the first replacement alone and terminated a 128 MB isolate, which is + // the tenant's. It is the DECODED length that is bounded, not the raw + // header's: an encoded-word is always at least as long as what it decodes + // to (base64 is 4/3 of its bytes plus `=?UTF-8?B?…?=` per 45 of them, and + // a Q-encoded 3-octet character is nine characters), so a raw bound would + // refuse a CJK subject of under a thousand characters whose decoded form + // fits. The decode is one linear pass that holds only its output, already + // made to survive the 10 MiB case (see `quotedPrintableDecode` below) — + // accuracy for a pass that is paid either way. + const decoded = decodeRfc2047(original); + if (decoded.length > MAX_REPLY_SUBJECT_CHARS) { + return 'Re:'; + } + const cleaned = decoded + // eslint-disable-next-line no-control-regex -- C0 controls and DEL are the point + .replace(/[\x00-\x1f\x7f]+/g, ' ') + .replace(/ {2,}/g, ' ') + .trim(); + if (!cleaned) { + return 'Re:'; + } + const prefixed = /^re:/i.test(cleaned) ? cleaned : `Re: ${cleaned}`; + // Only a pure-ASCII subject can fail to fold: anything else goes through + // `encodeWord`, which splits it into 75-character encoded-words joined + // by spaces, and those fold. An ASCII token wider than a line cannot. + if (/^[\x20-\x7e]*$/.test(prefixed) && !fitsHeaderLine('Subject', prefixed)) { + return 'Re:'; + } + return prefixed; +} + +/** + * Longest `References` value a reply carries, in UTF-16 code units — the + * measure `MAX_REPLY_SUBJECT_CHARS` uses. It is checked on `length` alone, + * so a chain the size of the inbound message costs one property read before + * anything is scanned or sliced. + * + * RFC 5322 puts no limit on the field, but everything downstream does. RFC + * 5537 §3.4.4 has a followup agent trim it to 998 characters, and Postfix's + * `header_size_limit` discards header bytes past 100 KiB. A message-id is + * tens of characters, so 8 KiB carries a chain over a hundred replies deep + * with every id intact — an order of magnitude past any conversation — and + * a value this size, folded and encoded, is at most ~25 KB of a 10 MiB + * reply, so the chain alone can never push the reply over the limit. + * Unbounded, it could: the inbound header can be the whole 10 MiB message, + * and 10 MiB of short ids passes the line guard — every id folds — yet + * `foldHeader`'s continuations put the rendered reply over + * `MAX_MESSAGE_BYTES`, and `buildMime` threw (measured: 10,777,129 bytes + * from a 10,485,557-character chain) for a reply the guide promises is sent. + * + * Past the bound the NEWEST ids are kept, whole, and the parent's id still + * ends the chain. That is what a mail client threads on: it links the reply + * under any id it recognises, and the parent is also `In-Reply-To`. The + * root id RFC 5537 keeps is a Netnews convention (a newsreader identifies a + * thread by it); in mail it would spend the budget on an id older than + * every client in the conversation. + */ +const MAX_REPLY_REFERENCES_CHARS = 8192; + +/** + * The `References` value a reply carries, or `''` when nothing can be + * threaded. In order: the whole inbound chain continued by the parent's + * `messageId` when it fits `MAX_REPLY_REFERENCES_CHARS`; otherwise the + * newest ids of the chain that fit, none of them split; and the parent's id + * alone (which may be empty) when even those hold an id no line can carry. + */ +function replyReferences(originalReferences: string, messageId: string): string { + const parent = messageId ? ` ${messageId}` : ''; + let chain = originalReferences; + const budget = MAX_REPLY_REFERENCES_CHARS - parent.length; + if (chain.length > budget) { + // Keep the tail, from the first SP or HTAB at or after the cut so no id + // is split — the breaks `foldHeader` folds at, as in `fitsHeaderLine`. + // The scan and the slice are bounded by the budget, not by the header. + let cut = chain.length - budget; + while (cut < chain.length) { + const c = chain.charCodeAt(cut); + if (c === 0x20 || c === 0x09) { + break; + } + cut++; + } + chain = chain.slice(cut); + } + // After the cut, so the only chain this ever walks is one the budget + // already bounds. `parent` was checked by the caller and is never dropped. + chain = dropControlIds(chain); + chain = `${chain}${parent}`.trim(); + // A chain folds at its spaces, so one this long is fine; it is a single + // oversized id inside it that is not. + return fitsHeaderLine('References', chain) ? chain : messageId; +} + +/** + * Minimal RFC 2047 encoded-word decoder. Handles `=?charset?B?base64?=` + * and `=?charset?Q?quoted-printable?=` segments separated by optional + * whitespace. Unknown charsets pass through (best-effort UTF-8 decode); + * malformed inputs return the original. + * + * Scope is intentionally narrow — used only for cosmetic subject + * normalization in `prefixReplySubject`. Anything that needs robust + * decoding (e.g. searching subject text) should use postal-mime's + * parsed view via `parseEmail`. + */ +function decodeRfc2047(value: string): string { + if (!value.includes('=?')) { + return value; + } + try { + // One encoded-word at a time. `replace` with a callback collects every + // match — result array, captures and all — before the callback runs + // once, so a 10 MiB subject of tiny encoded-words (750k of them, inside + // MAX_MESSAGE_BYTES) needed 138 MB on the reply path and terminated a + // 128 MB isolate. `matchAll` is lazy: the same path needs 35 MB, and what + // is kept is the decoded output. + const parts: Array = []; + let last = 0; + for (const m of value.matchAll(/=\?([\w-]+)\?([BbQq])\?([^?]*)\?=(?:\s+(?==\?))?/g)) { + const [match, charset, enc, payload] = m; + const bytes = + enc.toUpperCase() === 'B' ? base64Decode(payload) : quotedPrintableDecode(payload); + // Honor the charset declared in the encoded-word. Q-encoded + // ISO-8859-1 / Windows-1252 subjects are still common in mail + // from older systems; decoding them as UTF-8 mojibakes any + // byte ≥ 0x80. TextDecoder rejects unknown labels by throwing, + // so fall back to UTF-8 only when the requested charset isn't + // supported (better than leaving the raw encoded-word in place). + let decoder: TextDecoder; + try { + decoder = new TextDecoder(charset || 'utf-8', { fatal: false }); + } catch { + decoder = new TextDecoder('utf-8', { fatal: false }); + } + parts.push(value.slice(last, m.index), decoder.decode(bytes)); + last = m.index + match.length; + } + parts.push(value.slice(last)); + return parts.join(''); + } catch { + return value; + } +} + +function base64Decode(b64: string): Uint8Array { + const binary = atob(b64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; +} + +function quotedPrintableDecode(qp: string): Uint8Array { + // RFC 2047 Q-encoding: `_` = space, `=XX` = hex byte, otherwise literal. + // + // Decode straight into a typed array. The payload regex in `decodeRfc2047` + // accepts whitespace, so an unfolded 10 MiB `Subject` is ONE encoded-word + // and this loop sees every character of it. Pushing each byte onto an + // `Array` first held one boxed element per character — measured + // at 200 MB of heap for 10 MiB of plain Q text (330 MB on the whole reply + // path) — and terminated a 128 MB isolate before `buildMime`'s size + // projection could refuse the subject. Every input character yields at + // most one output byte, so `qp.length` is the exact upper bound and the + // decode holds nothing but the output. + const out = new Uint8Array(qp.length); + let kept = 0; + for (let i = 0; i < qp.length; i++) { + const ch = qp.charCodeAt(i); + if (ch === 0x5f /* _ */) { + out[kept++] = 0x20; + } else if (ch === 0x3d /* = */ && i + 2 < qp.length) { + // Both characters must be hex. `parseInt('0G', 16)` is 0, not NaN, + // so a finiteness check accepted any pair that merely STARTED with + // a hex digit and synthesised a single-nibble byte from it — `=0G` + // became NUL. A malformed escape stays literal, which is what + // RFC 2045 §6.7 asks of a robust decoder and what postal-mime does. + const hex = qp.slice(i + 1, i + 3); + if (/^[0-9A-Fa-f]{2}$/.test(hex)) { + out[kept++] = parseInt(hex, 16); + i += 2; + } else { + out[kept++] = ch; + } + } else { + out[kept++] = ch; + } + } + return out.subarray(0, kept); +} + +/** + * Pure helper: build the reply MIME from an inbound message + reply opts, + * with `Subject: Re: …` (no double-prefix), `In-Reply-To`, and `References` + * threaded from the original. Threading is best-effort (the inbound values + * are remote-sender controlled): an id no header line can carry is dropped, + * and a `References` chain over `MAX_REPLY_REFERENCES_CHARS` is trimmed to + * its newest ids. Exposed separately so the dispatch step (`message.reply`) + * can be tested without `cloudflare:email`. + */ +export async function buildReplyMessage( + message: ForwardableEmailMessage, + opts: ReplyEmailOptions, +): Promise<{ from: string; to: string; raw: Uint8Array; subject: string }> { + const headers = message.headers; + const originalSubject = headers.get('Subject') ?? ''; + const originalMessageId = headers.get('Message-ID') ?? ''; + const originalReferences = headers.get('References') ?? ''; + + // Default `from` for the reply. On the platform (a slug is injected), + // build `+noreply@` from the slug and mail domain. Falling + // back to `message.to` there would reply from the user-visible + // (slug-stripped) address, which the platform's slug-pin rejects — every + // outbound on the shared sender must carry `+...@`. If the + // domain binding is missing and the caller didn't provide an explicit + // `from`, surface a clear error rather than building an invalid + // envelope. There is deliberately no baked-in domain default: guessing + // the production zone is exactly the bug the binding exists to prevent. + // + // With no slug there is no shared sender to pin to: the message was + // delivered to a real address on the developer's own zone, and replying + // from that address is what a reply means. + let fromAddr: Address; + if (opts.from !== undefined) { + fromAddr = opts.from; + } else { + const slug = tryReadProjectSlug(); + if (slug) { + const domain = tryReadEmailDomain(); + if (!domain) { + throw new Error( + "replyEmail: 'from' is required when __VOID_PROJECT_SLUG is set " + + 'but __VOID_EMAIL_DOMAIN is not. Set `from` explicitly, or deploy ' + + 'via Void so the platform-injected __VOID_PROJECT_SLUG and ' + + '__VOID_EMAIL_DOMAIN can default it to +noreply@.', + ); + } + fromAddr = `${slug}+noreply@${domain}`; + } else if (readRawProjectSlug() !== undefined) { + // A slug IS injected, so this is a platform deployment — it just + // cannot be a sender, because `+noreply@` must stay inside RFC + // 5321's 64-octet local part, which caps the slug at 56 characters. + // Falling through to `message.to` would build the slug-stripped + // `@` shape that the platform's reply pin drops, with no + // error reaching the handler. + throw new Error( + "replyEmail: 'from' is required — this project's slug cannot build a " + + 'default reply sender (`+noreply@` must stay within the ' + + '64-octet local part, so the slug must be at most 56 characters). ' + + 'Set `from` explicitly.', + ); + } else { + fromAddr = message.to; + } + } + const toAddr: Address = opts.to ?? message.from; + const subject = opts.subject ?? prefixReplySubject(originalSubject); + + // User-supplied `headers` go through the normal (reserved-header + // filtered) channel; threading headers ride the dedicated `threading` + // field that buildMime emits unconditionally. This keeps the + // RESERVED_HEADERS guard against `In-Reply-To`/`References` spoofing + // from user input intact while letting the trusted reply path emit + // them. + // + // Both values come off the INBOUND message, so a remote sender controls + // them. `buildMime` rejects any header line over RFC 5322's 998-octet + // limit, and a message-id has no whitespace to fold at — so threading a + // hostile one verbatim would let anyone make this app's reply throw, with + // the app at fault for nothing. Threading is best-effort: drop a value + // that cannot be represented and still send the reply. A control + // character is the other way an id is unrepresentable (`hasControl`), and + // it is dropped the same way rather than refused by `normalizeEmail`. + const threading: { inReplyTo?: string; references?: string } = {}; + const messageId = + fitsHeaderLine('In-Reply-To', originalMessageId) && !hasControl(originalMessageId) + ? originalMessageId + : ''; + if (messageId) { + threading.inReplyTo = messageId; + } + const references = replyReferences(originalReferences, messageId); + if (references) { + threading.references = references; + } + + const normalized = await normalizeEmail({ + from: fromAddr, + to: toAddr, + subject, + text: opts.text, + html: opts.html, + headers: opts.headers, + attachments: opts.attachments, + threading: threading.inReplyTo || threading.references ? threading : undefined, + }); + const { raw } = buildMime(normalized); + return { + from: normalized.from.email, + to: normalized.to[0].email, + raw, + subject, + }; +} + +/** + * Send a threading-aware reply to an inbound message. Builds the reply MIME + * (correct `In-Reply-To` / `References` / `Subject: Re: …`) and records a + * reply intent on the message proxy. The platform router enacts the reply + * via the real `ForwardableEmailMessage.reply()` once the handler returns. + * + * The threading headers are best-effort, since the inbound values they are + * built from are remote-sender controlled: an id no header line can carry + * is dropped, and a `References` chain over 8 KiB keeps only its newest + * ids. The reply is sent either way. + */ +export async function replyEmail( + message: ForwardableEmailMessage, + opts: ReplyEmailOptions, +): Promise { + const { from, to, raw } = await buildReplyMessage(message, opts); + await ( + message as unknown as { + reply(arg: { from: string; to: string; raw: Uint8Array }): Promise; + } + ).reply({ from, to, raw }); +} + +/** + * Whether `value` is a real `ForwardableEmailMessage` — one Cloudflare + * delivered to this worker's `email()` directly — rather than an + * `EmailEnvelope` from the platform router. Functions do not survive RPC, + * so their presence is the discriminator. + */ +export function isForwardableEmailMessage(value: unknown): value is ForwardableEmailMessage { + if (typeof value !== 'object' || value === null) { + return false; + } + const m = value as { setReject?: unknown; forward?: unknown; reply?: unknown }; + return ( + typeof m.setReject === 'function' && + typeof m.forward === 'function' && + typeof m.reply === 'function' + ); +} + +/** + * Replay recorded `EmailAction`s on a real `ForwardableEmailMessage`, in + * order. The proxy already made `setReject` terminal (earlier forward/reply + * entries are purged, later ones dropped), so a reject is never followed by + * anything. `reply` needs a genuine `EmailMessage` — Cloudflare rejects a + * duck-typed object — so `cloudflare:email` is loaded here, and only when + * a reply was recorded. + * + * The `new Headers()` below is the one step this loop constructs from + * handler input, and it cannot fail on a recorded action: the proxy's + * `forward()` already ran the record through that constructor, so a bad + * record failed inside the handler, before any replay. (`EmailMessage` + * construction neither validates nor reads its stream — measured under + * workerd.) A reject's reason reaches the native `setReject()` as the proxy + * recorded it — already sliced and stripped the way the platform router + * would (`normalizeRejectReason`), so the SMTP reply is the same in both + * deployments. What Cloudflare itself refuses at `forward()` / `reply()` + * still throws out of the loop, as any native call would. + */ +export async function enactEmailActions( + message: ForwardableEmailMessage, + actions: ReadonlyArray, +): Promise { + for (const action of actions) { + switch (action.type) { + case 'reject': + message.setReject(action.reason); + break; + case 'forward': + await message.forward(action.to, action.headers ? new Headers(action.headers) : undefined); + break; + case 'reply': { + const { EmailMessage } = await import('cloudflare:email'); + await message.reply( + new EmailMessage(action.from, action.to, bytesToReadableStream(action.raw)), + ); + break; + } + } + } +} diff --git a/packages/void/src/runtime/email/mime.ts b/packages/void/src/runtime/email/mime.ts new file mode 100644 index 000000000..02a617df9 --- /dev/null +++ b/packages/void/src/runtime/email/mime.ts @@ -0,0 +1,1858 @@ +/** + * Minimal RFC 5322 / 2047 / 2045 MIME builder for the void/email runtime. + * + * Scope is intentionally narrow — text/plain, text/html, multipart/alternative, + * and multipart/mixed for attachments — covering >95% of transactional sends. + * Inline images use multipart/related when both attachments and html are present. + */ + +import { validate as isValidEmailSyntax } from 'email-validator'; +import type { Address, Attachment, NormalizedAttachment, NormalizedEmail } from './types.ts'; + +/** Cloudflare Email Workers reject messages larger than ~10 MB after base64 expansion. */ +export const MAX_MESSAGE_BYTES: number = 10 * 1024 * 1024; + +/** + * Upper bound on the TOTAL attachment bytes `normalizeEmail` will materialize. + * + * Base64 expands by `ceil(n / 3) * 4`, so an attachment set larger than three + * quarters of `MAX_MESSAGE_BYTES` cannot fit in a message that passes the + * check at the end of `buildMime` no matter how small everything else is. + * `buildMime` remains the authority on the final size — this bound only stops + * `normalizeEmail` from materializing, and then expanding, bytes already known + * not to fit. + * + * It has to be charged here because that final check comes far too late to + * help. Measured peak live memory is about 10x the attachment bytes (8 MiB in + * gives 83 MiB live) once the normalized copy, the base64 binary string, the + * 76-column slices, the joins, `rawText` and its `TextEncoder.encode` are all + * resident at once. Against a 128 MB isolate ceiling, a set over roughly + * 12 MiB terminates the isolate rather than returning the `MIME_ERROR` the + * docs promise for an oversize payload. + * + * This is a developer-experience guardrail, NOT the security boundary. + * Attachments come from the app's own code, never from a remote sender, and + * the platform proxy already caps the base64 length before decoding and the + * raw length after. What this buys is a precise, catchable failure at the call + * that went wrong, instead of a dead isolate with nothing a caller could act + * on. + */ +const MAX_ATTACHMENT_BYTES_TOTAL: number = Math.floor((MAX_MESSAGE_BYTES * 3) / 4); + +/** + * Cap on the recipient count across `to`, `cc` and `bcc`, checked against + * the RAW input before a single address is parsed. + * + * The platform proxy refuses more than 100 recipients (`MAX_RECIPIENTS` in + * `proxy/src/email.ts`, counted on the deduplicated list this module + * produces), so on the platform a larger list is always rejected — but only + * after the tenant has parsed every address and rendered the whole `To:` + * header. Measured, that rendering costs about 250 bytes of heap per + * recipient plus the header's own ~31 bytes per character, so 230k short, + * valid addresses (a 4.8 MiB message, legal under `MAX_MESSAGE_BYTES`) + * terminated a 128 MB isolate instead of reaching the proxy's `INVALID_TO`. + * Under `void dev` the same list was captured as a success and failed only + * once deployed. Mirroring the cap here makes it a precise, catchable error + * at the call that went wrong, in every mode. + * + * This is the framework's own limit on an own-account deploy, where the + * `SEND_EMAIL` binding sends one envelope per recipient and imposes no + * per-call count. Cloudflare's object-form `send()` API caps `to + cc + bcc` + * at 50, so 100 is the looser of the two. + * + * Counted on the raw entries rather than after deduplication: parsing is what + * this bound exists to skip, so a list of 150 entries with 100 unique + * addresses is refused here where the proxy would have accepted it. + */ +export const MAX_RECIPIENTS: number = 100; + +/** + * Pre-parse cap on one parsed address, in characters. RFC 5321 §4.5.3.1 + * allows a 64-octet local part and a 255-octet domain, so nothing over 320 + * characters can be delivered; the platform proxy applies the same + * `MAX_RECIPIENT_LENGTH` to the same parsed address. This is the bound on + * the WORK — it is checked before the address is scanned — not the + * deliverability bound, which `email-validator` applies at 254 (see + * `validateAddressParts`). A display name is not bounded — it is folded or + * encoded into legal header lines at any length, and the tokenizers below + * keep that cost linear. + */ +export const MAX_ADDRESS_LENGTH: number = 320; + +const CRLF = '\r\n'; +const ASCII_PRINTABLE = /^[\x20-\x7e]*$/; +const HEADER_NAME = /^[!-9;-~]+$/; + +function rejectCRLF(field: string, value: string): void { + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c === 0x0d || c === 0x0a || c === 0x00) { + throw new MimeError('MIME_ERROR', `${field}: must not contain CR, LF, or NUL.`); + } + } +} + +/** + * Refuse any other C0 control or DEL in a value that is rendered VERBATIM + * into a header — an attachment's filename, media type and Content-ID. + * `rejectCRLF` is enough for a value `encodeWord` renders (the subject, a + * custom header value, a display name): anything outside printable ASCII + * sends the whole value into base64, so a control never reaches the wire. + * These three go through `quoteParam` or straight into the line, so + * `invoice\x01.pdf` was written raw into `name="…"` and `filename="…"`, + * captured as a success under `void dev` and by the test harness, and left + * for the receiving MTA to refuse. + * + * RFC 5322 §3.2.4 `qtext` is printable US-ASCII less `"` and `\`; a + * control inside a quoted-string is `obs-qtext`, which §4 says MUST NOT be + * generated. HTAB is deliberately NOT refused: `quoted-string` is + * `DQUOTE *([FWS] qcontent) [FWS] DQUOTE` and `FWS` is built from `WSP` + * (SP / HTAB), so a tab between characters is folding white space exactly + * as a space is — and `obs-NO-WS-CTL`, the RFC's own list of the controls + * that used to be `qtext`, leaves %d9 out. The same set is illegal in an + * RFC 2045 §5.1 `token` (no CTLs) and an RFC 5322 §3.6.4 `msg-id` + * (`dot-atom-text`), so one rule serves the media type and the Content-ID. + * + * The message names the code point and never echoes the value, as + * `validateAddressParts` does. Runs AFTER the filename's length bound, so + * the scan is over a value already known to fit a line. + */ +function rejectControls(field: string, value: string): void { + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if ((c < 0x20 && c !== 0x09) || c === 0x7f) { + throw new MimeError( + 'MIME_ERROR', + `${field} contains a control character (U+${c.toString(16).toUpperCase().padStart(4, '0')}).`, + ); + } + } +} + +/** RFC 2045 §5.1 `tspecials`: what a `token` may not contain besides SPACE and CTLs. */ +const TSPECIALS = '()<>@,;:\\"/[]?='; + +/** RFC 2045 §5.1 `token` character: printable US-ASCII less SPACE and `tspecials`. */ +function isTokenChar(c: number): boolean { + return c > 0x20 && c < 0x7f && !TSPECIALS.includes(String.fromCharCode(c)); +} + +/** + * Refuse an attachment media type that does not parse as RFC 2045 §5.1 + * `type "/" subtype *(";" parameter)`. + * + * `rejectControls` answers only for CTLs, and `buildAttachmentPart` writes + * the value verbatim ahead of its own `; name="…"`, so `text/plain; x="` + * rendered `Content-Type: text/plain; x="; name="a.txt"` — captured as a + * success under `void dev` and by the harness, while Python's `email` + * reports `end of header inside quoted string` on that line and reads the + * builder's `name` parameter as the VALUE of `x`. `text` (no subtype), + * `text/plain; charset` (no value) and `text/plain;;` went the same way. + * + * The grammar is the RFC's, read as a generator: a `token` is printable + * US-ASCII less SPACE and `tspecials`; a `value` is a `token` or a + * quoted-string, whose content is anything but a bare `"` or `\` (the same + * rule `quoteParam` renders `name="…"` by, so a caller's quoted value may + * carry what the builder's own already does). Optional WSP is admitted on + * either side of `;` — the RFC 822 implicit LWSP every parser accepts and + * the form HTTP's `Content-Type` writes — and nowhere else: `text/ plain`, + * a trailing `;`, and an RFC 822 comment are refused rather than repaired. + * Two parameters are also refused on their meaning: a `name`, because the + * builder appends `name=""` and RFC 6838 §4.3 makes it "an error + * for a specific parameter to be specified more than once"; and by that + * same sentence any attribute given twice, compared case-insensitively as + * §5.1 says attributes match. Type and subtype are kept as given — matching + * is case-insensitive, so `TEXT/PLAIN` is legal and renders unchanged. + * + * One pass, no backtracking, and the only allocation is one lower-cased + * attribute per parameter for the duplicate check, which is why this runs + * AFTER the value's length bound. The message names the rule and never + * echoes the value. + */ +function validateMediaType(field: string, value: string): void { + const syntax = () => + new MimeError( + 'MIME_ERROR', + `${field} is not a valid media type (RFC 2045 §5.1: type/subtype, then ; attribute=value parameters).`, + ); + const n = value.length; + let i = 0; + /** Advance over a `token`; the number of characters consumed. */ + const token = (): number => { + const start = i; + while (i < n && isTokenChar(value.charCodeAt(i))) { + i++; + } + return i - start; + }; + const wsp = (): void => { + while (i < n && (value[i] === ' ' || value[i] === '\t')) { + i++; + } + }; + if (token() === 0 || value[i] !== '/') { + throw syntax(); + } + i++; + if (token() === 0) { + throw syntax(); + } + const seen = new Set(); + while (i < n) { + wsp(); + if (value[i] !== ';') { + throw syntax(); + } + i++; + wsp(); + const start = i; + if (token() === 0 || value[i] !== '=') { + throw syntax(); + } + const attribute = value.slice(start, i).toLowerCase(); + i++; + if (value[i] === '"') { + i++; + for (;;) { + if (i >= n) { + throw syntax(); + } + const ch = value[i++]; + if (ch === '"') { + break; + } + if (ch === '\\') { + if (i >= n) { + throw syntax(); + } + i++; + } + } + } else if (token() === 0) { + throw syntax(); + } + if (attribute === 'name') { + throw new MimeError( + 'MIME_ERROR', + `${field} must not carry a name= parameter; the builder adds name="" itself.`, + ); + } + if (seen.has(attribute)) { + throw new MimeError( + 'MIME_ERROR', + `${field} gives the same parameter twice; attribute names are case-insensitive and each may appear once (RFC 6838 §4.3).`, + ); + } + seen.add(attribute); + } +} + +/** RFC 5322 §3.2.3 `atext` besides ALPHA and DIGIT. */ +const ATEXT_SYMBOLS = "!#$%&'*+-/=?^_`{|}~"; + +/** RFC 5322 §3.2.3 `atext` character. */ +function isAtext(c: number): boolean { + return ( + (c >= 0x30 && c <= 0x39) || + (c >= 0x41 && c <= 0x5a) || + (c >= 0x61 && c <= 0x7a) || + ATEXT_SYMBOLS.includes(String.fromCharCode(c)) + ); +} + +/** + * Refuse an attachment Content-ID that would not render as one RFC 5322 + * §3.6.4 `msg-id`. + * + * `rejectControls` answers only for CTLs, and `buildAttachmentPart` writes + * the value verbatim — adding `<` and `>` unless the value already starts + * with `<` — so `logo other` rendered `Content-ID: `, `` rendered `Content-ID: >`: + * captured as a success under `void dev` and by the harness, and left for + * the receiver to refuse or to fail the `cid:` reference against. + * + * The grammar is the RFC's, read as a generator, with two deliberate + * differences. An `msg-id` is `"<" id-left "@" id-right ">"`, each side a + * `dot-atom-text` (§3.2.3: `1*atext *("." 1*atext)`, so no leading, + * trailing or doubled dot — the strict form; `obs-id-left`/`obs-id-right` + * are §4 obsolete syntax, which MUST NOT be generated); `no-fold-literal` + * (`[…]` for `id-right`) is left out. First difference: the `@` and its + * right side are OPTIONAL. RFC 2392 §2 resolves a `cid:` URL by wrapping + * its body in `<` `>` and matching that against `Content-ID`, so an id + * without `@` — the documented `contentId: 'logo'` + `cid:logo` — is what + * every client already resolves, and the domain that would make it a + * proper `msg-id` adds nothing a reference could use. Second: the angle + * brackets are optional too, because the builder has always added them + * around a bare id; given, they must be one pair around the whole value. + * + * Refused, then: whitespace of any kind, a second `@`, `<` or `>` anywhere + * but as that one pair, `"`, `(`, `)`, `,`, `;`, `:`, `\`, `[`, `]`, + * anything non-ASCII, and the empty string (which used to be validated + * and then silently NOT rendered). Everything accepted renders exactly as + * it did. + * + * One pass, no allocation, which is why this runs AFTER the value's length + * bound. The message names the rule and never echoes the value. + */ +function validateContentId(field: string, value: string): void { + const syntax = () => + new MimeError( + 'MIME_ERROR', + `${field} is not a valid Content-ID (RFC 5322 §3.6.4 msg-id: an identifier of atext, "." and "@", optionally in one pair of angle brackets).`, + ); + let i = 0; + let end = value.length; + if (value[0] === '<') { + if (end < 2 || value[end - 1] !== '>') { + throw syntax(); + } + i = 1; + end--; + } + /** Advance over a `dot-atom-text`; false when there is none, or a dot is misplaced. */ + const dotAtomText = (): boolean => { + let run = 0; // atext since the last dot + for (; i < end; i++) { + const c = value.charCodeAt(i); + if (isAtext(c)) { + run++; + } else if (c === 0x2e /* . */ && run > 0) { + run = 0; + } else { + break; + } + } + return run > 0; + }; + if (!dotAtomText()) { + throw syntax(); + } + if (i < end) { + if (value[i] !== '@') { + throw syntax(); + } + i++; + if (!dotAtomText()) { + throw syntax(); + } + } + if (i !== end) { + throw syntax(); + } +} + +const COMMON_TYPES: Record = { + txt: 'text/plain', + html: 'text/html', + htm: 'text/html', + css: 'text/css', + csv: 'text/csv', + pdf: 'application/pdf', + json: 'application/json', + xml: 'application/xml', + zip: 'application/zip', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + ico: 'image/x-icon', + ics: 'text/calendar', + eml: 'message/rfc822', +}; + +function inferContentType(filename: string): string { + const ext = filename.split('.').pop()?.toLowerCase() ?? ''; + return COMMON_TYPES[ext] ?? 'application/octet-stream'; +} + +export type MimeErrorCode = 'INVALID_FROM' | 'INVALID_TO' | 'MIME_ERROR'; + +export class MimeError extends Error { + code: MimeErrorCode; + constructor(code: MimeErrorCode, message: string) { + super(message); + this.name = 'MimeError'; + this.code = code; + } +} + +function fieldErrorCode(field: string): MimeErrorCode { + if (field === 'from') { + return 'INVALID_FROM'; + } + if (field === 'to') { + return 'INVALID_TO'; + } + return 'MIME_ERROR'; +} + +/** + * Validate one parsed address (and its display name, when there is one), + * throwing a `MimeError` for `field` on anything undeliverable. Exported so + * the inbound proxy's `forward(to)` and hand-built `reply()` refuse a bare + * address by the same rule `sendEmail` applies to its `to`. + * + * The address rule is `email-validator`, and nothing else. It is also the + * platform router's rule (`isValidEmailAddress` in the proxy's + * `email-router.ts`), so there is exactly ONE address syntax in the tree: + * an action the framework records is never dropped by the router for its + * shape, and a test harness never captures as a success what the managed + * deployment would lose. The hand-written predecessor grew one character + * per review round — the exactly-one-`@` rule, then every C0 control and + * DEL, then `:`, then a parity test against the router's own regex, which + * had drifted on the dot in the domain — and still admitted `a..b@x.com`, + * `.a@x.com` and `a@x..com`, which no dot-atom allows and every MTA + * refuses. One maintained rule on both sides ends that series. + * + * What it enforces: at most 254 characters in total; an ASCII dot-atom + * local part of at most 64 characters (RFC 5322 §3.2.3 `atext`, dots only + * between atoms — no quoted local part, no whitespace, no control, no + * special); a dotted domain of ASCII labels of at most 63 characters each, + * with a TLD that starts with a letter and has at least two characters; so + * `user@localhost`, `x@y` and a raw IDN or SMTPUTF8 address are refused + * (an IDN domain is accepted as punycode, `xn--…`). ASCII-only makes + * octets and characters the same thing, so the RFC 5321 §4.5.3.1 octet + * limits are the package's length checks; the 64 is also what caps the + * default `+noreply@` sender (`VALID_SLUG` in `runtime/email.ts`, + * `MAX_SLUG_LENGTH` = `64 - 8` in `cli/project-slug.ts` and the API). One + * known quirk, pinned in the tests rather than worked around: the FIRST + * character of the local part may not be a backtick, while any later one + * may. + * + * `MAX_ADDRESS_LENGTH` still runs first, on the raw string, so the scan is + * bounded before anything reads the address; the package's own 254 check + * runs before its regex for the same reason. The message never echoes the + * address: it may hold a control character, and naming the rule is enough. + */ +export function validateAddressParts(field: string, email: string, name?: string): void { + if (email.length > MAX_ADDRESS_LENGTH) { + throw new MimeError( + fieldErrorCode(field), + `${field}: address is ${email.length} characters, over the ${MAX_ADDRESS_LENGTH}-character limit.`, + ); + } + if (!isValidEmailSyntax(email)) { + throw new MimeError( + fieldErrorCode(field), + `${field}: address is not a valid email address (ASCII local part and dotted domain, ` + + `RFC 5322 dot-atom; at most 64 characters before the '@' and 254 in total).`, + ); + } + if (name !== undefined) { + rejectCRLF(`${field} display name`, name); + // An ASCII name with specials is rendered as one quoted-string, which + // `foldHeader` never splits: ` "name"` is a line of at least 3 octets + // plus the name. Same reason as the filename guard above — the escaping + // in `formatAddress` costs heap per escaped character, and the line + // check that would refuse it runs only after everything is rendered. + // A non-ASCII name becomes encoded-words and folds; a plain name folds + // at its spaces; neither is bounded here. + if ( + ASCII_PRINTABLE.test(name) && + RFC5322_SPECIALS.test(name) && + name.length + QUOTED_NAME_LINE_OVERHEAD > MAX_LINE_OCTETS + ) { + throw new MimeError( + fieldErrorCode(field), + `${field}: display name is ${name.length} characters and must be quoted, which cannot be folded, ` + + `so its line would exceed the ${MAX_LINE_OCTETS}-octet limit in RFC 5322 §2.1.1. Shorten it.`, + ); + } + // `@` in a display name almost always means a typo like + // `a@x.dev` got mis-parsed as `name=a@x.dev, + // email=b@evil.dev`. Reject so the caller sees a clear validation + // error rather than silently rerouting to a different address. + if (name.includes('@')) { + throw new MimeError( + fieldErrorCode(field), + `${field}: display name '${name}' must not contain '@' (likely a malformed address).`, + ); + } + } +} + +/** + * Undo the quoted-pairs in the content of a quoted display name: `\x` → + * `x` for any `x` (RFC 5322 §3.2.4 — `qtext` excludes `"` and `\`, so a + * quoted-string carries either only as a quoted-pair). The parsed name is + * the RAW text `formatAddress` will quote and escape again, so leaving the + * pairs in place rendered `"Smith \"Bob\"" ` as + * `"Smith \\\"Bob\\\""` and the recipient saw `Smith \"Bob\"`, while the + * object form of the same name rendered correctly. Only the quoted branch + * of `parseAddress` calls this; a backslash in an unquoted name is a + * character like any other, as it is in the object form. + * + * NOT `text.replace(/\\(.)/g, '$1')`: a global-regex replacement holds one + * part per match before it builds the result, about 60 bytes each, and + * nothing bounds this name before it runs — the quoted-string line guard + * in `validateAddressParts` needs the DECODED name, and a non-ASCII name is + * never bounded at all. Measured, 10 MiB of `\"` pairs peaked at 307 MB of + * heap that way and terminated a 128 MB isolate; this shape peaks at 49 MB. + * Same discipline as `to7bitBody`: one counting pass, a buffer of exactly + * the output size, one fill, and the 32K-chunk conversion `base64` uses. + * The common case — no backslash at all — is one scan and no allocation. + * The regex that admits the text guarantees every backslash is followed by + * a character, so the fill never reads past the end. + */ +function unquotePairs(text: string): string { + const first = text.indexOf('\\'); + if (first === -1) { + return text; + } + let pairs = 0; + for (let i = first; i < text.length; i++) { + if (text.charCodeAt(i) === 0x5c) { + pairs++; + i++; + } + } + // UTF-16 code units, not bytes: a quoted name may be non-ASCII, and a + // surrogate pair is two units that round-trip exactly. + const out = new Uint16Array(text.length - pairs); + let n = 0; + for (let i = 0; i < text.length; i++) { + let c = text.charCodeAt(i); + if (c === 0x5c) { + c = text.charCodeAt(++i); + } + out[n++] = c; + } + let s = ''; + const chunk = 0x8000; + for (let i = 0; i < n; i += chunk) { + s += String.fromCharCode(...out.subarray(i, i + chunk)); + } + return s; +} + +/** + * Parse one address — a bare `email@domain` or `Display Name ` + * — into its parts, throwing a `MimeError` for `field` on anything else. + * Exported so `void.json` `email.from` is validated by the same rules the + * runtime applies to `from` at send time. + */ +export function parseAddress(value: Address, field: string): { email: string; name?: string } { + if (typeof value === 'string') { + rejectCRLF(`${field} address`, value); + // Match `Display Name ` with paired quotes around the + // name (both or neither). The earlier regex had two independent + // optional quotes, so a single stray `"` slipped through. + // + // The unquoted name must start and end on a non-whitespace character. + // The earlier `^\s*([^"<]+?)?\s*<` let three consecutive quantifiers + // compete for the same run of whitespace, which the backtracker + // resolves by trying every split — cubic in the run length. Measured: + // 4,000 leading spaces before a valid address (accepted, then trimmed) + // cost 10 s; a 16 KB run would exceed the 30 s CPU limit. Both forms + // accept the same strings with the same captures (checked exhaustively + // to length 7 over `a`, space, tab, `<`, `>`, `"`, `@`, `\`, `,`); + // this one is linear because `[^"<\s]` cannot take a space from `\s*`. + const quoted = value.match(/^\s*"((?:[^"\\]|\\.)*)"\s*<\s*([^>\s]+)\s*>\s*$/); + const angle = + quoted ?? value.match(/^\s*(?:([^"<\s](?:[^"<]*[^"<\s])?)\s*)?<\s*([^>\s]+)\s*>\s*$/); + if (angle) { + // The quoted form's `\"` and `\\` are quoted-pairs, undone here so + // the parsed name is the raw text — the same thing the object form + // carries — and `formatAddress` escapes it exactly once. Decoded + // BEFORE `validateAddressParts`, whose quoted-string line guard has + // to see the name `formatAddress` will render. + const name = (quoted ? unquotePairs(angle[1] ?? '') : angle[1])?.trim(); + const email = angle[2]; + validateAddressParts(field, email, name); + return name ? { email, name } : { email }; + } + const trimmed = value.trim(); + validateAddressParts(field, trimmed); + return { email: trimmed }; + } + validateAddressParts(field, value.email ?? '', value.name); + return value.name ? { email: value.email, name: value.name } : { email: value.email }; +} + +function parseAddresses( + value: Address | Array
, + field: string, +): Array<{ email: string; name?: string }> { + const list = Array.isArray(value) ? value : [value]; + return list.map((entry) => parseAddress(entry, field)); +} + +/** Entries in one recipient field, counted the way `parseAddresses` will see them. */ +function countRecipients(value: Address | Array
| undefined): number { + if (!value) { + return 0; + } + return Array.isArray(value) ? value.length : 1; +} + +/** + * Keeps the `exceeds Cloudflare limit of bytes` tail that `buildMime`'s + * own size error already uses, so the documented shape — and any caller + * matching on it — holds for both paths. + */ +function attachmentsTooLargeError(bytes: number): MimeError { + return new MimeError( + 'MIME_ERROR', + `email: attachment size ${bytes} bytes exceeds Cloudflare limit of ${MAX_ATTACHMENT_BYTES_TOTAL} bytes.`, + ); +} + +/** + * Size of `content` WITHOUT materializing it. + * + * A string reports its UTF-16 `length`, which is a sound LOWER bound on its + * UTF-8 byte length — every code unit encodes to at least one byte — so this + * can never reject a string that would have fit; the exact length is charged + * after the encode. Anything else reports 0 and is left to `toBytes` to reject + * by type. + */ +function declaredSize(content: Attachment['content']): number { + if (content instanceof Uint8Array) { + return content.byteLength; + } + if (content instanceof ArrayBuffer) { + return content.byteLength; + } + if (typeof Blob !== 'undefined' && content instanceof Blob) { + return content.size; + } + if (typeof content === 'string') { + return content.length; + } + return 0; +} + +async function toBytes(content: Attachment['content']): Promise { + if (content instanceof Uint8Array) { + return content; + } + if (content instanceof ArrayBuffer) { + return new Uint8Array(content); + } + if (typeof Blob !== 'undefined' && content instanceof Blob) { + return new Uint8Array(await content.arrayBuffer()); + } + if (typeof content === 'string') { + return new TextEncoder().encode(content); + } + throw new MimeError( + 'MIME_ERROR', + 'attachment content must be string, Uint8Array, ArrayBuffer, or Blob.', + ); +} + +async function normalizeAttachment(att: Attachment): Promise { + if (!att.filename) { + throw new MimeError('MIME_ERROR', 'attachment: missing filename.'); + } + rejectCRLF('attachment filename', att.filename); + // A quoted parameter never folds — `foldHeader` keeps a quoted-string + // whole — so ` filename="…"` is one line of at least 12 octets plus the + // filename. Refuse here what the post-render line check would refuse + // anyway: `quoteParam` costs heap per escaped character, and a filename + // of ~1.7M quotes killed a 128 MB isolate before that check could run. + // `length` never exceeds the UTF-8 octet count, so this cannot reject a + // filename whose line would have fit. + if (att.filename.length + QUOTED_FILENAME_LINE_OVERHEAD > MAX_LINE_OCTETS) { + throw new MimeError( + 'MIME_ERROR', + `attachment filename is ${att.filename.length} characters; a quoted \`filename=\` parameter cannot be folded, ` + + `so its line would exceed the ${MAX_LINE_OCTETS}-octet limit in RFC 5322 §2.1.1. Shorten the filename.`, + ); + } + // After the bound: `rejectCRLF` above answers for the line delimiters on + // the raw string, and this scan is over a filename already known to fit. + rejectControls('attachment filename', att.filename); + if (att.contentType !== undefined) { + rejectCRLF('attachment contentType', att.contentType); + // Same order as the filename: the bound, then the scans. `Content-Type: + // ; name="…"` puts the media type on the line ahead of the + // builder's own parameter, and `validateMediaType` keeps one string per + // parameter, so both run over a value already known to fit. + if (att.contentType.length + CONTENT_TYPE_LINE_OVERHEAD > MAX_LINE_OCTETS) { + throw new MimeError( + 'MIME_ERROR', + `attachment contentType is ${att.contentType.length} characters; at most ${MAX_LINE_OCTETS - CONTENT_TYPE_LINE_OVERHEAD} fit ` + + `on the Content-Type line under the ${MAX_LINE_OCTETS}-octet limit in RFC 5322 §2.1.1. Shorten it.`, + ); + } + rejectControls('attachment contentType', att.contentType); + validateMediaType('attachment contentType', att.contentType); + } + if (att.contentId !== undefined) { + rejectCRLF('attachment contentId', att.contentId); + // Same order again: the bound, then the scans. An id the grammar admits + // has no whitespace, so `Content-ID: ` is one token after the field + // name and `foldHeader` returns the line whole: exactly the field name + // plus the id plus the `<` `>` `buildAttachmentPart` adds around a bare + // one — an id that already starts with `<` renders as given, so it is + // allowed those 2 characters more. `length` never exceeds the UTF-8 + // octet count, so this cannot reject an id whose line would have fit; + // an over-long id with whitespace in it is refused here rather than + // by the grammar, and refused either way. + const brackets = att.contentId.startsWith('<') ? 0 : 2; + if (att.contentId.length + brackets + CONTENT_ID_LINE_OVERHEAD > MAX_LINE_OCTETS) { + throw new MimeError( + 'MIME_ERROR', + `attachment contentId is ${att.contentId.length} characters; at most ${MAX_LINE_OCTETS - CONTENT_ID_LINE_OVERHEAD - brackets} fit ` + + `on the Content-ID line under the ${MAX_LINE_OCTETS}-octet limit in RFC 5322 §2.1.1. Shorten it.`, + ); + } + rejectControls('attachment contentId', att.contentId); + validateContentId('attachment contentId', att.contentId); + } + // The type admits only the two literals, but that binds nothing at + // runtime: a JS caller, or a JSON-parsed options object, can hand over any + // string, and `buildAttachmentPart` interpolates the value straight into + // `Content-Disposition`. An allow-list, not `rejectCRLF`: there is no + // third disposition this builder could render meaningfully. + if ( + att.disposition !== undefined && + att.disposition !== 'attachment' && + att.disposition !== 'inline' + ) { + throw new MimeError('MIME_ERROR', "attachment disposition must be 'attachment' or 'inline'."); + } + const bytes = await toBytes(att.content); + return { + filename: att.filename, + contentType: att.contentType ?? inferContentType(att.filename), + bytes, + contentId: att.contentId, + disposition: att.disposition ?? (att.contentId ? 'inline' : 'attachment'), + }; +} + +/** + * Validate inputs and produce a normalized email shape that can be handed to + * the MIME builder, the dev inbox, or the test harness without re-parsing. + */ +export async function normalizeEmail(opts: { + from: Address; + to: Address | Array
; + subject: string; + text?: string; + html?: string; + replyTo?: Address; + cc?: Address | Array
; + bcc?: Address | Array
; + headers?: Record; + attachments?: Array; + threading?: { inReplyTo?: string; references?: string }; +}): Promise { + if (!opts.text && !opts.html) { + throw new MimeError('MIME_ERROR', "email: at least one of 'text' or 'html' is required."); + } + if (opts.subject !== undefined) { + rejectCRLF('subject', opts.subject); + } + if (opts.headers) { + for (const [name, value] of Object.entries(opts.headers)) { + if (!HEADER_NAME.test(name)) { + throw new MimeError( + 'MIME_ERROR', + `headers: '${name}' is not a valid RFC 5322 header name.`, + ); + } + // A field name never folds. The shortest line that can carry it is + // `Name:` on its own — what `foldHeader` emits when the value's + // first token does not fit beside a long name — so a name over + // `MAX_LINE_OCTETS - 1` characters has no legal rendering whatever + // its value, and the post-render line check would refuse it. Refuse + // it here instead, as `normalizeAttachment` does a filename: that + // check runs only after the name has been lowercased, joined into + // its line, the header block, `rawText` and `raw` — four copies of a + // name that can be the whole message. `HEADER_NAME` admits only + // ASCII, so `length` is the octet count exactly and this cannot + // reject a name whose line would have fit. + if (name.length + HEADER_NAME_LINE_OVERHEAD > MAX_LINE_OCTETS) { + throw new MimeError( + 'MIME_ERROR', + `headers: a header name is ${name.length} characters; a field name cannot be folded, ` + + `so its line would exceed the ${MAX_LINE_OCTETS}-octet limit in RFC 5322 §2.1.1. Shorten the name.`, + ); + } + rejectCRLF(`header '${name}'`, value); + } + } + // Before any address is parsed: the count is what bounds the work below. + const recipientCount = + countRecipients(opts.to) + countRecipients(opts.cc) + countRecipients(opts.bcc); + if (recipientCount > MAX_RECIPIENTS) { + throw new MimeError( + 'INVALID_TO', + `email: too many recipients (max ${MAX_RECIPIENTS}, got ${recipientCount}) across 'to', 'cc' and 'bcc'.`, + ); + } + const from = parseAddress(opts.from, 'from'); + const to = parseAddresses(opts.to, 'to'); + if (to.length === 0) { + throw new MimeError('INVALID_TO', "email: 'to' must include at least one recipient."); + } + const cc = opts.cc ? parseAddresses(opts.cc, 'cc') : []; + const bcc = opts.bcc ? parseAddresses(opts.bcc, 'bcc') : []; + const replyTo = opts.replyTo ? parseAddress(opts.replyTo, 'replyTo') : undefined; + // Before any entry is normalized: the count is what bounds the loop below + // for a zero-byte attachment, which the byte total never sees. The type + // admits only an array, but that binds nothing at runtime, and the count + // is only a bound on the loop if the loop walks the array it was read + // from — so anything else is refused, as `toBytes` refuses a content of + // a type outside the contract. + const attachmentList = opts.attachments ?? []; + if (!Array.isArray(attachmentList)) { + throw new MimeError('MIME_ERROR', 'email: attachments must be an array.'); + } + if (attachmentList.length > MAX_ATTACHMENTS) { + throw new MimeError( + 'MIME_ERROR', + `email: too many attachments (max ${MAX_ATTACHMENTS}, got ${attachmentList.length}).`, + ); + } + // Sequential, not `Promise.all`: the running total has to be charged + // BEFORE each copy, or the entire set is already resident by the time + // anything looks at a size. Sequencing costs nothing measurable — every + // conversion in `toBytes` is synchronous except `Blob.arrayBuffer`, which + // is a memcpy of bytes the caller already holds, so there is no I/O here to + // overlap. + const attachments: Array = []; + let attachmentBytes = 0; + for (const att of attachmentList) { + const declared = declaredSize(att.content); + if (attachmentBytes + declared > MAX_ATTACHMENT_BYTES_TOTAL) { + throw attachmentsTooLargeError(attachmentBytes + declared); + } + const normalized = await normalizeAttachment(att); + // Charge the exact length too: a non-ASCII string undercounts itself + // above, and `declaredSize` reports 0 for a type `toBytes` will reject. + attachmentBytes += normalized.bytes.byteLength; + if (attachmentBytes > MAX_ATTACHMENT_BYTES_TOTAL) { + throw attachmentsTooLargeError(attachmentBytes); + } + attachments.push(normalized); + } + // Threading headers are passed through as-is — they come from the + // trusted reply path (buildReplyMessage), not user input. We still + // reject CR/LF inside them, same as any other header value. + if (opts.threading?.inReplyTo) { + rejectCRLF('threading.inReplyTo', opts.threading.inReplyTo); + } + if (opts.threading?.references) { + rejectCRLF('threading.references', opts.threading.references); + } + + return { + from, + to, + cc, + bcc, + replyTo, + subject: opts.subject ?? '', + text: opts.text, + html: opts.html, + headers: opts.headers ? { ...opts.headers } : {}, + attachments, + threading: opts.threading, + }; +} + +/** + * RFC 2047 §2 caps an encoded-word at 75 characters *including* the + * `=?UTF-8?B?` prefix (10) and the `?=` suffix (2). That leaves 63 + * characters of base64, and base64 spends 4 characters per 3 input + * bytes, so 45 source bytes is the largest payload that fits — 48 would + * need 64. + */ +const ENCODED_WORD_PAYLOAD_BYTES = 45; + +/** + * RFC 2047 encoded-word for non-ASCII text in headers (UTF-8, base64). + * + * Text longer than one word's payload becomes several encoded-words + * separated by a space, which `foldHeader` turns into CRLF + SPACE + * wherever a line would otherwise overrun (RFC 2047 §6.2: whitespace + * between adjacent encoded-words is dropped on decode either way). A + * single unbounded word breached both §2's 75-character ceiling and — + * for a long subject or display name — RFC 5322 §2.1.1's 998-octet hard + * line limit, which nothing downstream repairs. + */ +function encodeWord(text: string): string { + if (ASCII_PRINTABLE.test(text)) { + return text; + } + const encoder = new TextEncoder(); + // A code point is at most 4 UTF-8 bytes, so the buffer never needs + // more than the budget plus one character of overshoot. + const pending = new Uint8Array(ENCODED_WORD_PAYLOAD_BYTES + 4); + let length = 0; + const words: Array = []; + const flush = () => { + if (length > 0) { + words.push(`=?UTF-8?B?${base64(pending.subarray(0, length))}?=`); + length = 0; + } + }; + // `for..of` iterates code points, so a surrogate pair is never split + // across two words — RFC 2047 §5 forbids splitting a multi-octet + // character between adjacent encoded-words, and half a pair would + // decode to U+FFFD. Each chunk is base64'd on its own for the same + // reason: encoding once and slicing the output would cut characters + // mid-sequence. Every character is measured and appended exactly once, + // so a character wider than the whole budget (impossible in UTF-8) + // lands alone in its own word instead of spinning. + for (const char of text) { + const bytes = encoder.encode(char); + if (length > 0 && length + bytes.length > ENCODED_WORD_PAYLOAD_BYTES) { + flush(); + } + pending.set(bytes, length); + length += bytes.length; + } + flush(); + return words.join(' '); +} + +/** + * RFC 5322 §3.2.3, verbatim: + * + * specials = "(" / ")" / "<" / ">" / "[" / "]" / ":" / ";" / "@" / + * "\" / "," / "." / DQUOTE + * + * A display name is a §3.2.5 `phrase = 1*word`, `word = atom / + * quoted-string`, and `atext` is everything printable EXCEPT these, so an + * ASCII name containing any of them must be rendered as one quoted-string. + * The class below lists the thirteen in the RFC's order so it can be checked + * against the production by eye. This check has been wrong twice: it once + * sat inside a branch that had already excluded every special, so it was + * unreachable and a comma in a name split the address list; then the list + * itself lacked `.`, so `Dr. Smith ` went out unquoted. That is + * `obs-phrase` (§4.1, `word *(word / "." / CFWS)`), which §4 says MUST NOT + * be generated, and a strict receiver flags it as obsolete syntax (Python's + * `email` parser: "period in 'phrase'"). Every conforming generator — + * Python's `email.headerregistry.Address`, for one — emits `"Dr. Smith"`. + */ +const RFC5322_SPECIALS = /[()<>[\]:;@\\,."]/; + +function formatAddress(addr: { email: string; name?: string }): string { + if (!addr.name) { + return addr.email; + } + // Non-ASCII → RFC 2047 encoded-word. + if (!ASCII_PRINTABLE.test(addr.name)) { + return `${encodeWord(addr.name)} <${addr.email}>`; + } + // ASCII with any specials → quoted-string. Escape backslashes first so + // the subsequent quote-escape doesn't double-escape them. + if (RFC5322_SPECIALS.test(addr.name)) { + const escaped = addr.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escaped}" <${addr.email}>`; + } + // Plain atom / atom-with-spaces. + return `${addr.name} <${addr.email}>`; +} + +function formatAddressList(addrs: Array<{ email: string; name?: string }>): string { + return addrs.map(formatAddress).join(', '); +} + +/** + * Soft-fold a long header line. Fold points, strongest first: address-list + * separators (a `, ` outside any double-quoted string), then any remaining + * unquoted whitespace inside a part. A naive `split(/, /)` would split + * inside `"Smith, John"`, corrupting quoted display names, and folding only + * between whole parts is not enough — one recipient carrying a long encoded + * display name, or a `References:` run of message IDs, is a single part + * that can blow past RFC 5322 §2.1.1's 998-octet hard limit on its own. + * + * RFC 2047 §2 limits any header line carrying an encoded-word to 76 + * characters; RFC 5322 §2.1.1 makes 78 a SHOULD and 998 a MUST. Folding at + * 76 satisfies all three. A comma always stays on the line it terminates. + * A token longer than the budget still gets its own line, which is the + * best a WSP-only fold rule allows (§3.2.2 permits folding only at WSP). + * + * One pass, and the only thing kept per input character is the folded + * output itself: each finished line is one flat string in `lines`. The + * earlier shape — tokenize the whole value into an array of slices, wrap + * each in a `{ text, comma }` object, then grow the result with `out +=` + * per token — held about 130 bytes of heap per token for the length of the + * fold (a slice, an object and a cons-string node each), so a legal 3 MB + * subject of one-letter words terminated a 128 MB isolate. That was a + * per-TOKEN cost, invisible to any size projection: the message fit. + */ +function foldHeader(name: string, value: string): string { + const line = `${name}: ${value}`; + if (line.length <= 78) { + return line; + } + const MAX_LINE = 76; + const lines: Array = []; + // Pieces of the line being built, joined by the single space each fold + // point becomes. The first line starts on `Name:`; a continuation starts + // on the empty string so the join yields its leading space. + let current: Array = [`${name}:`]; + let lineLen = name.length + 1; + let chunks = 0; + const emit = (text: string, comma: boolean) => { + chunks++; + const piece = comma ? `${text},` : text; + if (piece.length > 0 && lineLen + 1 + piece.length > MAX_LINE) { + lines.push(current.join(' ')); + current = ['', piece]; + lineLen = 1 + piece.length; + } else { + current.push(piece); + lineLen += 1 + piece.length; + } + }; + // A token is emitted only once the scanner knows whether it is the last + // of its part, because that one carries the part's comma. An empty part + // (`a, , b`) still owes its separator, so it emits an empty chunk rather + // than losing a comma. + let tokenStart = -1; + let heldStart = -1; + let heldEnd = -1; + const endToken = (i: number) => { + if (tokenStart < 0) { + return; + } + if (heldStart >= 0) { + emit(value.slice(heldStart, heldEnd), false); + } + heldStart = tokenStart; + heldEnd = i; + tokenStart = -1; + }; + const endPart = (isLast: boolean) => { + if (heldStart >= 0) { + emit(value.slice(heldStart, heldEnd), !isLast); + heldStart = -1; + } else { + emit('', !isLast); + } + }; + let inQuotes = false; + let escape = false; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (escape) { + escape = false; + if (tokenStart < 0) { + tokenStart = i; + } + continue; + } + if (ch === '\\' && inQuotes) { + escape = true; + if (tokenStart < 0) { + tokenStart = i; + } + continue; + } + if (ch === '"') { + inQuotes = !inQuotes; + if (tokenStart < 0) { + tokenStart = i; + } + continue; + } + if (!inQuotes && ch === ',' && value[i + 1] === ' ') { + endToken(i); + endPart(false); + i++; // skip the space + continue; + } + if (!inQuotes && (ch === ' ' || ch === '\t')) { + endToken(i); + continue; + } + if (tokenStart < 0) { + tokenStart = i; + } + } + endToken(value.length); + endPart(true); + // One unbreakable token — a single long Message-ID, say. There is + // nowhere to fold, and `Name:\r\n ` would shorten nothing. + if (chunks <= 1) { + return line; + } + lines.push(current.join(' ')); + return lines.join(CRLF); +} + +/** RFC 5322 §2.1.1: each line MUST be at most 998 octets, excluding CRLF. */ +const MAX_LINE_OCTETS = 998; +/** ` filename="` + `"`: the shortest line a quoted filename parameter can fold onto. */ +const QUOTED_FILENAME_LINE_OVERHEAD = 12; +/** + * ` ` + `;`: the shortest line an attachment's media type can fold onto, + * the whole value taken as one token. `foldHeader` would split a value + * of several parameters at their spaces, so this bound is exact for the + * one-token media type every attachment carries in practice and + * conservative for a run of parameters that together pass the line + * limit; that is accepted, because the alternative — find the fold points + * first — is the scan this bound exists to run ahead of. + */ +const CONTENT_TYPE_LINE_OVERHEAD = 2; +/** + * `Content-ID: `: the only line an attachment's Content-ID can sit on. The + * value is one token — `` — so `foldHeader` cannot fold it onto a + * continuation line the way it can a media type, and the field name is + * part of what the id must fit beside. Exact, not conservative: the `<` + * and `>` the builder adds around a bare id are charged at the call. + */ +const CONTENT_ID_LINE_OVERHEAD = 'Content-ID: '.length; +/** ` "` + `"`: the shortest line a quoted display name can fold onto. */ +const QUOTED_NAME_LINE_OVERHEAD = 3; +/** `:` alone: the shortest line a header name can sit on, its value folded onto the next. */ +const HEADER_NAME_LINE_OVERHEAD = 1; + +/** + * First line of `text` that exceeds the octet limit, if any. + * + * Every line is scanned, not just the top header block: a multipart part's + * own headers (`Content-Disposition: …; filename="…"`) live inside the body + * and are just as unfoldable. Body content cannot trip this — text is + * quoted-printable re-encoded above 990 octets and base64 is emitted at a + * fixed width — so a hit is always a header. + * + * One pass over the string, counting UTF-8 octets per CRLF-delimited line, + * with NO allocation. The earlier `text.split(CRLF)` held one array slot and + * one substring per line for the whole scan, and a legal body of millions of + * short lines — pasted logs, say — is millions of lines. Only a CRLF pair + * ends a line, exactly as the split did; a bare CR or LF is content. + */ +function findOverlongLine(text: string): { index: number; octets: number } | undefined { + let index = 0; + let octets = 0; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + if (c === 0x0d && text.charCodeAt(i + 1) === 0x0a) { + if (octets > MAX_LINE_OCTETS) { + return { index, octets }; + } + index++; + octets = 0; + i++; + } else if (c < 0x80) { + octets += 1; + } else if (c < 0x800) { + octets += 2; + } else if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) { + const d = text.charCodeAt(i + 1); + if (d >= 0xdc00 && d <= 0xdfff) { + octets += 4; // the pair is one code point + i++; + } else { + octets += 3; // lone surrogate encodes as U+FFFD + } + } else { + octets += 3; + } + } + return octets > MAX_LINE_OCTETS ? { index, octets } : undefined; +} + +/** + * Escape a value for a quoted-string parameter (RFC 2045 §5.1 value, whose + * quoted-string comes from RFC 5322 §3.2.4). + * + * `qtext` excludes both `\\` and `"`, so each may appear only as a + * quoted-pair. Deleting the quotes and leaving backslashes raw — what this + * did before — is wrong in two ways: a TRAILING backslash escapes the + * closing quote and leaves the parameter unterminated, and every embedded + * backslash is silently eaten by a conformant parser, so a Windows path + * `C:\\Users\\bob\\report.pdf` arrived as `C:Usersbobreport.pdf`. Measured, + * Python's `email.policy.default` and postal-mime both report + * `InvalidHeaderDefect: end of header inside quoted string` on the first + * case and disagree with a naive parser on the second. + * + * Backslash first, then quote — the other order would double-escape. + * + * Note this file's own `foldHeader` already tracks quoted-pairs when + * folding, so the emitter was contradicting the folder. + */ +function quoteParam(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function base64(bytes: Uint8Array): string { + // workerd, browsers, and Node 18+ all expose btoa + binary string conversion. + let binary = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return btoa(binary); +} + +function base64Wrapped(bytes: Uint8Array): string { + const encoded = base64(bytes); + const lines: Array = []; + for (let i = 0; i < encoded.length; i += 76) { + lines.push(encoded.slice(i, i + 76)); + } + return lines.join(CRLF); +} + +function quotedPrintable(text: string): string { + // Encode as UTF-8 bytes then quote per RFC 2045. + const bytes = new TextEncoder().encode(text); + // Emit into a byte buffer, not a string: `out += chunk` per byte builds a + // V8 cons-string tree whose nodes cost ~56 bytes per encoded byte and stay + // live until the message is flattened. Measured, 1 MiB of non-ASCII text + // held 57 MiB that way; a 1.9 MiB body killed a 128 MB isolate while its + // 5.9 MiB message was under the limit. Every input byte yields at most 3 + // output bytes, a soft break (3) lands at most once per 73 content bytes, + // and a lone LF becomes CRLF, so this buffer never grows. + const out = new Uint8Array(Math.ceil(bytes.length * 3 * (76 / 73)) + 8); + let n = 0; + let lineLen = 0; + const HEX = '0123456789ABCDEF'; + const put = (c: number) => { + out[n++] = c; + }; + const flush = (c0: number, c1?: number, c2?: number) => { + const width = c2 !== undefined ? 3 : c1 !== undefined ? 2 : 1; + if (lineLen + width > 75) { + put(0x3d); + put(0x0d); + put(0x0a); + lineLen = 0; + } + put(c0); + if (c1 !== undefined) { + put(c1); + } + if (c2 !== undefined) { + put(c2); + } + lineLen += width; + }; + const quote = (b: number) => flush(0x3d, HEX.charCodeAt(b >> 4), HEX.charCodeAt(b & 0xf)); + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i]; + if (b === 0x0a) { + put(0x0d); + put(0x0a); + lineLen = 0; + continue; + } + if (b === 0x0d) { + // CR alone or part of CRLF — emit CRLF on the LF side, skip standalone CR. + if (bytes[i + 1] === 0x0a) { + continue; + } + put(0x0d); + put(0x0a); + lineLen = 0; + continue; + } + if (b === 0x20 || b === 0x09) { + // Defer encoding decision: a space at end-of-line must be quoted. + const isLast = i === bytes.length - 1 || bytes[i + 1] === 0x0a || bytes[i + 1] === 0x0d; + if (isLast) { + quote(b); + } else { + flush(b); + } + continue; + } + if (b === 0x3d || b < 0x20 || b > 0x7e) { + quote(b); + continue; + } + flush(b); + } + return bytesToAsciiString(out.subarray(0, n)); +} + +/** Pure-ASCII bytes to a string, in the same 32K chunks `base64` uses. */ +function bytesToAsciiString(bytes: Uint8Array): string { + let s = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + s += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return s; +} + +/** + * Whether a body must be quoted-printable encoded: anything outside + * printable ASCII, or any line longer than 990 characters (RFC 5322 §2.1.1 + * makes 998 octets the hard limit). Only LF and CRLF end a line; a bare CR + * is a control character like any other and forces the encoding. + * + * One pass over the string with NO allocation. The earlier shape — strip + * every line break with a global `replace`, test the copy, then `split` the + * text into an array of lines — was linear in the message but held a copy + * of the body plus one array slot and one substring per line, and a legal + * 6 MB body of millions of two-character lines needed 64 MB for the strip + * and another 24-40 MB for the split. Those are per-LINE costs, invisible + * to any size projection: the message fit. + */ +function needsQuotedPrintable(text: string): boolean { + let lineLen = 0; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + if (c === 0x0a) { + lineLen = 0; + continue; + } + if (c === 0x0d) { + if (text.charCodeAt(i + 1) === 0x0a) { + continue; + } + return true; + } + if (c < 0x20 || c > 0x7e) { + return true; + } + lineLen++; + if (lineLen > 990) { + return true; + } + } + return false; +} + +/** + * The 7bit body: `text` with every bare LF widened to CRLF, and every CRLF + * kept. Only called once `needsQuotedPrintable` has returned false, so every + * character is printable ASCII, LF, or the CR of a CRLF — one byte each — + * and a byte buffer is exact. + * + * NOT `text.replace(/\r?\n/g, CRLF)`: V8 builds a global-regex replacement + * from a parts array that holds two or three slots per match, grown by + * doubling, so that one line cost 144 MB of heap on a legal 6 MB body of + * 3M lines and terminated a 128 MB isolate — the largest single allocation + * in the whole build, for a body that renders to 9 MB. The buffer here is + * the output size, known up front from one counting pass. A body that is + * already CRLF-only (or has no line breaks) is returned as-is. + */ +function to7bitBody(text: string): string { + let bare = 0; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 0x0a && (i === 0 || text.charCodeAt(i - 1) !== 0x0d)) { + bare++; + } + } + if (bare === 0) { + return text; + } + const out = new Uint8Array(text.length + bare); + let n = 0; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + if (c === 0x0a && (i === 0 || text.charCodeAt(i - 1) !== 0x0d)) { + out[n++] = 0x0d; + } + out[n++] = c; + } + return bytesToAsciiString(out); +} + +/** + * Lower bound on the octets `text` will occupy in the rendered message, in + * one pass over the string and with NO allocation. Every UTF-8 byte yields at + * least one octet on either body path — the 7bit path copies bytes and only + * ever widens a bare LF to CRLF; the quoted-printable path emits each + * non-ASCII byte as `=XX` (3 octets) and every other byte as itself, and its + * soft breaks only add. So `ascii + 3 * nonAscii` can never overshoot the + * real size, which keeps a false rejection impossible; `buildMime`'s final + * check remains the authority. `TextEncoder.encode` would give the same count + * but at the price of a full copy, which is exactly the allocation this + * bound exists to avoid. + */ +function minBodyOctets(text: string): number { + let octets = 0; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + if (c < 0x80) { + octets += 1; + } else if (c < 0x800) { + octets += 6; // 2 UTF-8 bytes, each `=XX` + } else if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) { + const d = text.charCodeAt(i + 1); + if (d >= 0xdc00 && d <= 0xdfff) { + octets += 12; // 4 UTF-8 bytes for the pair + i++; + continue; + } + octets += 9; // lone surrogate encodes as U+FFFD, 3 bytes + } else { + octets += 9; // 3 UTF-8 bytes + } + } + return octets; +} + +/** Octets a base64 body of `n` bytes occupies at 76 columns, excluding CRLF. */ +function base64Octets(n: number): number { + return Math.ceil(n / 3) * 4; +} + +interface MimePart { + headers: Array<[string, string]>; + body: string; +} + +function renderPart(part: MimePart): string { + const lines = part.headers.map(([k, v]) => foldHeader(k, v)); + return lines.join(CRLF) + CRLF + CRLF + part.body; +} + +function buildBodyPart(contentType: string, body: string): MimePart { + const useQp = needsQuotedPrintable(body); + return { + headers: [ + ['Content-Type', `${contentType}; charset=UTF-8`], + ['Content-Transfer-Encoding', useQp ? 'quoted-printable' : '7bit'], + ], + body: useQp ? quotedPrintable(body) : to7bitBody(body), + }; +} + +function buildAttachmentPart(att: NormalizedAttachment): MimePart { + const headers: Array<[string, string]> = [ + ['Content-Type', `${att.contentType}; name="${quoteParam(att.filename)}"`], + ['Content-Transfer-Encoding', 'base64'], + ['Content-Disposition', `${att.disposition}; filename="${quoteParam(att.filename)}"`], + ]; + if (att.contentId) { + const cid = att.contentId.startsWith('<') ? att.contentId : `<${att.contentId}>`; + headers.push(['Content-ID', cid]); + } + return { headers, body: base64Wrapped(att.bytes) }; +} + +function makeBoundary(prefix: string): string { + // 16 random bytes → 24-char base64. Workers + browsers expose crypto. + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return `=_void_${prefix}_${base64(bytes).replace(/[+/=]/g, '')}`; +} + +function wrapMultipart( + type: 'mixed' | 'alternative' | 'related', + parts: Array, + extra: Record = {}, +): MimePart { + const boundary = makeBoundary(type); + const headerEntries: Array<[string, string]> = [ + ['Content-Type', `multipart/${type}; boundary="${boundary}"`], + ]; + for (const [k, v] of Object.entries(extra)) { + headerEntries.push([k, v]); + } + const body = + parts.map((p) => `--${boundary}${CRLF}${renderPart(p)}`).join(CRLF) + + `${CRLF}--${boundary}--${CRLF}`; + return { headers: headerEntries, body }; +} + +const RESERVED_HEADERS = new Set( + [ + 'from', + 'to', + 'cc', + 'bcc', + 'subject', + 'reply-to', + 'date', + 'message-id', + // Threading headers are owned by replyEmail() — accepting them via + // the user-supplied `headers` map would let a caller spoof reply + // chains. replyEmail builds them itself from the original message. + 'in-reply-to', + 'references', + 'mime-version', + 'content-type', + 'content-transfer-encoding', + 'content-disposition', + ].map((h) => h.toLowerCase()), +); + +/** + * Lower bound on the octets one header VALUE occupies once `encodeWord` (or + * `formatAddress`, for a display name) and `foldHeader` have rendered it, in + * one pass over the string and with NO allocation. + * + * `encodeWord` leaves a value alone when every character is printable ASCII + * and otherwise B-encodes ALL of it, so the two paths need different terms: + * + * - Plain: every character is at least one octet, EXCEPT whitespace. + * `foldHeader` re-joins a long value from its non-WSP tokens, so a run of + * spaces or tabs collapses to one, and a bound that charged them could + * reject a message that fits. Only the non-WSP characters count. + * - Encoded: each RFC 2047 word carries at most `ENCODED_WORD_PAYLOAD_BYTES` + * (45) UTF-8 bytes as base64 — `ceil(n / 3) * 4 >= 4n / 3` octets — inside + * 12 octets of `=?UTF-8?B?` and `?=`, so there are at least `floor(n / 45)` + * words' worth of framing. Whitespace inside the value is base64 like any + * other byte; only the single separators between words fold. + * + * Folding (`foldHeader`) can only ADD octets beyond that — a `CRLF SP` per + * line — and quoting a display name (`formatAddress`) adds the quotes and + * escapes, so neither path can render fewer octets than this returns. + * Measured, the encoded bound is 96% of the rendered size for CJK, where the + * real cost is 5 octets per character; a UTF-16 `length` would have counted + * 1 and let the isolate die on a header five times over the limit. + */ +function minHeaderValueOctets(value: string): number { + let utf8 = 0; + let visible = 0; + let encoded = false; + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c < 0x80) { + utf8 += 1; + if (c === 0x20 || c === 0x09) { + continue; + } + if (c < 0x20 || c === 0x7f) { + encoded = true; + } + visible += 1; + } else { + encoded = true; + if (c < 0x800) { + utf8 += 2; + } else if (c >= 0xd800 && c <= 0xdbff && i + 1 < value.length) { + const d = value.charCodeAt(i + 1); + if (d >= 0xdc00 && d <= 0xdfff) { + utf8 += 4; // the pair is one code point + i++; + continue; + } + utf8 += 3; // lone surrogate encodes as U+FFFD + } else { + utf8 += 3; + } + } + } + if (!encoded) { + return visible; + } + return Math.floor((utf8 * 4) / 3) + 12 * Math.floor(utf8 / ENCODED_WORD_PAYLOAD_BYTES); +} + +/** Lower bound on one rendered address: its display name, plus the address itself verbatim. */ +function minAddressOctets(addr: { email: string; name?: string }): number { + return addr.email.length + (addr.name ? minHeaderValueOctets(addr.name) : 0); +} + +/** + * Lower bound on the octets of a value rendered VERBATIM — never through + * `encodeWord` — into a header `foldHeader` may still fold: `In-Reply-To`, + * `References`, and an attachment part's `Content-Type` media type and + * `Content-ID`. Every non-WSP UTF-16 code unit is at least one octet of + * UTF-8; a WSP run may collapse to a single space when the line folds, so + * none is charged. The `<>` around a Content-ID and the fold's `CRLF SP` + * only add. + * + * `minHeaderValueOctets` cannot stand in here: a non-ASCII value sends it + * down the encoded-word formula (4.8 octets per CJK character) while these + * values render as raw UTF-8 (3), so it would overshoot and could reject a + * message that fits. + */ +function minVerbatimOctets(value: string): number { + let octets = 0; + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c !== 0x20 && c !== 0x09) { + octets += 1; + } + } + return octets; +} + +/** + * Octets every attachment part costs before any of its values: what + * `wrapMultipart`, `renderPart` and `buildAttachmentPart` emit for a part + * whose filename, media type, disposition and content are all empty. Each + * term is emitted verbatim on every path — `foldHeader` returns a short + * line as-is and re-joins a long one from its tokens with at least one + * space between them, so the space after `Name:` and the one after `;` are + * floors, not counts — and a fold's `CRLF SP` only adds. The boundary's + * random suffix is left out: `makeBoundary` strips `+`, `/` and `=`, so it + * has no provable minimum, and `mixed` is the shortest prefix an attachment + * part can sit under. + */ +const ATTACHMENT_PART_MIN_OCTETS: number = + CRLF.length + // the join between this part and the one before it + ('--=_void_mixed_'.length + CRLF.length) + + ('Content-Type: '.length + '; name=""'.length + CRLF.length) + + ('Content-Transfer-Encoding: base64'.length + CRLF.length) + + ('Content-Disposition: '.length + '; filename=""'.length + CRLF.length) + + CRLF.length; // the blank line before the body + +/** + * Cap on the attachment COUNT, checked against the raw list in + * `normalizeEmail` before a single entry is normalized. + * + * Every part costs at least `ATTACHMENT_PART_MIN_OCTETS` of framing plus + * its filename twice, and `normalizeAttachment` refuses an empty filename, + * so the size projection in `buildMime` charges a part at least 119 octets + * and refuses one more part than this whatever else the message holds. + * That projection runs only after `normalizeEmail` has normalized EVERY + * entry, and `MAX_ATTACHMENT_BYTES_TOTAL` cannot bound that loop for a + * zero-byte attachment — its running total never advances — while the + * normalized copy (the object, a fresh `Uint8Array`, the `TextEncoder` + * that made it) costs about 275 bytes of live heap per entry. Measured, 2M + * empty attachments terminated a 128 MB isolate in 0.3 s instead of + * returning `MIME_ERROR`; this many normalize in 24 MB and are then refused + * by the post-render size check under the same ceiling. + * + * Derived, not chosen: it is exactly the largest count the projection can + * admit, so this cannot reject a message that would have been accepted, and + * `buildMime` remains the authority on the final size. Same class as + * `MAX_ATTACHMENT_BYTES_TOTAL` — a developer-experience guardrail on the + * app's own input, not the security boundary. + */ +export const MAX_ATTACHMENTS: number = Math.floor( + MAX_MESSAGE_BYTES / (ATTACHMENT_PART_MIN_OCTETS + 2), +); + +/** + * Octets every custom header costs around its value: the `: ` after the + * name and the CRLF that ends its line. Exact, not a floor, once the name + * is counted: `buildMime` renders each non-reserved custom header through + * `foldHeader`, which emits the name verbatim, then `: ` — or, when the + * value's first token does not fit beside the name, `:` CRLF SP, which is + * more — and `join(CRLF)` ends every header line, a custom header never + * being the last of the block (the body part's own headers follow it). + */ +const CUSTOM_HEADER_FRAMING_OCTETS: number = ': '.length + CRLF.length; + +/** + * Lower bound on the octets of every header `buildMime` renders from + * caller-supplied values: `Subject`, the custom headers, the addresses in + * `From`, `To`, `Cc` and `Reply-To`, the `In-Reply-To` / `References` pair + * `replyEmail` threads through (remote-sender controlled, up to the whole + * inbound message), and each attachment part's `Content-Type`, `Content-ID` + * and — twice, once per header it is rendered into — filename. `Bcc` is + * never rendered and a reserved custom header is dropped, so neither is + * counted — charging either could reject a message that fits. The fixed + * `Name: ` and `CRLF` of the headers `buildMime` itself names — `Subject`, + * the address lists, the threading pair — are omitted for the same reason + * a lower bound may omit any term. They are NOT omitted where the caller + * controls the name or the count, because there they can be the whole + * message: + * + * - A custom header's name is rendered verbatim, and `normalizeEmail` + * bounds ONE name at under a kilobyte, not the sum, nor the count. With + * empty values the names and the `: ` CRLF around each are the entire + * size, and the framing is exact once the name is counted (see + * `CUSTOM_HEADER_FRAMING_OCTETS`), so charging both cannot reject a + * message that fits. Measured before the name was charged, 60k empty + * headers with 500-character names — a 30 MB message — projected to + * zero, rendered in full and peaked at 121 MB of a 128 MB isolate; 100k + * of them (50 MB) went to 169 MB. + * - An attachment part: `normalizeAttachment` bounds ONE filename at under + * a kilobyte, not the sum, and a message is free to carry any number of + * parts, so the filename and the framing every part is wrapped in are + * the whole size of a message of empty attachments. Measured, 50k + * zero-byte attachments with legal 986-character filenames rendered to + * 106 MB — held three to four times over between the parts, their join, + * `rawText` and `raw` — and terminated a 128 MB isolate before the final + * size check could return `MIME_ERROR`. + * + * The header COUNT is deliberately not bounded here. Each header now costs + * at least five octets (a one-character name, `: `, CRLF), so the count + * the projection can admit is finite, and past a few hundred thousand keys + * the caller's own object — measured at about 74 bytes per key, before + * `sendEmail` is called — is the dominant cost, which no check inside this + * module can pre-empt. Rendering does add live heap per header (about + * 150 bytes: two `Object.entries`, the `topHeaders` pair, the folded line), + * so a legal message of 500k four-character headers still does not fit a + * 128 MB isolate while one of 400k does; a bound on that would be the + * framework's own number — the platform proxy bounds bytes, not headers — + * and is left as a product decision rather than invented here. + */ +function minHeaderOctets(email: NormalizedEmail): number { + let octets = minHeaderValueOctets(email.subject); + for (const [name, value] of Object.entries(email.headers)) { + if (!RESERVED_HEADERS.has(name.toLowerCase())) { + // `HEADER_NAME` admits only ASCII, so `length` is the name's octets. + octets += name.length + CUSTOM_HEADER_FRAMING_OCTETS + minHeaderValueOctets(value); + } + } + octets += minAddressOctets(email.from); + for (const addr of email.to) { + octets += minAddressOctets(addr); + } + for (const addr of email.cc) { + octets += minAddressOctets(addr); + } + if (email.replyTo) { + octets += minAddressOctets(email.replyTo); + } + if (email.threading?.inReplyTo) { + octets += minVerbatimOctets(email.threading.inReplyTo); + } + if (email.threading?.references) { + octets += minVerbatimOctets(email.threading.references); + } + for (const att of email.attachments) { + // The filename sits inside a quoted-string in both `name="…"` and + // `filename="…"`, and `foldHeader` never splits a quoted-string, so + // every code unit of it — whitespace included — is at least one octet + // on each line; `quoteParam`'s escapes only add. + octets += ATTACHMENT_PART_MIN_OCTETS + 2 * att.filename.length; + octets += minVerbatimOctets(att.contentType); + if (att.contentId) { + octets += minVerbatimOctets(att.contentId); + } + } + return octets; +} + +function generateMessageId(fromEmail: string): string { + const domain = fromEmail.split('@')[1] ?? 'localhost'; + const bytes = new Uint8Array(12); + crypto.getRandomValues(bytes); + const id = base64(bytes).replace(/[+/=]/g, ''); + return `<${id}@${domain}>`; +} + +/** + * Build the raw RFC 5322 message body. Returns the message bytes plus the + * generated Message-ID header so callers can surface it in the result. + */ +export function buildMime(email: NormalizedEmail): { raw: Uint8Array; messageId: string } { + // Project the message size BEFORE rendering anything — headers included. + // The final check below sees the message only after every header has been + // encoded and folded and every body quoted-printable encoded, all of it + // rendered into `rawText` and encoded again. Measured, that peak is about + // 11x the UTF-8 bytes of a non-ASCII body, and `encodeWord` alone holds + // over 3x the UTF-8 bytes of a non-ASCII header (its array of encoded + // words plus their join) before the fold, the join, `rawText` and `raw` + // each add another copy of the rendered header. A body several times over the + // limit, or a 20 MB subject or display name, terminated a 128 MB isolate + // instead of returning the `MIME_ERROR` the guide promises. Each term is + // a lower bound on the rendered size, so this never rejects a message + // that would have fit; the final check stays the authority. + const projected = + minHeaderOctets(email) + + (email.text ? minBodyOctets(email.text) : 0) + + (email.html ? minBodyOctets(email.html) : 0) + + email.attachments.reduce((sum, a) => sum + base64Octets(a.bytes.byteLength), 0); + if (projected > MAX_MESSAGE_BYTES) { + throw new MimeError( + 'MIME_ERROR', + `email: message is at least ${projected} bytes once encoded, which exceeds Cloudflare limit of ${MAX_MESSAGE_BYTES} bytes.`, + ); + } + + const messageId = generateMessageId(email.from.email); + const date = new Date().toUTCString().replace('GMT', '+0000'); + + const topHeaders: Array<[string, string]> = [ + ['Date', date], + ['From', formatAddress(email.from)], + ['To', formatAddressList(email.to)], + ]; + if (email.cc.length > 0) { + topHeaders.push(['Cc', formatAddressList(email.cc)]); + } + // Bcc is intentionally omitted from the rendered headers. The CF `send_email` + // binding fans out one envelope per recipient, but every envelope carries + // the same raw MIME body — so a `Bcc:` header here would expose the BCC list + // to every To/Cc recipient. The envelope itself handles BCC delivery. + if (email.replyTo) { + topHeaders.push(['Reply-To', formatAddress(email.replyTo)]); + } + topHeaders.push(['Subject', encodeWord(email.subject)]); + topHeaders.push(['Message-ID', messageId]); + // Threading headers from the trusted reply path. These are reserved + // against user-supplied `headers` (RESERVED_HEADERS) so the only way + // they land in the wire MIME is via `email.threading`. + if (email.threading?.inReplyTo) { + topHeaders.push(['In-Reply-To', email.threading.inReplyTo]); + } + if (email.threading?.references) { + topHeaders.push(['References', email.threading.references]); + } + topHeaders.push(['MIME-Version', '1.0']); + + for (const [name, value] of Object.entries(email.headers)) { + if (RESERVED_HEADERS.has(name.toLowerCase())) { + continue; + } + topHeaders.push([name, encodeWord(value)]); + } + + const textPart = email.text ? buildBodyPart('text/plain', email.text) : undefined; + const htmlPart = email.html ? buildBodyPart('text/html', email.html) : undefined; + + let bodyPart: MimePart; + if (textPart && htmlPart) { + bodyPart = wrapMultipart('alternative', [textPart, htmlPart]); + } else { + bodyPart = (textPart ?? htmlPart) as MimePart; + } + + if (email.attachments.length > 0) { + const inlineAttachments = email.attachments.filter((a) => a.disposition === 'inline'); + const fileAttachments = email.attachments.filter((a) => a.disposition !== 'inline'); + + if (inlineAttachments.length > 0 && htmlPart) { + // Wrap (alternative or html) with inline attachments via multipart/related. + bodyPart = wrapMultipart('related', [ + bodyPart, + ...inlineAttachments.map(buildAttachmentPart), + ]); + } else { + // No inline images — treat all as regular file attachments. + fileAttachments.push(...inlineAttachments); + } + if (fileAttachments.length > 0) { + bodyPart = wrapMultipart('mixed', [bodyPart, ...fileAttachments.map(buildAttachmentPart)]); + } + } + + // Promote the body part's headers into the top-level headers. + for (const [k, v] of bodyPart.headers) { + topHeaders.push([k, v]); + } + + const headerBlock = topHeaders.map(([k, v]) => foldHeader(k, v)).join(CRLF); + const rawText = headerBlock + CRLF + CRLF + bodyPart.body; + const raw = new TextEncoder().encode(rawText); + + // RFC 5322 §2.1.1 makes 998 octets per line a MUST, and RFC 5321 + // §4.5.3.1.6 caps an SMTP text line at 1000 including CRLF. `foldHeader` + // can only fold at whitespace (§3.2.2), so a long token with none — + // a JWT, a signed URL, a machine-generated Message-ID — cannot be folded + // at all and leaks through both its single-chunk early return AND its + // fold loop, which still gives an over-long chunk its own line. + // + // Checked here, after rendering, rather than per field: this is the only + // place that sees every header path at once, including the header NAME + // and the `filename=` / `name=` / `Content-ID` parameters that are built + // outside `foldHeader`. Encoding the value instead of rejecting is not a + // general remedy — RFC 2047 §5 permits encoded-words only in unstructured + // fields, comments and phrases, so it is illegal in `References`, + // `In-Reply-To`, `Content-ID` and parameter values, where a receiver + // would read `=?UTF-8?B?…?=` as a literal id or filename. Truncating + // silently corrupts: a clipped `References` breaks threading with no + // signal, a clipped `Content-ID` orphans a `cid:` reference. + const overlong = findOverlongLine(rawText); + if (overlong !== undefined) { + throw new MimeError( + 'MIME_ERROR', + `email: header line ${overlong.index + 1} is ${overlong.octets} octets, over the ${MAX_LINE_OCTETS}-octet limit in RFC 5322 §2.1.1. ` + + 'A value with no whitespace cannot be folded — shorten it, or move it into the body.', + ); + } + + if (raw.byteLength > MAX_MESSAGE_BYTES) { + throw new MimeError( + 'MIME_ERROR', + `email: message size ${raw.byteLength} bytes exceeds Cloudflare limit of ${MAX_MESSAGE_BYTES} bytes.`, + ); + } + + return { raw, messageId }; +} diff --git a/packages/void/src/runtime/email/testing.ts b/packages/void/src/runtime/email/testing.ts new file mode 100644 index 000000000..07c3e508b --- /dev/null +++ b/packages/void/src/runtime/email/testing.ts @@ -0,0 +1,530 @@ +/** + * Test harnesses for void/email. + * + * createEmailTestHarness — captures outbound `sendEmail` calls. + * createInboundTestHarness — delivers a synthesized message through your + * inbound route table and captures any + * replies / forwards / rejects. + * + * import { createEmailTestHarness } from "void/email/testing"; + * const harness = createEmailTestHarness(); + * await sendEmail({ from: "a@x.dev", to: "b@y.dev", subject: "hi", text: "yo" }); + * expect(harness.messages[0].subject).toBe("hi"); + * harness.dispose(); + * + * import { createInboundTestHarness } from "void/email/testing"; + * const inbound = createInboundTestHarness({ + * routes: { "support+[ticket]": supportHandler, _default: catchAll }, + * }); + * const result = await inbound.deliver({ + * from: "user@example.com", + * // The WIRE address: `+@`, as the platform delivers + * // it. The harness strips `+` before matching, so passing the + * // user-facing "support+abc-1@app.dev" would read `support` as the slug. + * to: "myslug+support+abc-1@app.dev", + * subject: "help", + * text: "thanks", + * }); + * expect(result.handler).toBe("support+[ticket]"); + * expect(result.params).toEqual({ ticket: "abc-1" }); + */ + +import { + compareEmailRoutes, + compileEmailPattern, + type CompiledEmailPattern, +} from '../../router/email.ts'; +import { registerEmailSink, type CapturedMessage } from './dev-inbox.ts'; +import { + createMessageProxy, + type EmailAction, + type EmailHandler, + type EmailHandlerInfo, + MAX_INBOUND_BYTES, + MAX_INBOUND_HEADERS, +} from './inbound.ts'; +import { buildMime, normalizeEmail } from './mime.ts'; + +export interface EmailTestHarness { + /** Messages captured since the harness was created (or since the last clear). */ + readonly messages: ReadonlyArray; + /** Discard captured messages. */ + clear(): void; + /** Stop intercepting sends. After dispose, real binding sends resume. */ + dispose(): void; +} + +export function createEmailTestHarness(): EmailTestHarness { + const messages: Array = []; + const unregister = registerEmailSink((m) => { + messages.push(m); + }); + return { + get messages() { + return messages; + }, + clear() { + messages.length = 0; + }, + dispose() { + unregister(); + messages.length = 0; + }, + }; +} + +export type { CapturedMessage } from './dev-inbox.ts'; + +// --------------------------------------------------------------------------- +// Inbound test harness +// --------------------------------------------------------------------------- + +export interface InboundDeliverOptions { + from: string; + to: string; + subject?: string; + text?: string; + html?: string; + headers?: Record; + /** Override the constructed MIME body. Bytes preferred; strings encoded as UTF-8. */ + raw?: string | Uint8Array; +} + +export interface InboundReplyCapture { + from: string; + to: string; + raw: Uint8Array; +} + +export interface InboundForwardCapture { + to: string; + headers?: Headers; +} + +export interface InboundDeliveryResult { + /** Which handler key matched. `undefined` if the message was not handled. */ + handler?: string; + params: Record; +} + +export interface InboundTestHarness { + readonly replies: ReadonlyArray; + readonly forwards: ReadonlyArray; + readonly rejects: ReadonlyArray; + readonly deliveries: ReadonlyArray; + deliver(opts: InboundDeliverOptions): Promise; + clear(): void; +} + +// Reserved key in the `routes` map that is not an address pattern: +// _default — fallback handler (mirrors email/_default.ts) + +export interface InboundTestHarnessOptions { + /** + * Address pattern → handler. Pattern syntax matches the file-based + * convention: `support`, `support+[ticket]`, `[user]`, `[user]+[tag]`. + * Reserved key: `_default` (fallback handler, mirrors email/_default.ts). + */ + routes: Record; + /** Forwarded as the second arg to handlers. Defaults to `{}`. */ + env?: unknown; + /** + * Project slug used to strip the platform-injected `+` prefix from + * `message.to` before handlers see it (mirrors production: the dispatched + * `message.to` is `@`, never `+@`). When + * omitted, the harness infers the slug from each delivery's recipient + * (everything before the first `+` in the local-part) so existing tests + * with synthetic `slug+tag@domain` recipients keep working without + * configuration. + * + * `null` means NO platform prefix at all — a worker deployed on your own + * Cloudflare account (`void deploy --platform cloudflare`) receives the + * address the handler author wrote, and the dispatcher matches the full + * local part: `support@mail.acme.com` reaches `support`, and + * `support+T-42@mail.acme.com` reaches `support+[ticket]` with `T-42`. + */ + slug?: string | null; +} + +interface CompiledRoute { + /** The route key as written; named `address` so the route sorts through `compareEmailRoutes`. */ + address: string; + pattern: CompiledEmailPattern; + handler: EmailHandler; +} + +function compileRoutes(routes: Record): { + compiled: Array; + defaultHandler?: EmailHandler; +} { + const compiled: Array = []; + let defaultHandler: EmailHandler | undefined; + for (const [key, handler] of Object.entries(routes)) { + if (key === '_default') { + defaultHandler = handler; + continue; + } + const pattern = compileEmailPattern(key); + if (!pattern) { + throw new Error( + `createInboundTestHarness: route key ${JSON.stringify(key)} is not a valid email pattern. Use shapes like "support", "support+[ticket]", "[user]", "[user]+[tag]", or the reserved key "_default".`, + ); + } + compiled.push({ address: key, pattern, handler }); + } + // The scanner's own comparator (`router/email.ts`), so the harness cannot + // drift from the order the generated dispatcher tries patterns in. + compiled.sort(compareEmailRoutes); + return { compiled, defaultHandler }; +} + +async function buildSyntheticRaw(opts: InboundDeliverOptions): Promise { + if (opts.raw !== undefined) { + return typeof opts.raw === 'string' ? new TextEncoder().encode(opts.raw) : opts.raw; + } + // Default: a minimal text/plain message so handlers calling parseEmail() + // see the expected from/to/subject/body. normalizeEmail requires either + // text or html — synthesize a placeholder body when neither is supplied. + const text = opts.text ?? (opts.html ? undefined : '(empty)'); + const normalized = await normalizeEmail({ + from: opts.from, + to: opts.to, + subject: opts.subject ?? '', + text, + html: opts.html, + headers: opts.headers, + }); + return buildMime(normalized).raw; +} + +/** + * Thrown by `parseHeaderBlock` at the first distinct name past its cap — + * the router's `InboundTooManyHeadersError` (`email-router.ts`). + */ +class InboundTooManyHeadersError extends Error {} + +/** + * Headers of a raw MIME message, up to the first blank line. + * + * Unfolds continuation lines (RFC 5322 §2.2.3: a line starting with WSP + * continues the one before it). Anything it cannot parse is skipped rather + * than thrown, because a test may deliver deliberately malformed bytes. + * + * `maxDistinct` is the router's header-count admission (`buildEnvelope` in + * `email-router.ts`): it walks the bag Cloudflare built from the wire and + * refuses at the cap, BEFORE any entry past it is materialized. That bag is + * a `Headers`, so the unit is the DISTINCT, case-insensitive name — a + * repeated `Received:` is one entry, and a continuation line is part of the + * one before it, never its own. The check sits in the same walk, checked + * before the `set`, so an over-cap message costs this harness the same + * `maxDistinct + 1` names it costs the router. A name a `Headers` refuses + * is not in the bag on either side, so it does not count. + */ +function parseHeaderBlock(raw: Uint8Array, maxDistinct: number): Headers { + const headers = new Headers(); + const text = new TextDecoder().decode(raw); + const end = text.indexOf('\r\n\r\n'); + const block = end === -1 ? text : text.slice(0, end); + let distinct = 0; + let current = ''; + const commit = () => { + const colon = current.indexOf(':'); + if (colon > 0) { + const name = current.slice(0, colon).trim(); + let known: boolean; + try { + known = headers.has(name); + } catch { + // Not a name a Headers object accepts — skip it. + current = ''; + return; + } + if (!known && distinct >= maxDistinct) { + throw new InboundTooManyHeadersError(); + } + try { + headers.set(name, current.slice(colon + 1).trim()); + if (!known) { + distinct++; + } + } catch { + // Not a value a Headers object accepts — skip it. + } + } + current = ''; + }; + // One line at a time rather than `split`, so the lines past the cap are + // never materialized either. + for (let pos = 0; pos <= block.length;) { + const nl = block.indexOf('\r\n', pos); + const line = nl === -1 ? block.slice(pos) : block.slice(pos, nl); + pos = nl === -1 ? block.length + 1 : nl + 2; + if (line === '') { + break; + } + if (/^[ \t]/.test(line) && current !== '') { + current += ` ${line.trim()}`; + continue; + } + commit(); + current = line; + } + commit(); + return headers; +} + +export function createInboundTestHarness(opts: InboundTestHarnessOptions): InboundTestHarness { + const { compiled, defaultHandler } = compileRoutes(opts.routes); + const env = opts.env ?? {}; + + const replies: Array = []; + const forwards: Array = []; + const rejects: Array = []; + const deliveries: Array = []; + + async function deliver(input: InboundDeliverOptions): Promise { + const raw = await buildSyntheticRaw(input); + + // The managed router's ADMISSION, before anything else looks at the + // bytes (`routeInbound` in `email-router.ts`): a message over + // `MAX_INBOUND_BYTES` is rejected from `rawSize` before its body is + // read, then `buildEnvelope` refuses one with more than + // `MAX_INBOUND_HEADERS` distinct names — each with a reject the handler + // never sees, because it is never invoked. The harness used to invoke it + // regardless, so a handler that forwards a 12 MiB message without + // calling `parseEmail()` recorded that forward here and had the message + // bounced at the router after deploy. Same order, same reasons, same + // outcome: one reject, no handler call. Platform lane only — a worker on + // your own Cloudflare account (`slug: null`) has no router in front of + // it, so Cloudflare hands the handler everything it accepts. + // + // The router prefixes every reason with the zone it serves + // (`EMAIL_SENDER_DOMAIN`). It is the catch-all rule on that zone, so the + // wire recipient's domain IS that zone and the harness recovers the label + // from the address it was given; `void email` is the router's own + // fallback when the var is unset. The size check reads `byteLength`: it + // runs BEFORE `parseHeaderBlock` decodes anything. + // + // Production's `buildEnvelope` copies every wire header verbatim, so + // `message.headers` and `message.raw` agree by construction. Start from + // the bytes for the same reason: the harness's own synthetic MIME + // carries a generated `Message-ID`, and `replyEmail` reads exactly that + // header — rebuilding from `input` alone made a harness reply come out + // unthreaded while the same handler threaded in production. It also + // dropped the headers of a caller-supplied `raw:`, which are the exact + // wire bytes the caller asked to deliver. + // + // A hand-rolled header split rather than postal-mime, so a deliberately + // malformed `raw:` fixture still reaches the handler instead of + // throwing PARSE_FAILED inside the harness. + let headers: Headers; + if (opts.slug === null) { + headers = parseHeaderBlock(raw, Infinity); + } else { + const at = input.to.indexOf('@'); + const label = at === -1 ? 'void email' : input.to.slice(at + 1).toLowerCase() || 'void email'; + if (raw.byteLength > MAX_INBOUND_BYTES) { + return refuse(`${label}: message too large (max ${MAX_INBOUND_BYTES} bytes)`); + } + try { + headers = parseHeaderBlock(raw, MAX_INBOUND_HEADERS); + } catch (e) { + if (e instanceof InboundTooManyHeadersError) { + return refuse(`${label}: too many headers (max ${MAX_INBOUND_HEADERS})`); + } + throw e; + } + } + // The caller's own headers win, so an explicit override stays an escape + // hatch. Applied AFTER admission: they are not on the wire, so the + // router never counted them. + for (const [k, v] of new Headers(input.headers ?? {})) { + headers.set(k, v); + } + // Subject is NOT re-set from `input.subject`. The bytes already carry it, + // and they carry it the way the wire does: `buildMime` RFC 2047-encodes a + // non-ASCII subject, and production hands the handler that encoded form. + // Overriding with the decoded string here would make + // `message.headers.get('Subject')` differ between harness and production + // for any non-ASCII subject — the pattern the guide itself shows. + if (!headers.has('Subject') && input.subject) { + headers.set('Subject', input.subject); + } + if (!headers.has('From')) { + headers.set('From', input.from); + } + if (!headers.has('To')) { + headers.set('To', input.to); + } + + // Mirror production: the dispatcher strips the platform-injected + // `+` prefix from message.to before user code sees it. When the + // harness was created with an explicit `slug`, use that; otherwise + // infer it from this delivery's recipient (the part before the first + // `+`). Inference keeps existing tests with `slug+tag@domain` recipients + // working without configuration. Falls back to the verbatim address + // if no `+` is present. + const slug = (() => { + if (opts.slug === null) { + return undefined; + } + if (opts.slug) { + return opts.slug; + } + const local = input.to.split('@')[0] ?? ''; + const plus = local.indexOf('+'); + return plus === -1 ? undefined : local.slice(0, plus); + })(); + + // The message the handler sees is production's own proxy, built the way + // the generated `email()` entry builds it: from an envelope and the slug. + // Everything the proxy enforces — setReject is terminal, the 100-action + // queue, the address checks (length and shape), the header-record check, the + // per-reply and per-delivery byte bounds — therefore throws here at the + // same call, with the same error, as it does after deploy. A hand-rolled + // message that recorded every call passed handlers that fail on the + // 101st forward in production. The proxy also makes `raw` replayable, + // as the platform lane does. + // + // `Object.fromEntries` is what the platform router's `buildEnvelope` + // does with the native message's headers. + const { message, getActions } = createMessageProxy( + { + from: input.from, + to: input.to, + headers: Object.fromEntries(headers), + raw, + rawSize: raw.byteLength, + }, + { slug }, + ); + + const ctx = { + waitUntil() { + // no-op + }, + passThroughOnException() { + // no-op + }, + } as unknown as ExecutionContext; + + // Mirror the production dispatcher's slug-strip: on the platform the + // recipient is `+@mail.void.cloud` and the dispatcher + // strips through the first `+` before matching. The test harness must do + // the same so tests don't pass against routing paths that can't occur in + // production. With `slug: null` (own-account deploy) nothing is stripped + // and the full local part is matched, exactly as the dispatcher does with + // no `__VOID_PROJECT_SLUG` configured. + // Case is PRESERVED and the patterns carry the `i` flag instead, exactly + // as the generated dispatcher does (`router/compile.ts`). Lowercasing + // here would lowercase the CAPTURED groups too, so a handler under test + // would see `t-42` for `support+T-42@` while production hands it `T-42` — + // a green test asserting behavior the deployed code does not have. + const fullLocal = input.to.split('@')[0] ?? ''; + const plus = fullLocal.indexOf('+'); + const local = opts.slug === null ? fullLocal : plus === -1 ? '' : fullLocal.slice(plus + 1); + // Note: on the platform a recipient with no `+` (e.g. `slug@mail.void.cloud`) + // has no tag to match — production lands at `_default` if defined, otherwise + // no-op. We mirror that here. + + async function dispatch(): Promise { + for (const route of compiled) { + const re = new RegExp(route.pattern.regexSource, 'i'); + const m = re.exec(local); + if (!m) { + continue; + } + const params: Record = {}; + for (let i = 0; i < route.pattern.paramNames.length; i++) { + params[route.pattern.paramNames[i]] = m[i + 1]; + } + const info: EmailHandlerInfo = { params }; + await route.handler(message, env, ctx, info); + return { handler: route.address, params }; + } + if (defaultHandler) { + const info: EmailHandlerInfo = { params: {} }; + await defaultHandler(message, env, ctx, info); + return { handler: '_default', params: {} }; + } + // Nothing matched and there is no `_default`. The generated dispatcher + // rejects here so the remote MTA gets a non-delivery report + // (`router/compile.ts`), and the harness has to mirror that or a test + // for catch-all or anti-spam behavior observes an ACCEPTED message + // where deployment bounces it. Routed through `setReject` rather than + // pushed directly, so the terminal-reject semantics stay identical. + // + // The reason is byte-for-byte the dispatcher's, built from the WIRE + // recipient with its original case — that string reaches the sender's + // DSN, and rebuilding it from the stripped or lowercased address would + // recreate the divergence class this mirroring exists to prevent. + message.setReject( + `no handler matched recipient "${input.to}" — add email/_default.ts to handle unmatched mail`, + ); + return { handler: undefined, params: {} }; + } + + const result = await dispatch(); + // Recorded once the handler has returned, as production enacts them: a + // handler that throws leaves nothing — the platform router rejects the + // message as a handler error, and on your own account the invocation + // fails before `enactEmailActions` runs. + record(getActions()); + deliveries.push(result); + return result; + } + + // An admission refusal, recorded the way a handler's own `setReject` is: + // the queue is that one reject, and the delivery is unhandled. + function refuse(reason: string): InboundDeliveryResult { + record([{ type: 'reject', reason }]); + const result: InboundDeliveryResult = { handler: undefined, params: {} }; + deliveries.push(result); + return result; + } + + // The proxy already made `setReject` terminal — earlier forward/reply + // entries purged, later ones dropped — so the queue is exactly what the + // router would enact. A forward's headers cross the queue as the record + // the proxy validated; the capture hands them back as `Headers`. + function record(actions: ReadonlyArray): void { + for (const action of actions) { + switch (action.type) { + case 'reject': + rejects.push(action.reason); + break; + case 'forward': + forwards.push({ + to: action.to, + ...(action.headers ? { headers: new Headers(action.headers) } : {}), + }); + break; + case 'reply': + replies.push({ from: action.from, to: action.to, raw: action.raw }); + break; + } + } + } + + return { + get replies() { + return replies; + }, + get forwards() { + return forwards; + }, + get rejects() { + return rejects; + }, + get deliveries() { + return deliveries; + }, + deliver, + clear() { + replies.length = 0; + forwards.length = 0; + rejects.length = 0; + deliveries.length = 0; + }, + }; +} diff --git a/packages/void/src/runtime/email/types.ts b/packages/void/src/runtime/email/types.ts new file mode 100644 index 000000000..bda176538 --- /dev/null +++ b/packages/void/src/runtime/email/types.ts @@ -0,0 +1,145 @@ +/** + * Public types for the void/email module. + * + * The runtime entrypoint (../email.ts) re-exports these so that consumers + * can import them via `import type { SendEmailOptions } from "void/email"`. + */ + +export type Address = string | { email: string; name?: string }; + +export interface Attachment { + filename: string; + content: string | Uint8Array | ArrayBuffer | Blob; + contentType?: string; + contentId?: string; + disposition?: 'attachment' | 'inline'; +} + +export interface SendEmailOptions { + /** + * Sender envelope address. Optional — when omitted, the framework + * substitutes `<__VOID_PROJECT_SLUG>+noreply@<__VOID_EMAIL_DOMAIN>` + * from the two platform-injected bindings (the project slug and the + * mail domain configured for this environment). Outside a deployed + * worker, or whenever either binding is missing, `sendEmail` returns + * `INVALID_FROM` — it never falls back to a baked-in domain. + */ + from?: Address; + to: Address | Array
; + subject: string; + text?: string; + html?: string; + replyTo?: Address; + cc?: Address | Array
; + bcc?: Address | Array
; + headers?: Record; + attachments?: Array; + /** + * Per-call escape hatch for local development. When true, bypasses the + * `void dev` in-memory inbox for this one send. + * + * It does not deliver. `void dev` binds no send transport at all: the + * platform's `__VOID_PROXY` service binding is added only on a deployed + * worker, and the own-account `SEND_EMAIL` binding is stripped under + * `serve`. With the inbox skipped there is nothing to fall through to, + * so the call returns `BINDING_MISSING`. To verify real delivery, deploy + * and send from the deployed worker. + * + * Has no effect outside `void dev` (production always sends; the dev + * inbox is registered only when `void dev` is running). + */ + sendInDev?: boolean; +} + +export type SendEmailErrorCode = + | 'BINDING_MISSING' + | 'INVALID_FROM' + | 'INVALID_TO' + | 'UNVERIFIED_DESTINATION' + | 'MIME_ERROR' + | 'QUOTA_EXCEEDED' + | 'UPSTREAM_ERROR'; + +export interface SendEmailError { + code: SendEmailErrorCode; + message: string; + cause?: unknown; +} + +/** + * One successful CF envelope send. The `to`/`cc`/`bcc` fan-out produces one + * of these per recipient, since the CF `EmailMessage` envelope is + * single-recipient. + */ +export interface SendEmailDelivery { + recipient: string; + messageId: string; +} + +/** + * Per-recipient outcome for a multi-recipient send. `ok: true` carries the + * CF-issued messageId; `ok: false` carries the error for that specific + * recipient (e.g. one address is unverified while others succeed). + */ +export type SendEmailRecipientResult = + | { recipient: string; ok: true; messageId: string } + | { recipient: string; ok: false; error: SendEmailError }; + +/** + * Result of `sendEmail`. + * + * Three discriminated cases — narrow with `result.ok`, then check whether + * `'error' in result` (pre-flight failure, no sends attempted) or + * `'deliveries' in result` (per-recipient failure, some may have succeeded): + * + * - `{ ok: true, ids }` — every envelope succeeded. + * - `{ ok: false, error }` — pre-flight failure (validation, MIME, + * missing binding); no envelopes attempted. + * - `{ ok: false, deliveries }` — sends were attempted; at least one failed. + * `deliveries` carries the per-recipient + * outcome so callers can safely retry only + * the failed ones. There is no aggregate + * top-level error — distinct recipients can + * fail with distinct errors, and picking one + * would be arbitrary. + */ +export type SendEmailResult = + | { ok: true; ids: Array } + | { ok: false; error: SendEmailError } + | { ok: false; deliveries: Array }; + +/** + * Normalized representation produced by validation, consumed by both the + * MIME builder and the dev inbox. + */ +export interface NormalizedEmail { + from: { email: string; name?: string }; + to: Array<{ email: string; name?: string }>; + cc: Array<{ email: string; name?: string }>; + bcc: Array<{ email: string; name?: string }>; + replyTo?: { email: string; name?: string }; + subject: string; + text?: string; + html?: string; + headers: Record; + attachments: Array; + /** + * Internal-only threading headers, set by `replyEmail`/`buildReplyMessage`. + * The MIME builder emits these as proper `In-Reply-To` / `References` + * top-level headers. User-supplied `headers` with these names are + * always dropped (they're in `RESERVED_HEADERS`); this field is the + * supported channel for the trusted reply path. + */ + threading?: { + inReplyTo?: string; + references?: string; + }; +} + +export interface NormalizedAttachment { + filename: string; + contentType: string; + bytes: Uint8Array; + contentId?: string; + disposition: 'attachment' | 'inline'; +} diff --git a/packages/void/src/shared/dotenv.ts b/packages/void/src/shared/dotenv.ts index bada835a2..a4c88a528 100644 --- a/packages/void/src/shared/dotenv.ts +++ b/packages/void/src/shared/dotenv.ts @@ -241,7 +241,7 @@ export function mergeAndExpand( } /** - * Wrangler 4.107's bundled `dotenv` LINE regex (verbatim), used ONLY to detect the + * Wrangler 4.131's bundled `dotenv` LINE regex (verbatim), used ONLY to detect the * declared KEYS wrangler would load — including the OPTIONAL `export ` prefix, the `KEY: val` * form, and single/double/backtick quoting — so a transport-redirect / config-override fence * sees exactly what wrangler's per-command dotenv reload would. We read keys, never values. diff --git a/packages/void/src/shared/env.ts b/packages/void/src/shared/env.ts index 08d55aa90..1463fe6ca 100644 --- a/packages/void/src/shared/env.ts +++ b/packages/void/src/shared/env.ts @@ -6,7 +6,7 @@ /** * Marks the isolated probes and Vite build run by - * `void deploy --backend cloudflare`. + * `void deploy --platform cloudflare`. * * Kept here so the deploy orchestrator and Vite plugin share one spelling. * The marker is passed through child-process env only; it is never a Worker diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-addresses-list.exit b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-addresses-list.exit new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-addresses-list.exit @@ -0,0 +1 @@ +0 diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-addresses-list.stderr b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-addresses-list.stderr new file mode 100644 index 000000000..a4c5b0cdf --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-addresses-list.stderr @@ -0,0 +1,3 @@ +▲ [WARNING] 🚧 `wrangler email routing addresses list` is an open beta command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose + + diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-addresses-list.stdout b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-addresses-list.stdout new file mode 100644 index 000000000..411be5ca1 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-addresses-list.stdout @@ -0,0 +1,4 @@ + + ⛅️ wrangler 4.131.0 +──────────────────── +No destination addresses found. diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.app.exit b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.app.exit new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.app.exit @@ -0,0 +1 @@ +0 diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.app.stderr b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.app.stderr new file mode 100644 index 000000000..a4198520a --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.app.stderr @@ -0,0 +1,3 @@ +▲ [WARNING] 🚧 `wrangler email routing rules list` is an open beta command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose + + diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.app.stdout b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.app.stdout new file mode 100644 index 000000000..26562f158 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.app.stdout @@ -0,0 +1,7 @@ + + ⛅️ wrangler 4.131.0 +──────────────────── +No custom routing rules found. + +Catch-all rule: disabled, action: drop + (use `wrangler email routing rules get catch-all` to view details) diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.cloud.exit b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.cloud.exit new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.cloud.exit @@ -0,0 +1 @@ +0 diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.cloud.stderr b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.cloud.stderr new file mode 100644 index 000000000..a4198520a --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.cloud.stderr @@ -0,0 +1,3 @@ +▲ [WARNING] 🚧 `wrangler email routing rules list` is an open beta command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose + + diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.cloud.stdout b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.cloud.stdout new file mode 100644 index 000000000..26562f158 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-rules-list-void.cloud.stdout @@ -0,0 +1,7 @@ + + ⛅️ wrangler 4.131.0 +──────────────────── +No custom routing rules found. + +Catch-all rule: disabled, action: drop + (use `wrangler email routing rules get catch-all` to view details) diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-mail.void.cloud.exit b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-mail.void.cloud.exit new file mode 100644 index 000000000..d00491fd7 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-mail.void.cloud.exit @@ -0,0 +1 @@ +1 diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-mail.void.cloud.stderr b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-mail.void.cloud.stderr new file mode 100644 index 000000000..6137fbf9b --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-mail.void.cloud.stderr @@ -0,0 +1,7 @@ +▲ [WARNING] 🚧 `wrangler email routing settings` is an open beta command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose + + +✘ [ERROR] Could not find zone for `mail.void.cloud`. Make sure the domain exists in your account. + + +🪵 Logs were written to "/home/dev/.wrangler/logs/wrangler-2026-09-11_14-07-06_050.log" diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-mail.void.cloud.stdout b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-mail.void.cloud.stdout new file mode 100644 index 000000000..a808eae54 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-mail.void.cloud.stdout @@ -0,0 +1,4 @@ + + ⛅️ wrangler 4.131.0 +──────────────────── + diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.app.exit b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.app.exit new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.app.exit @@ -0,0 +1 @@ +0 diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.app.stderr b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.app.stderr new file mode 100644 index 000000000..35ef41dbd --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.app.stderr @@ -0,0 +1,3 @@ +▲ [WARNING] 🚧 `wrangler email routing settings` is an open beta command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose + + diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.app.stdout b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.app.stdout new file mode 100644 index 000000000..920afc4b2 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.app.stdout @@ -0,0 +1,9 @@ + + ⛅️ wrangler 4.131.0 +──────────────────── +Email Routing for void.app: + Enabled: false + Status: unconfigured + Created: 2026-09-11T07:38:41.273162Z + Modified: 2026-09-11T07:38:41.273162Z + Tag: 198763c1ff7c51a4c97e5f6e6ee1d2f4 diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.cloud.exit b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.cloud.exit new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.cloud.exit @@ -0,0 +1 @@ +0 diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.cloud.stderr b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.cloud.stderr new file mode 100644 index 000000000..35ef41dbd --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.cloud.stderr @@ -0,0 +1,3 @@ +▲ [WARNING] 🚧 `wrangler email routing settings` is an open beta command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose + + diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.cloud.stdout b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.cloud.stdout new file mode 100644 index 000000000..0c79e7c84 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/routing-settings-void.cloud.stdout @@ -0,0 +1,9 @@ + + ⛅️ wrangler 4.131.0 +──────────────────── +Email Routing for void.cloud: + Enabled: true + Status: ready + Created: 2026-03-18T02:37:48.990322Z + Modified: 2026-03-18T02:44:54.833251Z + Tag: 5b66f5cb84fd7dcfdd0170515e622181 diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/sending-list-void.cloud.exit b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-list-void.cloud.exit new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-list-void.cloud.exit @@ -0,0 +1 @@ +0 diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/sending-list-void.cloud.stderr b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-list-void.cloud.stderr new file mode 100644 index 000000000..31c44f525 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-list-void.cloud.stderr @@ -0,0 +1,3 @@ +▲ [WARNING] 🚧 `wrangler email sending list` is an open beta command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose + + diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/sending-list-void.cloud.stdout b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-list-void.cloud.stdout new file mode 100644 index 000000000..70755d369 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-list-void.cloud.stdout @@ -0,0 +1,8 @@ + + ⛅️ wrangler 4.131.0 +──────────────────── +┌────────────┬─────────┬──────────────────────────────────┐ +│ name │ enabled │ tag │ +├────────────┼─────────┼──────────────────────────────────┤ +│ void.cloud │ yes │ 19af4fdbee924a92916afea0de5a8fdd │ +└────────────┴─────────┴──────────────────────────────────┘ diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-nonexistent.void.cloud.exit b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-nonexistent.void.cloud.exit new file mode 100644 index 000000000..d00491fd7 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-nonexistent.void.cloud.exit @@ -0,0 +1 @@ +1 diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-nonexistent.void.cloud.stderr b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-nonexistent.void.cloud.stderr new file mode 100644 index 000000000..15c09dd2c --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-nonexistent.void.cloud.stderr @@ -0,0 +1,7 @@ +▲ [WARNING] 🚧 `wrangler email sending settings` is an open beta command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose + + +✘ [ERROR] No sending subdomain found for `nonexistent.void.cloud`. Run `wrangler email sending list nonexistent.void.cloud` to see configured subdomains. + + +🪵 Logs were written to "/home/dev/.wrangler/logs/wrangler-2026-09-11_14-07-07_321.log" diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-nonexistent.void.cloud.stdout b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-nonexistent.void.cloud.stdout new file mode 100644 index 000000000..a808eae54 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-nonexistent.void.cloud.stdout @@ -0,0 +1,4 @@ + + ⛅️ wrangler 4.131.0 +──────────────────── + diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.app.exit b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.app.exit new file mode 100644 index 000000000..d00491fd7 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.app.exit @@ -0,0 +1 @@ +1 diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.app.stderr b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.app.stderr new file mode 100644 index 000000000..77d6d55e6 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.app.stderr @@ -0,0 +1,7 @@ +▲ [WARNING] 🚧 `wrangler email sending settings` is an open beta command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose + + +✘ [ERROR] No sending subdomain found for `void.app`. Run `wrangler email sending list void.app` to see configured subdomains. + + +🪵 Logs were written to "/home/dev/.wrangler/logs/wrangler-2026-09-11_14-08-07_882.log" diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.app.stdout b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.app.stdout new file mode 100644 index 000000000..a808eae54 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.app.stdout @@ -0,0 +1,4 @@ + + ⛅️ wrangler 4.131.0 +──────────────────── + diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.cloud.exit b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.cloud.exit new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.cloud.exit @@ -0,0 +1 @@ +0 diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.cloud.stderr b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.cloud.stderr new file mode 100644 index 000000000..3640e3518 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.cloud.stderr @@ -0,0 +1,3 @@ +▲ [WARNING] 🚧 `wrangler email sending settings` is an open beta command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose + + diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.cloud.stdout b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.cloud.stdout new file mode 100644 index 000000000..8711e4d4b --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/sending-settings-void.cloud.stdout @@ -0,0 +1,10 @@ + + ⛅️ wrangler 4.131.0 +──────────────────── +Email Sending for void.cloud: + Enabled: true + Tag: 19af4fdbee924a92916afea0de5a8fdd + Created: 2026-03-30T16:39:39.300911Z + Modified: 2026-03-30T16:39:39.300911Z + DKIM selector: cf-bounce + Return-path domain: cf-bounce.void.cloud diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/whoami.exit b/packages/void/test/fixtures/wrangler-email/4.131.0/whoami.exit new file mode 100644 index 000000000..573541ac9 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/whoami.exit @@ -0,0 +1 @@ +0 diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/whoami.stderr b/packages/void/test/fixtures/wrangler-email/4.131.0/whoami.stderr new file mode 100644 index 000000000..e69de29bb diff --git a/packages/void/test/fixtures/wrangler-email/4.131.0/whoami.stdout b/packages/void/test/fixtures/wrangler-email/4.131.0/whoami.stdout new file mode 100644 index 000000000..8d0aa27c7 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/4.131.0/whoami.stdout @@ -0,0 +1,98 @@ +{ + "loggedIn": true, + "authType": "OAuth Token", + "email": "dev@example.com", + "accounts": [ + { + "id": "1111111111111111111111111111aaaa", + "name": "Personal Account", + "type": "standard", + "settings": { + "enforce_twofactor": false, + "api_access_enabled": null, + "access_approval_expiry": null, + "abuse_contact_email": null, + "oauth_app_access_enabled": true + }, + "legacy_flags": { + "enterprise_zone_quota": { + "maximum": 0, + "current": 0, + "available": 0 + } + }, + "created_on": "2025-09-27T13:18:28.403722Z" + }, + { + "id": "2222222222222222222222222222bbbb", + "name": "Second Personal Account", + "type": "standard", + "settings": { + "enforce_twofactor": false, + "api_access_enabled": null, + "access_approval_expiry": null, + "abuse_contact_email": null, + "oauth_app_access_enabled": true + }, + "legacy_flags": { + "enterprise_zone_quota": { + "maximum": 0, + "current": 0, + "available": 0 + } + }, + "created_on": "2020-05-09T08:15:53.092997Z" + }, + { + "id": "f721b8e5f721b8e5f721b8e5f721b8e5", + "name": "Acme", + "type": "standard", + "settings": { + "enforce_twofactor": true, + "api_access_enabled": null, + "access_approval_expiry": null, + "abuse_contact_email": null, + "oauth_app_access_enabled": true + }, + "legacy_flags": { + "enterprise_zone_quota": { + "maximum": 3, + "current": 2, + "available": 1 + } + }, + "created_on": "2023-09-01T04:33:20.498384Z" + } + ], + "tokenPermissions": [ + "user:read", + "offline_access", + "account:read", + "workers:write", + "workers_kv:write", + "workers_routes:write", + "workers_scripts:write", + "workers_tail:read", + "d1:write", + "pages:write", + "zone:read", + "ssl_certs:write", + "ai:write", + "ai-search:write", + "ai-search:run", + "websearch.run", + "agent-memory:write", + "queues:write", + "pipelines:write", + "secrets_store:write", + "artifacts:write", + "flagship:write", + "containers:write", + "cloudchamber:write", + "connectivity:admin", + "email_routing:write", + "email_sending:write", + "browser:write", + "challenge-widgets.write" + ] +} diff --git a/packages/void/test/fixtures/wrangler-email/README.md b/packages/void/test/fixtures/wrangler-email/README.md new file mode 100644 index 000000000..a4422b3b1 --- /dev/null +++ b/packages/void/test/fixtures/wrangler-email/README.md @@ -0,0 +1,25 @@ +# wrangler email prose fixtures + +Verbatim stdout / stderr / exit code of the pinned wrangler's `email` command group +and `whoami --json`, captured READ-ONLY against a real account, one directory per +wrangler version. `src/cli/email-cloudflare.ts` pins its prose parsers to these +(`WRANGLER_EMAIL_PROSE_VERSION`); `test/unit/wrangler-pin.test.ts` asserts that constant +equals the installed wrangler's version, so a catalog move fails that test until the prose is +re-captured into a new `/` directory and the constant is moved. + +Capture (all read-only; `WRANGLER_SEND_METRICS=false`, account pinned via +`CLOUDFLARE_ACCOUNT_ID`): + +``` +node whoami --json +node email routing settings +node email routing rules list +node email sending settings +node email routing settings # exit 1 +node email sending settings # exit 1 +``` + +Personal identifiers in `whoami.stdout` (email, personal account ids/names) and the +`~/.wrangler/logs/...` path in the stderr captures are replaced with placeholders; +every other byte is as printed. Every `wrangler email` run prints an "open beta +command" warning on stderr — the parsers read stdout only. diff --git a/packages/void/test/unit/cli-help.test.ts b/packages/void/test/unit/cli-help.test.ts index 9224582e5..0f29c00c1 100644 --- a/packages/void/test/unit/cli-help.test.ts +++ b/packages/void/test/unit/cli-help.test.ts @@ -1,5 +1,6 @@ import { note } from '@clack/prompts'; import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { parseEmailArgs } from '../../src/cli/email-args.ts'; import { getHelpPath, hasHelpPage, printHelpPage } from '../../src/cli/help.ts'; vi.mock('@clack/prompts', () => ({ @@ -101,3 +102,57 @@ describe('CLI help resolver', () => { }, ); }); + +describe('rendered help advertises the flags the parser honours', () => { + /** The page as handed to clack's `note` (mocked above), ANSI stripped, so an assertion reads like the terminal. */ + function render(path: Array): string { + vi.mocked(note).mockClear(); + printHelpPage(path); + const lines = vi.mocked(note).mock.calls.map((call) => String(call[0])); + // eslint-disable-next-line no-control-regex + return lines.join('\n').replace(/\u001b\[[0-9;]*m/g, ''); + } + + // `void email logs --limit ` is parsed, honoured, and documented in the + // CLI reference, but the help page was built with the shared project-only + // helper, so the flag was invisible in every help surface. Nothing asserted + // rendered help content before, so nothing would have caught it. + it('shows --limit on `email logs`, which is the only email flag that does anything', () => { + expect(parseEmailArgs(['logs', '--limit', '7'])).toMatchObject({ + subcommand: 'logs', + limit: 7, + }); + + const page = render(['email', 'logs']); + + expect(page).toContain('--limit '); + expect(page).toContain('--project '); + }); + + it('still shows --project on the email subcommands that take only that', () => { + for (const sub of ['usage', 'destinations', 'allow', 'disallow']) { + const page = render(['email', sub]); + expect(page).toContain('--project '); + expect(page).not.toContain('--limit'); + } + }); + + // Mode B: `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`', () => { + for (const sub of ['status', 'setup']) { + expect(parseEmailArgs([sub, '--platform', 'cloudflare'])).toMatchObject({ + subcommand: sub, + platform: 'cloudflare', + }); + const page = render(['email', sub]); + expect(page).toContain(`void email ${sub} --platform cloudflare`); + expect(page).toContain('--platform cloudflare'); + expect(page).not.toContain('--backend'); + expect(page).not.toContain('--project'); + } + const group = render(['email']); + expect(group).toContain('status --platform cloudflare'); + expect(group).toContain('setup --platform cloudflare'); + }); +}); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index aa766cc7f..6d0b1de6d 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; import { log } from '@clack/prompts'; +import { parseCloudflareVersionMessage } from '@void/deploy-cloudflare'; import { dirname, join } from 'pathslash'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { readCloudflareLink } from '../../src/cli/cloudflare-link.ts'; @@ -21,6 +22,7 @@ import { parseRootWranglerConfig, prepareCloudflareFrameworkArtifact, runCloudflareDeploy, + runWranglerAuthToken, syncCloudflareSchemaSecrets, snapshotBindingIdentities, snapshotPassthroughIdentities, @@ -32,6 +34,7 @@ import { type DeployWranglerRunResult, type EmittedWorkerVarsResult, } from '../../src/cli/deploy.ts'; +import { SENDING_FREE_PLAN_HINT, type EmailDns } from '../../src/cli/email-cloudflare.ts'; import { resolveEnvSchemaFactsViaSubprocess, validateEmittedVarsViaSubprocess, @@ -316,12 +319,16 @@ function harness(overrides: Partial = {}): { prepareFrameworkArtifact: async () => { trace.push('prepare-framework'); }, + // Stand in for the version upload + activation with one `deploy` spawn through the runner + // the deploy handed over (`options.runWrangler` — a test's own `runWrangler` override, else + // the recorder above), so a test that swaps the runner still observes whether the deploy + // happened. Dedicated suites in packages/deploy-cloudflare exercise the real activation. activateVersion: async (options) => { if (!options.verifyUploader()) { return { ok: false, reason: 'uploader-changed' }; } const result = await Promise.resolve( - runWrangler( + options.runWrangler( [ 'deploy', ...(options.configPath ? ['-c', options.configPath] : []), @@ -1929,6 +1936,32 @@ describe('runCloudflareDeploy — static, SPA, and SSG apps', () => { expect(trace).toEqual(['wrangler:whoami', 'build', 'gate', 'wrangler:deploy']); }); + // An `email/` handler compiles to the worker's `email()` export. The thin static edge + // Worker exports none, so routing an email-bearing `output: "static"` app there would + // drop every inbound handler with no deploy-time error — the same class of miss + // `hasNonPageBackend` closes on the managed path. + it('keeps an output:"static" app with an email/ handler on the full Worker path', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'email-ssg' }), + 'void.json': JSON.stringify({ output: 'static' }), + // A prerendered page carries the max `revalidate`, so the worker path desires + // ISR_CACHE; pre-provision it so the stubbed provisioner has nothing to create. + 'wrangler.jsonc': JSON.stringify({ + account_id: 'acc123', + kv_namespaces: [{ binding: 'ISR_CACHE', id: REAL_KV_ID }], + }), + 'pages/index.vue': ``, + 'pages/index.server.ts': `export const loader = () => ({});`, + 'email/support.ts': `export default async () => {};`, + }); + const { deps, trace, exitCalls } = harness(); + + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + + expect(exitCalls).toEqual([]); + expect(trace).toEqual(['wrangler:whoami', 'build', 'gate', 'wrangler:deploy']); + }); + it('accepts a valid custom migrations_pattern', async () => { const root = d1Project({ ...PROVISIONED_D1, @@ -1951,7 +1984,7 @@ describe('runCloudflareDeploy — static, SPA, and SSG apps', () => { it('A1: rejects a PRESENT-but-EMPTY migrations_pattern ("") before any account op', async () => { // `migrations_pattern: ""` is present-but-empty. readStoredResourceName collapses it to // `undefined` (== absent), so a length-based guard would wave it through as "default" — but - // wrangler 4.107 takes its non-default branch for ANY value that is not `undefined`, so an + // wrangler 4.131 takes its non-default branch for ANY value that is not `undefined`, so an // empty pattern diverges from the flat `*.sql` set this backend validates. Reject on presence. const root = d1Project({ ...PROVISIONED_D1, @@ -3153,8 +3186,9 @@ describe('DEPLOY_PASSTHROUGH_ASSERT_FIELDS — registry-derived composition', () } }); - it('whole-object-pins unsafe, the tail-consumer fields, and the traffic-attachment fields (the entry pin cannot see them)', () => { + it('whole-object-pins unsafe, the tail-consumer fields, the traffic-attachment fields, and addresses (the entry pin cannot see them)', () => { expect([...DEPLOY_WHOLE_OBJECT_PINNED_FIELDS].sort()).toEqual([ + 'addresses', 'preview_urls', 'route', 'routes', @@ -3165,8 +3199,8 @@ describe('DEPLOY_PASSTHROUGH_ASSERT_FIELDS — registry-derived composition', () ]); // `unsafe` stays in the assert-set (drift invariant) but is routed to the whole-object pin. expect(DEPLOY_PASSTHROUGH_ASSERT_FIELDS).toContain('unsafe'); - // The tail-consumer + traffic-attachment fields are NOT bindings, so the assert-set structurally - // cannot see them — only the whole-object pin covers them. + // The tail-consumer + traffic-attachment + inbound-mail fields are NOT bindings, so the + // assert-set structurally cannot see them — only the whole-object pin covers them. for (const field of [ 'tail_consumers', 'streaming_tail_consumers', @@ -3174,6 +3208,7 @@ describe('DEPLOY_PASSTHROUGH_ASSERT_FIELDS — registry-derived composition', () 'route', 'workers_dev', 'preview_urls', + 'addresses', ]) { expect(FRIENDLY_BINDING_NAMES).not.toHaveProperty(field); } @@ -5426,6 +5461,40 @@ describe('runCloudflareDeploy — config laundering by a build-adjacent probe', expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(true); }); + it('fails closed before whoami when the pre-build probe injects `addresses`, `services` or `routes` — with --provision too', async () => { + // The post-probe laundering guard compares the WHOLE root config, so it is provision- + // independent: an injected `addresses` entry is an `added` plan change wrangler applies with + // no prompt, even in CI — the account's inbound mail for that address would be routed to the + // deployed worker. `--provision` must not open a path around it. + const injections: Array<(c: Record) => void> = [ + (c) => { + c.addresses = ['ceo@acme.com']; + }, + (c) => { + c.services = [{ binding: 'EVIL', service: 'someone-elses-worker' }]; + }, + (c) => { + c.routes = [{ pattern: 'acme.com/*', zone_name: 'acme.com' }]; + }, + ]; + for (const inject of injections) { + const root = d1Project(PROVISIONED_D1); + const { deps, trace, wranglerArgs, exitCalls } = harness({ + resolveSchemaFacts: (() => { + rewriteRootConfig(root, inject); + return okSchemaFacts(); + }) as CloudflareDeployDeps['resolveSchemaFacts'], + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare', provision: true }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).not.toContain('wrangler:whoami'); + expect(trace).not.toContain('build'); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + } + }); + it('automatic provisioning rejects a probe rewrite before creating resources', async () => { const root = d1Project({ account_id: 'acc123', name: 'myapp' }); const { deps, trace, wranglerArgs, exitCalls } = harness({ @@ -5941,7 +6010,7 @@ describe('runCloudflareDeploy — migrations dir / count / auth', () => { const { deps, wranglerArgs, trace } = harness(); await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); // B2: the managed drift/journal gate runs for EVERY D1 app (a schema.ts with no - // generated migration is drift), but wrangler 4.107 hard-fails on an empty/absent + // generated migration is drift), but wrangler 4.131 hard-fails on an empty/absent // folder — so the apply/list is skipped. expect(trace).toContain('migrate-gate'); expect(wranglerArgs.some((a) => a[0] === 'd1')).toBe(false); @@ -7453,7 +7522,7 @@ describe('checkWranglerMigrationApplySet', () => { }); }); -describe('compareWranglerMigrationPaths (wrangler 4.107 numeric-prefix comparator)', () => { +describe('compareWranglerMigrationPaths (wrangler 4.131 numeric-prefix comparator)', () => { it('sorts by leading numeric prefix (2 before 10), matching wrangler', () => { const sorted = ['10_b.sql', '2_a.sql', '1_z.sql'].sort(compareWranglerMigrationPaths); expect(sorted).toEqual(['1_z.sql', '2_a.sql', '10_b.sql']); @@ -8535,3 +8604,1356 @@ describe('assertWranglerUploaderUnchanged', () => { expect(result.reason).toMatch(/cannot be verified/); }); }); + +// --------------------------------------------------------------------------- +// Mode B email (design doc 0078) — step 3b-ii. The email module itself is unit-tested in +// email-cloudflare.test.ts on fakes; these tests pin the ORCHESTRATION: where the step sits, +// what it spawns, what it writes, what it prints, and what it never leaks. +// --------------------------------------------------------------------------- + +describe('runCloudflareDeploy — Mode B email step (3b-ii)', () => { + const ZONE_ID = '5b66f5cb84fd7dcfdd0170515e622181'; + const BEARER = 'cfoa-SECRET-BEARER-TOKEN-0123456789abcdef'; + const BETA_WARNING = '▲ [WARNING] 🚧 `wrangler email x` is an open beta command.\n'; + + /** A worker-bearing D1 app with one inbound handler and `email.from` on a subdomain. */ + function emailProject( + wrangler: Record = PROVISIONED_D1, + extraFiles: Record = {}, + voidJson: Record = { email: { from: 'noreply@mail.acme.com' } }, + ): string { + return makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify(voidJson), + 'routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + 'email/support+[ticket].ts': `export default async () => {};`, + 'wrangler.jsonc': JSON.stringify(wrangler, null, 2), + ...INIT_MIGRATION, + ...extraFiles, + }); + } + + function readRootConfig(root: string): Record { + return JSON.parse(readFileSync(join(root, 'wrangler.jsonc'), 'utf-8')) as Record< + string, + unknown + >; + } + + const whoamiWithScopes = JSON.stringify({ + loggedIn: true, + email: 'dev@acme.com', + accounts: [{ id: 'acc123', name: 'Acme' }], + tokenPermissions: ['account:read', 'email_routing:write', 'email_sending:write'], + }); + + function routingSettings(name: string, status: string): DeployWranglerRunResult { + return { + ...cleanExit, + stdout: `\n ⛅️ wrangler 4.131.0\n────────────────────\nEmail Routing for ${name}:\n Enabled: true\n Status: ${status}\n Created: 2026-03-18T02:37:48.990322Z\n Modified: 2026-03-18T02:44:54.833251Z\n Tag: ${ZONE_ID}\n`, + stderr: BETA_WARNING, + }; + } + const zoneNotFound = (name: string): DeployWranglerRunResult => ({ + ...cleanExit, + status: 1, + stderr: `${BETA_WARNING}✘ [ERROR] Could not find zone for \`${name}\`. Make sure the domain exists in your account.\n`, + }); + const noRules: DeployWranglerRunResult = { + ...cleanExit, + stdout: + ' ⛅️ wrangler 4.131.0\n────────────────────\nNo custom routing rules found.\n\nCatch-all rule: disabled, action: drop\n (use `wrangler email routing rules get catch-all` to view details)\n', + stderr: BETA_WARNING, + }; + const myRule: DeployWranglerRunResult = { + ...cleanExit, + stdout: + ' ⛅️ wrangler 4.131.0\n────────────────────\nRule: 1a2b3c\n Name: support\n Enabled: true\n Matchers: to:support@mail.acme.com\n Actions: worker:myapp\n Priority: 0\n\n\nCatch-all rule: disabled, action: drop\n (use `wrangler email routing rules get catch-all` to view details)\n', + stderr: BETA_WARNING, + }; + const sendingNotFound: DeployWranglerRunResult = { + ...cleanExit, + status: 1, + stderr: `${BETA_WARNING}✘ [ERROR] No sending subdomain found for \`mail.acme.com\`. Run \`wrangler email sending list mail.acme.com\` to see configured subdomains.\n`, + }; + const sendingEnabled: DeployWranglerRunResult = { + ...cleanExit, + stdout: + '\n ⛅️ wrangler 4.131.0\n────────────────────\nEmail Sending for mail.acme.com:\n Enabled: true\n Tag: 19af4fdbee924a92916afea0de5a8fdd\n Created: 2026-03-30T16:39:39.300911Z\n Modified: 2026-03-30T16:39:39.300911Z\n DKIM selector: cf-bounce\n Return-path domain: cf-bounce.mail.acme.com\n', + stderr: BETA_WARNING, + }; + + type Spawn = { args: Array; opts: { capture: boolean; env?: Record } }; + + /** + * A deploy runner keyed on the wrangler argv WITHOUT the `-c --env=` context (asserted + * separately): the `whoami` / `auth token` / `email …` table for the first-deploy transcript + * of 0078, overridable per test. Unknown commands exit clean, as the base harness does. + */ + function emailRunner( + overrides: Record, + spawns: Array, + trace: Array, + ): DeployWranglerRun { + const table: Record = { + 'whoami --json': { ...cleanExit, stdout: whoamiWithScopes }, + 'auth token --json': { + ...cleanExit, + stdout: JSON.stringify({ type: 'oauth', token: BEARER }, null, 2), + }, + 'email routing settings mail.acme.com': zoneNotFound('mail.acme.com'), + 'email routing settings acme.com': routingSettings('acme.com', 'ready'), + 'email routing rules list acme.com': noRules, + 'email sending settings mail.acme.com': sendingNotFound, + 'email sending enable mail.acme.com': { ...cleanExit, stderr: BETA_WARNING }, + ...overrides, + }; + return (args, opts) => { + spawns.push({ args: [...args], opts: { ...opts } }); + trace.push(`wrangler:${args.slice(0, 2).join(' ')}`); + const bare: Array = []; + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '-c') { + i += 1; + continue; + } + if (args[i] === '--env=') { + continue; + } + bare.push(args[i] as string); + } + const hit = table[bare.join(' ')]; + if (hit) { + return hit; + } + if (args[0] === 'd1' && args[1] === 'migrations' && args[2] === 'list') { + return { ...cleanExit, stdout: '✅ No migrations to apply!\n' }; + } + return cleanExit; + }; + } + + type FetchCall = { method: string; path: string; body: string | undefined; auth: string }; + + /** The two bearer calls (+ the two preflight GETs), keyed `METHOD /zones…`; 200s by default. */ + function emailFetch( + calls: Array, + overrides: Record Response> = {}, + subaddressing = false, + ): typeof fetch { + const ok = (result: unknown) => () => + new Response(JSON.stringify({ success: true, result }), { status: 200 }); + const table: Record Response> = { + 'GET /zones?name=acme.com': ok([ + { id: ZONE_ID, name: 'acme.com', type: 'full', account: { id: 'acc123' } }, + ]), + [`GET /zones/${ZONE_ID}/email/routing`]: ok({ + enabled: true, + support_subaddress: subaddressing, + }), + [`POST /zones/${ZONE_ID}/email/routing/dns`]: ok({ enabled: true }), + [`PATCH /zones/${ZONE_ID}/email/routing`]: ok({ support_subaddress: true }), + ...overrides, + }; + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const path = url.slice(url.indexOf('/zones')); + const method = init?.method ?? 'GET'; + const headers = (init?.headers ?? {}) as Record; + calls.push({ + method, + path, + body: typeof init?.body === 'string' ? init.body : undefined, + auth: headers.Authorization ?? '', + }); + const hit = table[`${method} ${path}`]; + if (!hit) { + throw new Error(`unexpected fetch: ${method} ${path}`); + } + return hit(); + }) as typeof fetch; + } + + /** Public DNS for the transcript: Google MX on the apex, nothing on the subdomain yet. */ + function emailDns(mx: Record> = {}): EmailDns { + const noData = (): Error => Object.assign(new Error('ENODATA'), { code: 'ENODATA' }); + const records: Record> = { + 'acme.com': ['aspmx.l.google.com'], + ...mx, + }; + return { + resolveMx: async (name) => { + const list = records[name]; + if (!list || list.length === 0) { + throw noData(); + } + return list.map((exchange, i) => ({ exchange, priority: (i + 1) * 10 })); + }, + resolveTxt: async () => { + throw noData(); + }, + }; + } + + /** Capture EVERYTHING the deploy prints (clack log + raw stdio) for leak / message asserts. */ + function captureOutput(): { lines: Array; restore: () => void } { + const lines: Array = []; + const spies = (['message', 'info', 'warn', 'error', 'success', 'step'] as const).map((level) => + vi.spyOn(log, level).mockImplementation(((...args: Array) => { + lines.push(args.map(String).join(' ')); + }) as never), + ); + const out = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => { + lines.push(String(chunk)); + return true; + }) as never); + const err = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { + lines.push(String(chunk)); + return true; + }) as never); + return { + lines, + restore: () => { + for (const spy of spies) { + spy.mockRestore(); + } + out.mockRestore(); + err.mockRestore(); + }, + }; + } + + /** The harness, with the email seams wired to the transcript fakes. */ + function emailHarness( + options: { + runner?: Record; + fetchOverrides?: Record Response>; + subaddressing?: boolean; + dns?: EmailDns; + confirm?: (message: string) => Promise; + isCI?: boolean; + deps?: Partial; + } = {}, + ) { + const spawns: Array = []; + const fetchCalls: Array = []; + const confirms: Array = []; + const trace: Array = []; + const base = harness({ + isCI: options.isCI ?? false, + hashWranglerUploader: () => { + trace.push('hash'); + return 'uploader-hash-unchanged'; + }, + resolveSchemaFacts: (() => { + trace.push('probe'); + return okSchemaFacts(); + }) as CloudflareDeployDeps['resolveSchemaFacts'], + runBuild: () => { + trace.push('build'); + }, + runWrangler: emailRunner(options.runner ?? {}, spawns, trace), + emailFetch: emailFetch(fetchCalls, options.fetchOverrides, options.subaddressing), + emailDns: options.dns ?? emailDns(), + confirmEmailSetup: + options.confirm ?? + (async (message) => { + confirms.push(message); + return true; + }), + ...options.deps, + }); + return { ...base, spawns, fetchCalls, confirms, trace }; + } + + const configArgs = (root: string): Array => [ + '-c', + join(root, 'wrangler.jsonc'), + '--env=', + ]; + + it('first deploy, one Enter: preflight → checklist → confirm → apply → write → re-parse → deploy', async () => { + const root = emailProject(); + const { deps, spawns, fetchCalls, confirms, trace, exitCalls } = emailHarness(); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + + // ONE prompt, default-Yes wording, naming the mail domain. + expect(confirms).toEqual(['Set up email on mail.acme.com?']); + + // Exact argv per branch — every email call carries the SAME `-c --env=` context as + // whoami / migrate / deploy, and is captured (its prose is parsed, never streamed). + const emailSpawns = spawns.filter((s) => s.args[0] === 'email'); + expect(emailSpawns.map((s) => s.args)).toEqual([ + ['email', 'routing', 'settings', 'mail.acme.com', ...configArgs(root)], + ['email', 'routing', 'settings', 'acme.com', ...configArgs(root)], + ['email', 'routing', 'rules', 'list', 'acme.com', ...configArgs(root)], + ['email', 'sending', 'settings', 'mail.acme.com', ...configArgs(root)], + ['email', 'sending', 'enable', 'mail.acme.com', ...configArgs(root)], + ]); + for (const spawn of emailSpawns) { + expect(spawn.opts.capture).toBe(true); + } + // The two bearer writes wrangler has no command for (subdomain routing + subaddressing). + expect(fetchCalls.filter((c) => c.method !== 'GET')).toEqual([ + { + method: 'POST', + path: `/zones/${ZONE_ID}/email/routing/dns`, + body: JSON.stringify({ name: 'mail.acme.com' }), + auth: `Bearer ${BEARER}`, + }, + { + method: 'PATCH', + path: `/zones/${ZONE_ID}/email/routing`, + body: JSON.stringify({ support_subaddress: true }), + auth: `Bearer ${BEARER}`, + }, + ]); + + // The root config now carries the binding, the owned addresses array, and the sender var. + const written = readRootConfig(root); + expect(written.send_email).toEqual([{ name: 'SEND_EMAIL' }]); + expect(written.addresses).toEqual(['support@mail.acme.com']); + expect((written.vars as Record).__VOID_EMAIL_FROM).toBe( + 'noreply@mail.acme.com', + ); + // …and the deploy went through: `rootConfig` was re-parsed after the write, or the + // laundering guard (6a) would have rejected `send_email` / `addresses` as probe-injected. + expect(trace).toContain('build'); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + + // The checklist, the apply rows, and the post-deploy address map were printed. + const printed = output.lines.join('\n'); + expect(printed).toContain( + 'This changes YOUR Cloudflare account. acme.com itself is not touched.', + ); + expect(printed).toContain('✔ routing mail.acme.com enabled'); + expect(printed).toContain('✔ config wrangler.jsonc updated'); + expect(printed).toContain('inbound support@mail.acme.com → email/support+[ticket].ts'); + expect(printed).toContain('outbound sendEmail() from noreply@mail.acme.com'); + }); + + it('runs AFTER the uploader hash and BEFORE the env-schema probe (no project code has run)', async () => { + const root = emailProject(); + const { deps, trace } = emailHarness(); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + const firstEmailCall = trace.findIndex((t) => t.startsWith('wrangler:')); + const lastEmailCall = trace.lastIndexOf('wrangler:email sending'); + expect(trace[firstEmailCall]).toBe('wrangler:whoami --json'); + expect(trace.indexOf('hash')).toBeLessThan(firstEmailCall); + expect(lastEmailCall).toBeGreaterThan(firstEmailCall); + // The probe (3c) — the first project-code execution — comes only after the whole step. + expect(trace.indexOf('probe')).toBeGreaterThan(lastEmailCall); + expect(trace.indexOf('probe')).toBeLessThan(trace.indexOf('build')); + }); + + it('borrows the bearer via `auth token --json` with WRANGLER_WRITE_LOGS=false and never leaks it', async () => { + const root = emailProject(); + const { deps, spawns, fetchCalls } = emailHarness(); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + const authSpawns = spawns.filter((s) => s.args[0] === 'auth'); + expect(authSpawns.length).toBeGreaterThan(0); + for (const spawn of authSpawns) { + expect(spawn.args).toEqual(['auth', 'token', '--json', ...configArgs(root)]); + expect(spawn.opts).toEqual({ capture: true, env: { WRANGLER_WRITE_LOGS: 'false' } }); + } + // The token reached the Authorization header — and NOTHING else. + expect(fetchCalls.some((c) => c.auth === `Bearer ${BEARER}`)).toBe(true); + expect(output.lines.join('\n')).not.toContain(BEARER); + for (const spawn of spawns) { + expect(JSON.stringify(spawn.args)).not.toContain(BEARER); + expect(JSON.stringify(spawn.opts.env ?? {})).not.toContain(BEARER); + } + expect( + Object.values(process.env).some((v) => typeof v === 'string' && v.includes(BEARER)), + ).toBe(false); + // Only `auth token` gets the log pin; no other spawn carries any extra env. + for (const spawn of spawns.filter((s) => s.args[0] !== 'auth')) { + expect(spawn.opts.env).toBeUndefined(); + } + }); + + it('a failed `auth token` yields no bearer: the dashboard fallback is printed, addresses withheld, deploy continues', async () => { + const root = emailProject(); + const { deps, spawns, exitCalls } = emailHarness({ + runner: { + 'auth token --json': { + ...cleanExit, + status: 1, + stderr: '✘ [ERROR] Not logged in. Please run `wrangler login` to authenticate.\n', + }, + }, + }); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + const printed = output.lines.join('\n'); + expect(printed).toContain('Email → Settings → Subdomains'); + expect(printed).toContain('addresses withheld this run'); + // Sending still onboarded, so the binding + sender var are written; the key is NOT. + const written = readRootConfig(root); + expect(written.send_email).toEqual([{ name: 'SEND_EMAIL' }]); + expect(written.addresses).toBeUndefined(); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + }); + + it('--require-email fails when the apply wrote the binding but routing failed and addresses were withheld', async () => { + // The SAME run as the test above, with the flag: `auth token` fails, so the subdomain + // routing POST is never made and `addresses` is withheld. The worker would ship a + // SEND_EMAIL binding with NO inbound rule behind it — binding presence is not "set up". + const root = emailProject(); + const { deps, spawns, exitCalls, trace } = emailHarness({ + runner: { + 'auth token --json': { + ...cleanExit, + status: 1, + stderr: '✘ [ERROR] Not logged in. Please run `wrangler login` to authenticate.\n', + }, + }, + }); + const output = captureOutput(); + try { + await expect( + runCloudflareDeploy( + root, + { backend: 'cloudflare', requireEmail: true }, + silentCliLog, + deps, + ), + ).rejects.toBeInstanceOf(ExitError); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([1]); + const printed = output.lines.join('\n'); + expect(printed).toContain('addresses withheld this run'); + expect(printed).toContain('--require-email'); + // Nothing was built or uploaded; the config still records what the apply did manage. + expect(trace).not.toContain('probe'); + expect(trace).not.toContain('build'); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(false); + const written = readRootConfig(root); + expect(written.send_email).toEqual([{ name: 'SEND_EMAIL' }]); + expect(written.addresses).toBeUndefined(); + }); + + it('CI / non-TTY: prints the checklist + `void email setup` line, asks nothing, writes nothing, deploys without email', async () => { + const root = emailProject(); + const before = readFileSync(join(root, 'wrangler.jsonc'), 'utf-8'); + const { deps, spawns, fetchCalls, exitCalls, trace } = emailHarness({ + isCI: true, + confirm: async () => { + throw new Error('confirm must not be called in CI'); + }, + }); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + const printed = output.lines.join('\n'); + expect(printed).toContain('This changes YOUR Cloudflare account.'); + expect(printed).toContain( + 'Run `void email setup --platform cloudflare` once locally, commit wrangler.jsonc, then redeploy', + ); + // No apply: no bearer writes, no `sending enable`, no config write. + expect(fetchCalls.filter((c) => c.method !== 'GET')).toEqual([]); + expect(spawns.some((s) => s.args[0] === 'email' && s.args[2] === 'enable')).toBe(false); + // Step 5 still reconciles name/account_id, so compare the email keys, not the bytes. + const after = readRootConfig(root); + expect(after.send_email).toBeUndefined(); + expect(after.addresses).toBeUndefined(); + expect(after.vars).toBeUndefined(); + expect(JSON.parse(before)).toMatchObject({ name: 'myapp', account_id: 'acc123' }); + // The deploy itself went on. + expect(trace).toContain('build'); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + expect(printed).not.toContain('inbound support@mail.acme.com'); + }); + + it('--require-email in CI fails BEFORE the probe, the build, and any upload', async () => { + const root = emailProject(); + const { deps, spawns, exitCalls, trace } = emailHarness({ isCI: true }); + const output = captureOutput(); + try { + await expect( + runCloudflareDeploy( + root, + { backend: 'cloudflare', requireEmail: true }, + silentCliLog, + deps, + ), + ).rejects.toBeInstanceOf(ExitError); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([1]); + expect(trace).not.toContain('probe'); + expect(trace).not.toContain('build'); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(false); + expect(output.lines.join('\n')).toContain('--require-email'); + }); + + it('--require-email also fails when the developer answers No at the prompt', async () => { + const root = emailProject(); + const { deps, exitCalls, trace } = emailHarness({ confirm: async () => false }); + const output = captureOutput(); + try { + await expect( + runCloudflareDeploy( + root, + { backend: 'cloudflare', requireEmail: true }, + silentCliLog, + deps, + ), + ).rejects.toBeInstanceOf(ExitError); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([1]); + expect(trace).not.toContain('build'); + expect(readRootConfig(root).send_email).toBeUndefined(); + }); + + it('a preflight blocker (session without the email scopes) prints why and deploys without email', async () => { + const root = emailProject(); + const { deps, spawns, confirms, exitCalls } = emailHarness({ + runner: { + 'whoami --json': { + ...cleanExit, + stdout: JSON.stringify({ loggedIn: true, email: 'dev@acme.com', tokenPermissions: [] }), + }, + }, + }); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + expect(confirms).toEqual([]); + const printed = output.lines.join('\n'); + expect(printed).toContain('email_routing:write + email_sending:write'); + // The checklist goes through `formatCloudflareUserMessage` on this path, which turns a + // `wrangler …` command into "the underlying Cloudflare operation" — so the guidance must + // already be a Void command at the source to survive as guidance. + expect(printed).toContain('run `void cloudflare logout`, then `void cloudflare login`'); + expect(printed).not.toContain('underlying Cloudflare operation'); + expect(printed).toContain('deploying without email'); + // Nothing past whoami was asked of wrangler for email, and nothing was written. + expect(spawns.some((s) => s.args[0] === 'email')).toBe(false); + expect(readRootConfig(root).send_email).toBeUndefined(); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + }); + + it('no `email.from`: says what to add to void.json and deploys without email (no wrangler email call)', async () => { + const root = emailProject(PROVISIONED_D1, {}, {}); + const { deps, spawns, exitCalls } = emailHarness(); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + expect(output.lines.join('\n')).toContain( + 'Add "email": { "from": "you@mail.acme.com" } to void.json', + ); + expect(spawns.some((s) => s.args[0] === 'email' || s.args[0] === 'auth')).toBe(false); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + }); + + it('second deploy, everything ready: no prompt, no write, no bearer write, address map printed', async () => { + const root = emailProject({ + ...PROVISIONED_D1, + send_email: [{ name: 'SEND_EMAIL' }], + addresses: ['support@mail.acme.com'], + vars: { __VOID_EMAIL_FROM: 'noreply@mail.acme.com' }, + }); + const before = readRootConfig(root); + const { deps, spawns, fetchCalls, exitCalls } = emailHarness({ + runner: { + 'email routing rules list acme.com': myRule, + 'email sending settings mail.acme.com': sendingEnabled, + }, + subaddressing: true, + dns: emailDns({ 'mail.acme.com': ['route1.mx.cloudflare.net', 'route2.mx.cloudflare.net'] }), + confirm: async () => { + throw new Error('confirm must not be called when every row is ready'); + }, + }); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + expect(fetchCalls.filter((c) => c.method !== 'GET')).toEqual([]); + expect(spawns.some((s) => s.args[0] === 'email' && s.args[2] === 'enable')).toBe(false); + const after = readRootConfig(root); + expect(after.send_email).toEqual(before.send_email); + expect(after.addresses).toEqual(before.addresses); + expect(after.vars).toEqual(before.vars); + const printed = output.lines.join('\n'); + expect(printed).not.toContain('This changes YOUR Cloudflare account'); + expect(printed).toContain('inbound support@mail.acme.com → email/support+[ticket].ts'); + expect(printed).toContain('outbound sendEmail() from noreply@mail.acme.com'); + expect(printed).not.toContain('verified destinations only'); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + }); + + it('a hand-written `addresses` array Void did not derive skips the step, array untouched (decision 13)', async () => { + const root = emailProject({ ...PROVISIONED_D1, addresses: ['ceo@acme.com'] }); + const { deps, spawns, confirms, exitCalls } = emailHarness(); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + expect(confirms).toEqual([]); + expect(output.lines.join('\n')).toContain('ceo@acme.com'); + expect(readRootConfig(root).addresses).toEqual(['ceo@acme.com']); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + }); + + /** The committed Mode B config a first deploy leaves behind. */ + const COMMITTED_EMAIL = { + ...PROVISIONED_D1, + send_email: [{ name: 'SEND_EMAIL' }], + addresses: ['support@mail.acme.com'], + vars: { __VOID_EMAIL_FROM: 'noreply@mail.acme.com' }, + }; + /** Every account row ready: routing on the subdomain, subaddressing on, sending onboarded. */ + const readyRows = { + runner: { 'email sending settings mail.acme.com': sendingEnabled }, + subaddressing: true, + dns: emailDns({ 'mail.acme.com': ['route1.mx.cloudflare.net', 'route2.mx.cloudflare.net'] }), + confirm: async () => { + throw new Error('confirm must not be called when every account row is ready'); + }, + }; + + it('an API-token session (CLOUDFLARE_API_TOKEN) in CI with a committed config: --require-email passes, no prompt, map printed', async () => { + // The CI credential. wrangler reports no `tokenPermissions` for it (they come from the + // stored login file), so gating on them would fail every CI deploy before the build. + const root = emailProject(COMMITTED_EMAIL); + const { deps, spawns, fetchCalls, exitCalls, trace } = emailHarness({ + ...readyRows, + runner: { + ...readyRows.runner, + 'whoami --json': { + ...cleanExit, + stdout: JSON.stringify({ + loggedIn: true, + authType: 'User API Token', + accounts: [{ id: 'acc123', name: 'Acme' }], + }), + }, + 'auth token --json': { + ...cleanExit, + stdout: JSON.stringify({ type: 'api_token', token: BEARER }), + }, + 'email routing rules list acme.com': myRule, + }, + isCI: true, + }); + const output = captureOutput(); + try { + await runCloudflareDeploy( + root, + { backend: 'cloudflare', requireEmail: true }, + silentCliLog, + deps, + ); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + expect(fetchCalls.filter((c) => c.method !== 'GET')).toEqual([]); + expect(spawns.some((s) => s.args[0] === 'email' && s.args[2] === 'enable')).toBe(false); + expect(trace).toContain('build'); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + const printed = output.lines.join('\n'); + expect(printed).not.toContain('wrangler login'); + expect(printed).toContain('inbound support@mail.acme.com → email/support+[ticket].ts'); + }); + + it('committed config, rule not created yet (right after `void email setup`): CI + --require-email proceeds, the map says the deploy creates it', async () => { + const root = emailProject(COMMITTED_EMAIL); + const { deps, spawns, exitCalls, trace } = emailHarness({ ...readyRows, isCI: true }); + const output = captureOutput(); + try { + await runCloudflareDeploy( + root, + { backend: 'cloudflare', requireEmail: true }, + silentCliLog, + deps, + ); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + expect(trace).toContain('build'); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + const printed = output.lines.join('\n'); + expect(printed).not.toContain('deploying without email'); + expect(printed).not.toContain('This changes YOUR Cloudflare account'); + expect(printed).toContain( + 'inbound support@mail.acme.com → email/support+[ticket].ts (rule created by this deploy)', + ); + expect(readRootConfig(root).addresses).toEqual(['support@mail.acme.com']); + }); + + /** wrangler's `sending enable` on Workers Free: the documented not-entitled code. */ + const sendingRefused: DeployWranglerRunResult = { + ...cleanExit, + status: 1, + stderr: `${BETA_WARNING}✘ [ERROR] A request to the Cloudflare API (/zones/${ZONE_ID}/email/sending/subdomains) failed.\n\n something went wrong [code: 10105]\n`, + }; + + it('--require-email passes on Workers Free: routing is up, `sending enable` answers 10105, the binding written alongside remembers it', async () => { + // Every other row is ready; only Email Sending is refused (no entitlement). Inbound is + // complete — replies go through the native `message.reply()`, never the binding — and + // the apply writes the binding, which is what the NEXT preflight reads as the memory of + // this refusal (binding present + sending pending is settled; the memory rule in + // `preflightEmail`). So `satisfied` carries no sending term of its own: the deploy + // proceeds, the hint is printed once, and the map says what sending can do. + const root = emailProject(); + const { deps, spawns, exitCalls, trace } = emailHarness({ + ...readyRows, + runner: { 'email sending enable mail.acme.com': sendingRefused }, + confirm: async () => true, + }); + const output = captureOutput(); + try { + await runCloudflareDeploy( + root, + { backend: 'cloudflare', requireEmail: true }, + silentCliLog, + deps, + ); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + const printed = output.lines.join('\n'); + expect(printed).toContain('[code: 10105]'); + expect(printed).toContain(SENDING_FREE_PLAN_HINT); + expect(printed).not.toContain('--require-email'); + expect(printed).toContain( + 'outbound sendEmail() from noreply@mail.acme.com (verified destinations only)', + ); + expect(trace).toContain('build'); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + // Inbound DID come up, and the config records it — with the binding that remembers. + const written = readRootConfig(root); + expect(written.send_email).toEqual([{ name: 'SEND_EMAIL' }]); + expect(written.addresses).toEqual(['support@mail.acme.com']); + expect(written.vars).toEqual({ __VOID_EMAIL_FROM: 'noreply@mail.acme.com' }); + }); + + /** + * The Workers Free account AFTER the deploy above (or `void email setup`, which exits 0 + * there): the binding, `addresses` and the sender var are committed, routing + + * subaddressing are on, the rule exists, and `sending settings` still answers not-found — + * the same answer a Paid account that never onboarded gives. The committed binding is the + * memory of the refusal, so the preflight reads ready on every later deploy: no prompt in + * a TTY, nothing to ask in CI, `--require-email` passes, and the onboarding is never + * retried by the deploy — `void email setup` is where that retry is offered. + */ + const COMMITTED_ON_FREE = { + ...readyRows, + runner: { + 'email routing rules list acme.com': myRule, + 'email sending settings mail.acme.com': sendingNotFound, + }, + }; + + for (const [shell, isCI] of [ + ['CI', true], + ['a TTY', false], + ] as const) { + it(`Workers Free, committed config, ${shell} + --require-email: the refusal is remembered — no prompt, no \`sending enable\`, the deploy proceeds`, async () => { + const root = emailProject(COMMITTED_EMAIL); + const before = readRootConfig(root); + const { deps, spawns, fetchCalls, confirms, exitCalls, trace } = emailHarness({ + ...COMMITTED_ON_FREE, + isCI, + // Record the prompt instead of throwing on it, so the assertion names the failure. + confirm: undefined, + }); + const output = captureOutput(); + try { + await runCloudflareDeploy( + root, + { backend: 'cloudflare', requireEmail: true }, + silentCliLog, + deps, + ); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + expect(confirms).toEqual([]); + // The probe of sending still runs (read-only); the onboarding is NOT retried. + expect(spawns.some((s) => s.args[0] === 'email' && s.args[1] === 'sending')).toBe(true); + expect(spawns.some((s) => s.args[0] === 'email' && s.args[2] === 'enable')).toBe(false); + expect(fetchCalls.filter((c) => c.method !== 'GET')).toEqual([]); + const printed = output.lines.join('\n'); + expect(printed).not.toContain('This changes YOUR Cloudflare account'); + expect(printed).not.toContain('cannot ask'); + expect(printed).not.toContain('deploying without email'); + expect(printed).not.toContain('--require-email'); + expect(printed).not.toContain('[code: 10105]'); + expect(printed).toContain('inbound support@mail.acme.com → email/support+[ticket].ts'); + expect(printed).toContain( + 'outbound sendEmail() from noreply@mail.acme.com (verified destinations only)', + ); + expect(trace).toContain('build'); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + // The committed config is left exactly as the refused run wrote it. + const after = readRootConfig(root); + expect(after.send_email).toEqual(before.send_email); + expect(after.addresses).toEqual(before.addresses); + expect(after.vars).toEqual(before.vars); + }); + } + + it('--require-email passes on a fully ready second deploy: nothing to apply, zero keystrokes', async () => { + // The headline flow. No apply runs, so readiness comes from the PREFLIGHT result. + const root = emailProject(COMMITTED_EMAIL); + const { deps, spawns, fetchCalls, confirms, exitCalls, trace } = emailHarness({ + ...readyRows, + runner: { ...readyRows.runner, 'email routing rules list acme.com': myRule }, + }); + const output = captureOutput(); + try { + await runCloudflareDeploy( + root, + { backend: 'cloudflare', requireEmail: true }, + silentCliLog, + deps, + ); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + expect(confirms).toEqual([]); + expect(fetchCalls.filter((c) => c.method !== 'GET')).toEqual([]); + expect(spawns.some((s) => s.args[0] === 'email' && s.args[2] === 'enable')).toBe(false); + expect(trace).toContain('build'); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + expect(output.lines.join('\n')).not.toContain('--require-email'); + }); + + it('ready path: a committed `addresses` entry no longer in the pruned set is rewritten BEFORE `wrangler deploy`, without a prompt', async () => { + // Deploy 1 wrote [support, billing]; billing@ was later re-pointed at a forward in the + // dashboard. Left in the array it reaches wrangler's plan as a takeover `conflict`. + const forwardedBilling: DeployWranglerRunResult = { + ...myRule, + stdout: myRule.stdout.replace( + '\n\nCatch-all', + '\nRule: 4d5e6f\n Name: (none)\n Enabled: true\n Matchers: to:billing@mail.acme.com\n Actions: forward:ceo@gmail.com\n Priority: 1\n\n\nCatch-all', + ), + }; + const root = emailProject( + { ...COMMITTED_EMAIL, addresses: ['support@mail.acme.com', 'billing@mail.acme.com'] }, + { 'email/billing.ts': 'export default async () => {};' }, + ); + const atDeploy: Array = []; + const { deps, spawns, fetchCalls, exitCalls } = emailHarness({ + ...readyRows, + runner: { ...readyRows.runner, 'email routing rules list acme.com': forwardedBilling }, + }); + const inner = deps.runWrangler!; + deps.runWrangler = (args, opts) => { + if (args[0] === 'deploy') { + atDeploy.push(readRootConfig(root).addresses); + } + return inner(args, opts); + }; + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + expect(fetchCalls.filter((c) => c.method !== 'GET')).toEqual([]); + expect(spawns.some((s) => s.args[0] === 'email' && s.args[2] === 'enable')).toBe(false); + // Rewritten to the pruned set, and already so when `wrangler deploy` read the config. + expect(atDeploy).toEqual([['support@mail.acme.com']]); + const after = readRootConfig(root); + expect(after.addresses).toEqual(['support@mail.acme.com']); + expect(after.send_email).toEqual(COMMITTED_EMAIL.send_email); + expect(after.vars).toEqual(COMMITTED_EMAIL.vars); + const printed = output.lines.join('\n'); + expect(printed).toContain('billing@mail.acme.com: already routed to forward:ceo@gmail.com'); + expect(printed).toContain('wrangler.jsonc updated (addresses: ["support@mail.acme.com"])'); + expect(printed).toContain('inbound support@mail.acme.com → email/support+[ticket].ts'); + }); + + it('ready path: after a worker rename every rule is foreign, so the `addresses` key is removed (never `[]`)', async () => { + const root = emailProject({ ...COMMITTED_EMAIL, name: 'acme-mail' }); + const { deps, spawns, exitCalls } = emailHarness({ + ...readyRows, + runner: { ...readyRows.runner, 'email routing rules list acme.com': myRule }, + }); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + const after = readRootConfig(root); + expect(after.addresses).toBeUndefined(); + expect(after.send_email).toEqual(COMMITTED_EMAIL.send_email); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + expect(output.lines.join('\n')).toContain('wrangler.jsonc updated (addresses removed)'); + }); + + it('ready path: a changed `email.from` on the same domain rewrites `__VOID_EMAIL_FROM` without a prompt', async () => { + // Only the root var reaches the worker on build; without the rewrite the worker keeps + // sending as the old address while the map advertises the new one. + const root = emailProject(COMMITTED_EMAIL, {}, { email: { from: 'hello@mail.acme.com' } }); + const { deps, spawns, fetchCalls, exitCalls } = emailHarness({ + ...readyRows, + runner: { ...readyRows.runner, 'email routing rules list acme.com': myRule }, + }); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + expect(fetchCalls.filter((c) => c.method !== 'GET')).toEqual([]); + expect(spawns.some((s) => s.args[0] === 'email' && s.args[2] === 'enable')).toBe(false); + const after = readRootConfig(root); + expect((after.vars as Record).__VOID_EMAIL_FROM).toBe('hello@mail.acme.com'); + expect(after.addresses).toEqual(['support@mail.acme.com']); + expect(after.send_email).toEqual(COMMITTED_EMAIL.send_email); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + const printed = output.lines.join('\n'); + expect(printed).toContain( + 'wrangler.jsonc updated (vars.__VOID_EMAIL_FROM: "hello@mail.acme.com")', + ); + expect(printed).toContain('outbound sendEmail() from hello@mail.acme.com'); + }); + + it('a committed `addresses` array is withdrawn when routing is off, on the CI, declined and blocked exits; the worker ships with its binding', async () => { + // Left in place, wrangler's plan on it 400s (2062) AFTER the upload, on every deploy. + // Deleting the KEY changes no rule (wrangler skips the plan on `undefined`). + const cases: Array<{ + name: string; + options: Parameters[0]; + wantConfirm: boolean; + }> = [ + { + name: 'CI', + options: { + isCI: true, + confirm: async () => { + throw new Error('confirm must not be called in CI'); + }, + }, + wantConfirm: false, + }, + { name: 'declined', options: { confirm: async () => false }, wantConfirm: false }, + { + name: 'blocked (apex unlocked)', + options: { + runner: { 'email routing settings acme.com': routingSettings('acme.com', 'unlocked') }, + }, + wantConfirm: false, + }, + ]; + for (const c of cases) { + const root = emailProject(COMMITTED_EMAIL); + // The default harness DNS has no MX on mail.acme.com: routing reads `pending`. + const { deps, spawns, fetchCalls, confirms, exitCalls, trace } = emailHarness(c.options); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls, c.name).toEqual([]); + expect(confirms.length > 0, c.name).toBe(c.wantConfirm); + expect( + fetchCalls.filter((f) => f.method !== 'GET'), + c.name, + ).toEqual([]); + expect( + spawns.some((s) => s.args[0] === 'email' && s.args[2] === 'enable'), + c.name, + ).toBe(false); + const after = readRootConfig(root); + expect(after.addresses, c.name).toBeUndefined(); + expect(after.send_email, c.name).toEqual(COMMITTED_EMAIL.send_email); + expect(after.vars, c.name).toEqual(COMMITTED_EMAIL.vars); + expect(trace, c.name).toContain('build'); + expect( + spawns.some((s) => s.args[0] === 'deploy'), + c.name, + ).toBe(true); + const printed = output.lines.join('\n'); + expect(printed, c.name).toContain('addresses removed from wrangler.jsonc'); + expect(printed, c.name).toContain('support@mail.acme.com'); + // The binding is committed, so the deploy is WITH email — the wording and the map say so. + expect(printed, c.name).not.toContain('deploying without email'); + expect(printed, c.name).toContain( + 'deploying with the email setup wrangler.jsonc already carries', + ); + expect(printed, c.name).toContain('outbound sendEmail() from noreply@mail.acme.com'); + } + }); + + it('a committed `addresses` array Void did not derive stays untouched when routing is off too (decision 13)', async () => { + const root = emailProject({ ...COMMITTED_EMAIL, addresses: ['ceo@acme.com'] }); + const { deps, spawns, confirms, exitCalls } = emailHarness({ isCI: true }); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + expect(confirms).toEqual([]); + expect(readRootConfig(root).addresses).toEqual(['ceo@acme.com']); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + }); + + it('refuses a config the writer could not update BEFORE the account is touched (binding-name collision)', async () => { + // `vars.SEND_EMAIL` already exists: the write after the apply would fail, so the step is + // blocked up front — no prompt, no bearer write, no `sending enable`. + const root = emailProject({ ...PROVISIONED_D1, vars: { SEND_EMAIL: 'x' } }); + const { deps, spawns, fetchCalls, confirms, exitCalls } = emailHarness(); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + expect(confirms).toEqual([]); + expect(fetchCalls.filter((c) => c.method !== 'GET')).toEqual([]); + expect(spawns.some((s) => s.args[0] === 'email' && s.args[2] === 'enable')).toBe(false); + expect(output.lines.join('\n')).toContain( + 'already uses SEND_EMAIL as a Environment Variable binding', + ); + expect(readRootConfig(root).send_email).toBeUndefined(); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + }); + + it('an app that stopped using email warns about the setup wrangler.jsonc still carries, and deploys as-is', async () => { + // No `email/` directory and no `void/email` import, but the root still has what a Mode B + // deploy wrote: wrangler keeps routing support@ to a worker with no `email()` export. + const root = d1Project(COMMITTED_EMAIL, INIT_MIGRATION); + const { deps, spawns, exitCalls } = emailHarness({ + confirm: async () => { + throw new Error('confirm must not be called without email use'); + }, + }); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + expect(spawns.some((s) => s.args[0] === 'auth' || s.args[0] === 'email')).toBe(false); + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + expect(readRootConfig(root).addresses).toEqual(['support@mail.acme.com']); + const printed = output.lines.join('\n'); + expect(printed).toContain('no longer declares email handlers'); + expect(printed).toContain('support@mail.acme.com — still routed to worker "myapp"'); + expect(printed).toContain('send_email SEND_EMAIL binding'); + // The detach guidance names a dashboard path and a Void command, not a wrangler command + // (which the deploy log wrapper would render as "the underlying Cloudflare operation"). + expect(printed).toContain( + 'delete the rules in the Cloudflare dashboard (Email → Email Routing → Routing rules), or set `addresses: []` and let `void deploy --platform cloudflare` ask', + ); + expect(printed).not.toContain('underlying Cloudflare operation'); + }); + + it('last inbound handler removed, outbound `void/email` kept: the committed entry is a decision-13 blocker, the array stays, and the trigger snapshot still carries it (the ready-path `null` write is never reached)', async () => { + // Deploy 1 wrote COMMITTED_EMAIL from `email/support+[ticket].ts`. The handler is gone, but + // `routes/index.ts` still imports `void/email`, so `needsEmail` holds and the email step runs + // with an EMPTY derived set. Preflight step 8 reads the committed support@ entry as one Void + // did not derive (it is not in the empty candidate set): ownership `foreign`, an all-scope + // blocker, the step skips with the array untouched. `patch.addresses = null` (the ready path) + // is never reached, and the version annotation replays the entry — wrangler keeps the rule + // routed to a worker with no `email()` export. + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({ email: { from: 'noreply@mail.acme.com' } }), + 'routes/index.ts': [ + `import { sendEmail } from 'void/email';`, + `export const GET = async (c) => {`, + ` await sendEmail({ to: 'a@b.c', subject: 's', text: 't' });`, + ` return c.env.DB.prepare('SELECT 1').first();`, + `};`, + ].join('\n'), + 'wrangler.jsonc': JSON.stringify(COMMITTED_EMAIL, null, 2), + ...INIT_MIGRATION, + }); + const snapshots: Array = []; + const { deps, spawns, confirms, exitCalls } = emailHarness({ + ...readyRows, + runner: { ...readyRows.runner, 'email routing rules list acme.com': myRule }, + deps: { + // The real build passes the root's `addresses` through verbatim (whole-object pinned); + // mirror that so the annotation `activateVersion` receives is the one a deploy carries. + runBuild: () => { + const rootConfig = readRootConfig(root); + mkdirSync(join(root, 'dist/ssr'), { recursive: true }); + writeFileSync( + join(root, 'dist/ssr/wrangler.json'), + JSON.stringify({ + name: 'myapp', + account_id: 'acc123', + ...(rootConfig.addresses === undefined ? {} : { addresses: rootConfig.addresses }), + }), + ); + }, + }, + }); + const inner = deps.activateVersion!; + deps.activateVersion = async (options) => { + snapshots.push(parseCloudflareVersionMessage(options.message)); + return inner(options); + }; + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + expect(confirms).toEqual([]); + // The email step RAN (preflight probes), then blocked — no write, array untouched. + expect(spawns.some((s) => s.args[0] === 'email' && s.args[1] === 'routing')).toBe(true); + expect(readRootConfig(root).addresses).toEqual(['support@mail.acme.com']); + const printed = output.lines.join('\n'); + expect(printed).toContain( + 'addresses holds entries Void did not derive (support@mail.acme.com)', + ); + expect(printed).not.toContain('addresses removed'); + // The snapshot replays the stale entry: `addresses` reaches wrangler's plan as-is. + expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); + expect(snapshots).toEqual([ + expect.objectContaining({ + schema: 2, + triggerConfig: expect.objectContaining({ addresses: ['support@mail.acme.com'] }), + }), + ]); + }); + + it('an app with no email use never touches the email step (no whoami before 3e, no auth token)', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps, spawns, trace } = emailHarness({ + confirm: async () => { + throw new Error('confirm must not be called without email use'); + }, + }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(spawns.some((s) => s.args[0] === 'auth' || s.args[0] === 'email')).toBe(false); + // The ONLY whoami is 3e's, which comes after the probe. + expect(trace.filter((t) => t === 'wrangler:whoami --json')).toHaveLength(1); + expect(trace.indexOf('probe')).toBeLessThan(trace.indexOf('wrangler:whoami --json')); + }); +}); + +describe('runWranglerAuthToken — the session-bearer bridge, on its own', () => { + type AuthCall = { args: Array; opts: { capture: boolean; env?: Record } }; + const bridge = (result: DeployWranglerRunResult | (() => never)) => { + const calls: Array = []; + const run: DeployWranglerRun = (args, opts) => { + calls.push({ args: [...args], opts }); + return typeof result === 'function' ? result() : result; + }; + return { calls, token: runWranglerAuthToken(run, '/p/wrangler.jsonc') }; + }; + + it('spawns exactly `auth token --json` in the deploy config context, captured, logs off', async () => { + const { calls, token } = bridge({ + status: 0, + stdout: `{\n "type": "oauth",\n "token": "cfoa.abc"\n}\n`, + stderr: '', + signal: null, + error: null, + }); + expect(await token).toBe('cfoa.abc'); + expect(calls).toEqual([ + { + args: ['auth', 'token', '--json', '-c', '/p/wrangler.jsonc', '--env='], + opts: { capture: true, env: { WRANGLER_WRITE_LOGS: 'false' } }, + }, + ]); + }); + + it('accepts an api_token session and refuses an api_key pair (no single bearer to borrow)', async () => { + const ok = (stdout: string): DeployWranglerRunResult => ({ + status: 0, + stdout, + stderr: '', + signal: null, + error: null, + }); + expect(await bridge(ok('{"type":"api_token","token":"abc"}')).token).toBe('abc'); + expect( + await bridge(ok('{"type":"api_key","key":"k","email":"dev@example.com"}')).token, + ).toBeNull(); + expect(await bridge(ok('{"type":"oauth"}')).token).toBeNull(); + expect(await bridge(ok('not json')).token).toBeNull(); + }); + + it('resolves null on a failed, signal-killed, unspawnable, or throwing run — never throws', async () => { + expect( + await bridge({ status: 1, stdout: '', stderr: 'Not logged in.', signal: null, error: null }) + .token, + ).toBeNull(); + expect( + await bridge({ status: 0, stdout: '{}', stderr: '', signal: 'SIGKILL', error: null }).token, + ).toBeNull(); + expect( + await bridge({ status: 0, stdout: '{}', stderr: '', signal: null, error: 'ENOENT' }).token, + ).toBeNull(); + expect( + await bridge(() => { + throw new Error('boom'); + }).token, + ).toBeNull(); + }); +}); + +describe('runCloudflareDeploy — `addresses` is whole-object pinned across the build', () => { + function writeEmittedDeployArtifact(root: string, generated: Record): void { + mkdirSync(join(root, '.wrangler', 'deploy'), { recursive: true }); + mkdirSync(join(root, 'dist', 'ssr'), { recursive: true }); + writeFileSync( + join(root, '.wrangler', 'deploy', 'config.json'), + JSON.stringify({ configPath: '../../dist/ssr/wrangler.json' }), + ); + writeFileSync(join(root, 'dist', 'ssr', 'wrangler.json'), JSON.stringify(generated)); + } + const EMITTED_D1 = { + account_id: 'acc123', + name: 'myapp', + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + }; + + it('fails closed when the build injects an `addresses` array the root never declared (used to fail open)', async () => { + // wrangler ≥ 4.113 would turn this into a routing rule sending the account's inbound mail for + // `ceo@acme.com` to the deployed worker. No email step runs (no email use), so the pin alone + // must catch it. + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => + writeEmittedDeployArtifact(root, { ...EMITTED_D1, addresses: ['ceo@acme.com'] }), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + const errors: Array = []; + const errorSpy = vi.spyOn(log, 'error').mockImplementation((message) => { + errors.push(String(message)); + }); + try { + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + } finally { + errorSpy.mockRestore(); + } + expect(exitCalls).toEqual([1]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + expect(errors.join('\n')).toMatch(/addresses/); + }); + + it('fails closed when the build rewrites a root-declared `addresses` entry', async () => { + const root = d1Project({ ...PROVISIONED_D1, addresses: ['support@acme.com'] }, INIT_MIGRATION); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => + writeEmittedDeployArtifact(root, { ...EMITTED_D1, addresses: ['attacker@acme.com'] }), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await expect( + runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(false); + }); + + it('passes a root-declared `addresses` array the build passed through verbatim', async () => { + const root = d1Project({ ...PROVISIONED_D1, addresses: ['support@acme.com'] }, INIT_MIGRATION); + const { deps, wranglerArgs, exitCalls } = harness({ + runBuild: () => + writeEmittedDeployArtifact(root, { ...EMITTED_D1, addresses: ['support@acme.com'] }), + verifyDeployConfig: verifyGeneratedDeployConfig, + }); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + expect(wranglerArgs.some((a) => a[0] === 'deploy')).toBe(true); + }); + + it('verifyGeneratedDeployConfig: an emitted `addresses: []` with no root value FAILS (wrangler plans the deletion of every owned rule; the key is never default-filled), a root `[]` passes, a dropped root value fails', () => { + const emptyRoot = makeProject({ + '.wrangler/deploy/config.json': JSON.stringify({ + configPath: '../../dist/ssr/wrangler.json', + }), + 'dist/ssr/wrangler.json': JSON.stringify({ + account_id: 'acc123', + name: 'myapp', + addresses: [], + }), + }); + const injected = verifyGeneratedDeployConfig(emptyRoot, { + accountId: 'acc123', + workerName: 'myapp', + passthroughRawFields: snapshotPassthroughRawFields({}), + }); + expect(injected.ok).toBe(false); + expect(injected.reason).toMatch(/addresses/); + // A hand-written root `[]` passed through verbatim is the developer's own choice. + expect( + verifyGeneratedDeployConfig(emptyRoot, { + accountId: 'acc123', + workerName: 'myapp', + passthroughRawFields: snapshotPassthroughRawFields({ addresses: [] }), + }), + ).toEqual({ ok: true }); + const dropped = verifyGeneratedDeployConfig(emptyRoot, { + accountId: 'acc123', + workerName: 'myapp', + passthroughRawFields: snapshotPassthroughRawFields({ addresses: ['support@acme.com'] }), + }); + expect(dropped.ok).toBe(false); + expect(dropped.reason).toMatch(/addresses/); + }); +}); diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index 61e462abc..a8242232c 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -555,7 +555,7 @@ describe('compileRouteTable', () => { expect(code).toContain( 'export const RemovedChatWs = __createWebSocketTombstoneDurableObject("RemovedChatWs")', ); - expect(compileWorkerEntry(false, undefined, undefined, ['RemovedChatWs'])).toContain( + expect(compileWorkerEntry({ webSocketClasses: ['RemovedChatWs'] })).toContain( 'export { RemovedChatWs };', ); }); @@ -2251,26 +2251,15 @@ describe('default-target dev parity', () => { describe('compileWorkerEntry', () => { it('re-exports typed Durable Object modules as worker classes', () => { - const code = compileWorkerEntry( - false, - undefined, - 'assets', - [], - 'void/_env', - undefined, - false, - false, - undefined, - undefined, - 'none', - [ + const code = compileWorkerEntry({ + durableObjects: [ { bindingName: 'COUNTER', className: 'CounterDurableObject', filePath: '/app/durable-objects/counter.ts', }, ], - ); + }); expect(code).toContain('import __voidDurableObject0 from "/app/durable-objects/counter.ts";'); expect(code).toContain('__voidDurableObject0.__bind("COUNTER");'); @@ -2285,13 +2274,24 @@ describe('compileWorkerEntry', () => { expect(code).toContain('import { withRuntimeEnv } from "void/_env"'); expect(code).toContain('serveWithAssets'); expect(code).toContain('return withRuntimeEnv(env, async () => {'); - expect(code).toContain('export default { fetch:'); - expect(code).toContain('scheduled: handleScheduled'); + expect(code).toContain('import { WorkerEntrypoint } from "cloudflare:workers";'); + expect(code).toContain('export default class extends WorkerEntrypoint {'); + expect(code).toContain( + 'async fetch(request) { return __voidFetch(request, this.env, this.ctx); }', + ); + expect(code).toContain( + 'async scheduled(controller) { return handleScheduled(controller, this.env, this.ctx); }', + ); expect(code).not.toContain('queue('); + expect(code).not.toContain('email('); }); it('re-exports websocket classes when present', () => { - const code = compileWorkerEntry(false, undefined, 'assets', ['ChatRoomWs']); + const code = compileWorkerEntry({ + hasQueues: false, + assetsDir: 'assets', + webSocketClasses: ['ChatRoomWs'], + }); expect(code).toContain('__voidHandleWebSocket'); expect(code).toContain('ChatRoomWs'); @@ -2300,17 +2300,15 @@ describe('compileWorkerEntry', () => { }); it('re-exports the live Durable Object class when enabled', () => { - const code = compileWorkerEntry( - false, - undefined, - 'assets', - [], - 'void/_env', - undefined, - false, - false, - true, - ); + const code = compileWorkerEntry({ + hasQueues: false, + assetsDir: 'assets', + webSocketClasses: [], + runtimeEnvPath: 'void/_env', + sandbox: false, + serveSpaFallback: false, + live: true, + }); expect(code).toContain('VoidLiveStreamDurableObject'); expect(code).toContain('export { VoidLiveStreamDurableObject };'); @@ -2318,20 +2316,18 @@ describe('compileWorkerEntry', () => { }); it('re-exports the configured live Durable Object class', () => { - const code = compileWorkerEntry( - false, - undefined, - 'assets', - [], - 'void/_env', - undefined, - false, - false, - { + const code = compileWorkerEntry({ + hasQueues: false, + assetsDir: 'assets', + webSocketClasses: [], + runtimeEnvPath: 'void/_env', + sandbox: false, + serveSpaFallback: false, + live: { bindingName: 'VOID_LIVE_abcd1234', className: 'VoidLiveStreamDurableObject_abcd1234', }, - ); + }); expect(code).toContain( 'export { VoidLiveStreamDurableObject as VoidLiveStreamDurableObject_abcd1234 };', @@ -2340,7 +2336,13 @@ describe('compileWorkerEntry', () => { }); it('re-exports the VoidPlatformSandbox Durable Object class when enabled', () => { - const code = compileWorkerEntry(false, undefined, 'assets', [], 'void/_env', undefined, true); + const code = compileWorkerEntry({ + hasQueues: false, + assetsDir: 'assets', + webSocketClasses: [], + runtimeEnvPath: 'void/_env', + sandbox: true, + }); // Platform sandbox wraps Cloudflare's Sandbox with metering/gate hooks. // Importing it (instead of the bare Sandbox class) lets user subclasses @@ -2354,10 +2356,16 @@ describe('compileWorkerEntry', () => { }); it('exports the configured Sandbox Durable Object subclass via VoidPlatformSandbox', () => { - const code = compileWorkerEntry(false, undefined, 'assets', [], 'void/_env', undefined, { - bindingName: 'CODE', - className: 'CodeSandbox', - runtimePath: 'void/sandbox', + const code = compileWorkerEntry({ + hasQueues: false, + assetsDir: 'assets', + webSocketClasses: [], + runtimeEnvPath: 'void/_env', + sandbox: { + bindingName: 'CODE', + className: 'CodeSandbox', + runtimePath: 'void/sandbox', + }, }); expect(code).toContain( @@ -2371,8 +2379,14 @@ describe('compileWorkerEntry', () => { }); it('uses the resolved sandbox runtime path when one is provided', () => { - const code = compileWorkerEntry(false, undefined, 'assets', [], 'void/_env', undefined, { - runtimePath: '/abs/path/to/sandbox.mjs', + const code = compileWorkerEntry({ + hasQueues: false, + assetsDir: 'assets', + webSocketClasses: [], + runtimeEnvPath: 'void/_env', + sandbox: { + runtimePath: '/abs/path/to/sandbox.mjs', + }, }); expect(code).toContain( @@ -2390,12 +2404,12 @@ describe('compileWorkerEntry', () => { }); it('generates queue() export in worker entry when hasQueues=true', () => { - const code = compileWorkerEntry(true); + const code = compileWorkerEntry({ hasQueues: true }); - expect(code).toContain('async function queue(batch, env, ctx)'); + expect(code).toContain('async function __voidQueue(batch, env, ctx)'); expect(code).toContain('async function handleScheduled(controller, env, ctx)'); expect(code).toContain('serveWithAssets'); - expect(code).toContain('scheduled: handleScheduled, queue }'); + expect(code).toContain('async queue(batch) { return __voidQueue(batch, this.env, this.ctx); }'); expect(code).toContain('__processQueue'); expect(code).toContain('batch.queue'); expect(code).toContain('m.timestamp.toISOString()'); @@ -2404,7 +2418,7 @@ describe('compileWorkerEntry', () => { }); it('omits queue() export when hasQueues=false', () => { - const code = compileWorkerEntry(false); + const code = compileWorkerEntry({ hasQueues: false }); expect(code).not.toContain('queue'); }); @@ -2460,7 +2474,7 @@ describe('compileWorkerEntry', () => { }); it('uses custom assetsDir in serveWithAssets path check', () => { - const code = compileWorkerEntry(false, undefined, '_astro'); + const code = compileWorkerEntry({ hasQueues: false, assetsDir: '_astro' }); expect(code).toContain('"/_astro/"'); expect(code).not.toContain('"/assets/"'); @@ -2476,16 +2490,14 @@ describe('compileWorkerEntry', () => { }); it('serveWithAssets falls back to /index.html for unmatched HTML GET/HEADs when enabled', () => { - const code = compileWorkerEntry( - false, - undefined, - 'assets', - [], - 'void/_env', - undefined, - false, - true, - ); + const code = compileWorkerEntry({ + hasQueues: false, + assetsDir: 'assets', + webSocketClasses: [], + runtimeEnvPath: 'void/_env', + sandbox: false, + serveSpaFallback: true, + }); // With assetConfig.not_found_handling: 'none' (now the default for any // routes/middleware SPA), the asset binding returns 404 instead of a SPA @@ -2504,19 +2516,15 @@ describe('compileWorkerEntry', () => { // resolved handling has to reach the generated helper or the override is // inert on every worker-first app. it('serveWithAssets passes the asset 404 page through for not_found_handling: 404-page', () => { - const code = compileWorkerEntry( - false, - undefined, - 'assets', - [], - 'void/_env', - undefined, - false, - false, - undefined, - undefined, - '404-page', - ); + const code = compileWorkerEntry({ + hasQueues: false, + assetsDir: 'assets', + webSocketClasses: [], + runtimeEnvPath: 'void/_env', + sandbox: false, + serveSpaFallback: false, + notFound: '404-page', + }); // Pass the binding's own response through — CF's `404-page` is // directory-aware ("nearest 404.html"); a hardcoded `/404.html` fetch is not. @@ -2535,19 +2543,15 @@ describe('compileWorkerEntry', () => { }); it('serveWithAssets keeps the worker response for not_found_handling: none', () => { - const code = compileWorkerEntry( - false, - undefined, - 'assets', - [], - 'void/_env', - undefined, - false, - false, - undefined, - undefined, - 'none', - ); + const code = compileWorkerEntry({ + hasQueues: false, + assetsDir: 'assets', + webSocketClasses: [], + runtimeEnvPath: 'void/_env', + sandbox: false, + serveSpaFallback: false, + notFound: 'none', + }); // Byte-identical to the pre-existing default — `none` is what every // worker-first app infers, so this is the no-regression guard. @@ -2555,16 +2559,14 @@ describe('compileWorkerEntry', () => { }); it('serveWithAssets only falls back to index.html for HTML accept', () => { - const code = compileWorkerEntry( - false, - undefined, - 'assets', - [], - 'void/_env', - undefined, - false, - true, - ); + const code = compileWorkerEntry({ + hasQueues: false, + assetsDir: 'assets', + webSocketClasses: [], + runtimeEnvPath: 'void/_env', + sandbox: false, + serveSpaFallback: true, + }); // Non-HTML requests (e.g. JSON API calls hitting an unknown route) must // get the worker's original 404 — not the SPA shell — so clients can @@ -2573,7 +2575,7 @@ describe('compileWorkerEntry', () => { }); it('native queue handler calls __processQueue directly without HTTP', () => { - const code = compileWorkerEntry(true); + const code = compileWorkerEntry({ hasQueues: true }); // Native queue handler must call __processQueue directly (no /__void/queue HTTP round-trip) // This avoids the global-flag race condition with concurrent fetch events @@ -2594,7 +2596,7 @@ describe('compileWorkerEntry', () => { }); it('filters internal bindings in native queue handler', () => { - const code = compileWorkerEntry(true); + const code = compileWorkerEntry({ hasQueues: true }); // Queue handler must filter env before passing to __processQueue expect(code).toContain('const userEnv = __filterInternalBindings(env)'); @@ -2607,9 +2609,15 @@ describe('compileWorkerEntry', () => { }); it('splices boot-time env validation when envSchemaModule is provided', () => { - const code = compileWorkerEntry(true, undefined, 'assets', [], 'void/_env', { - importPath: '/abs/path/to/env.ts', - helperPath: '/resolved/env-public.mjs', + const code = compileWorkerEntry({ + hasQueues: true, + assetsDir: 'assets', + webSocketClasses: [], + runtimeEnvPath: 'void/_env', + envSchemaModule: { + importPath: '/abs/path/to/env.ts', + helperPath: '/resolved/env-public.mjs', + }, }); // Side-effect import of user env.ts so the schema is registered expect(code).toContain('import "/abs/path/to/env.ts"'); @@ -2621,13 +2629,13 @@ describe('compileWorkerEntry', () => { }); it('wraps remote-mode fetch and scheduled handlers with resolved runtime env', () => { - const code = compileWorkerEntry( - false, - { remotePath: '/resolved/remote.mjs', envPath: '/resolved/env-legacy.mjs' }, - 'assets', - [], - '/resolved/env.mjs', - ); + const code = compileWorkerEntry({ + hasQueues: false, + remote: { remotePath: '/resolved/remote.mjs', envPath: '/resolved/env-legacy.mjs' }, + assetsDir: 'assets', + webSocketClasses: [], + runtimeEnvPath: '/resolved/env.mjs', + }); expect(code).toContain('import { withRuntimeEnv } from "/resolved/env.mjs"'); expect(code).toContain('import { createRemoteEnv } from "/resolved/remote.mjs"'); @@ -2639,17 +2647,17 @@ describe('compileWorkerEntry', () => { }); it('passes custom binding names to remote env proxying', () => { - const code = compileWorkerEntry( - true, - { + const code = compileWorkerEntry({ + hasQueues: true, + remote: { bindingNames: { d1: 'myapp_db', kv: 'myapp_kv', r2: 'myapp_storage' }, remotePath: '/resolved/remote.mjs', envPath: '/resolved/env-legacy.mjs', }, - 'assets', - [], - '/resolved/env.mjs', - ); + assetsDir: 'assets', + webSocketClasses: [], + runtimeEnvPath: '/resolved/env.mjs', + }); expect(code).toContain( 'createRemoteEnv(env, {"d1":"myapp_db","kv":"myapp_kv","r2":"myapp_storage"})', @@ -2955,18 +2963,14 @@ describe('compileWorkerEntry — ISR wiring', () => { }); it('wires the ISR cache ladder with the two baked imports when the isr param is present', () => { - const code = compileWorkerEntry( - false, // hasQueues - undefined, // remote - 'assets', // assetsDir - [], // webSocketClasses - 'void/_env', // runtimeEnvPath - undefined, // envSchemaModule - undefined, // sandbox - false, // serveSpaFallback - undefined, // live - { runtimePath: 'void/isr', queryAllowlist: { '/search': ['q'] } }, - ); + const code = compileWorkerEntry({ + hasQueues: false, + assetsDir: 'assets', + webSocketClasses: [], + runtimeEnvPath: 'void/_env', + serveSpaFallback: false, + isr: { runtimePath: 'void/isr', queryAllowlist: { '/search': ['q'] } }, + }); // Baked import #1: handleIsr from the supplied runtime path. expect(code).toContain('import { handleIsr } from "void/isr"'); @@ -2998,18 +3002,14 @@ describe('compileWorkerEntry — ISR wiring', () => { // there is no request) so a self-host `revalidate()` from a scheduled or queue // handler can find it and purge KV instead of no-oping. it('stamps the deployment id at the top of the scheduled + queue handlers when isr is set', () => { - const code = compileWorkerEntry( - true, // hasQueues — emits the queue() handler too - undefined, // remote - 'assets', // assetsDir - [], // webSocketClasses - 'void/_env', // runtimeEnvPath - undefined, // envSchemaModule - undefined, // sandbox - false, // serveSpaFallback - undefined, // live - { runtimePath: 'void/isr' }, - ); + const code = compileWorkerEntry({ + hasQueues: true, + assetsDir: 'assets', + webSocketClasses: [], + runtimeEnvPath: 'void/_env', + serveSpaFallback: false, + isr: { runtimePath: 'void/isr' }, + }); // Scheduled handler stamps the id (deployment id only — no __VOID_ISR_HOST). expect(code).toContain( @@ -3019,7 +3019,7 @@ describe('compileWorkerEntry — ISR wiring', () => { ); // Queue handler stamps the id (deployment id only — no __VOID_ISR_HOST). expect(code).toContain( - 'async function queue(batch, env, ctx) {\n' + + 'async function __voidQueue(batch, env, ctx) {\n' + ' env.__VOID_ISR_DEPLOYMENT_ID ??= __VOID_ISR_DEPLOYMENT_ID;\n' + ' const userEnv = __filterInternalBindings(env);', ); @@ -3028,7 +3028,7 @@ describe('compileWorkerEntry — ISR wiring', () => { }); it('does NOT stamp the deployment id in scheduled/queue handlers when isr is absent', () => { - const code = compileWorkerEntry(true); // hasQueues, no isr + const code = compileWorkerEntry({ hasQueues: true }); // hasQueues, no isr // Byte-identical to today: no deployment-id stamp anywhere. expect(code).not.toContain('__VOID_ISR_DEPLOYMENT_ID ??='); expect(code).toContain( @@ -3036,7 +3036,7 @@ describe('compileWorkerEntry — ISR wiring', () => { ' const userEnv = __filterInternalBindings(env);', ); expect(code).toContain( - 'async function queue(batch, env, ctx) {\n const userEnv = __filterInternalBindings(env);', + 'async function __voidQueue(batch, env, ctx) {\n const userEnv = __filterInternalBindings(env);', ); }); }); diff --git a/packages/void/test/unit/config.test.ts b/packages/void/test/unit/config.test.ts index 330be1494..d0bd5f7be 100644 --- a/packages/void/test/unit/config.test.ts +++ b/packages/void/test/unit/config.test.ts @@ -17,6 +17,7 @@ import { resolveBindingNames, isNodeTarget, getDatabaseDialect, + getEmailFromHost, VALID_REDIRECT_STATUSES, countSplats, } from '../../src/config.ts'; @@ -1625,3 +1626,114 @@ describe('resolveBindingNames', () => { }); }); }); + +describe('top-level email config validation', () => { + it('accepts a bare email.from address', () => { + setup(JSON.stringify({ email: { from: 'noreply@mail.acme.com' } })); + expect(readConfig(tmpDir).email?.from).toBe('noreply@mail.acme.com'); + cleanup(); + }); + + it('accepts email.from with a display name', () => { + setup(JSON.stringify({ email: { from: 'Acme ' } })); + expect(readConfig(tmpDir).email?.from).toBe('Acme '); + cleanup(); + }); + + it('accepts email.from with a quoted display name', () => { + setup(JSON.stringify({ email: { from: '"Acme, Inc." ' } })); + expect(readConfig(tmpDir).email?.from).toBe('"Acme, Inc." '); + cleanup(); + }); + + it('accepts an empty top-level email object', () => { + setup(JSON.stringify({ email: {} })); + expect(readConfig(tmpDir).email).toEqual({}); + cleanup(); + }); + + it('throws when email is not an object', () => { + setup(JSON.stringify({ email: 'noreply@mail.acme.com' })); + expect(() => readConfig(tmpDir)).toThrow('"email" in void.json must be an object'); + setup(JSON.stringify({ email: ['noreply@mail.acme.com'] })); + expect(() => readConfig(tmpDir)).toThrow('"email" in void.json must be an object'); + cleanup(); + }); + + it('throws when email.from is not a non-empty string', () => { + setup(JSON.stringify({ email: { from: 123 } })); + expect(() => readConfig(tmpDir)).toThrow( + '"email.from" in void.json must be a non-empty string', + ); + setup(JSON.stringify({ email: { from: '' } })); + expect(() => readConfig(tmpDir)).toThrow( + '"email.from" in void.json must be a non-empty string', + ); + cleanup(); + }); + + // The same shapes `sendEmail()` refuses for `from`: no `@`, two addresses, + // a list, whitespace, a smuggled header, an `@` in the display name. + it.each([ + 'noreply', + 'noreply@', + '@mail.acme.com', + 'a@mail.acme.com,b@mail.acme.com', + 'a@mail.acme.com b@mail.acme.com', + 'Acme , Sales ', + 'noreply@mail.acme.com\r\nBcc: x@evil.example', + 'a@x.dev', + '"Acme ', + ])('rejects email.from %j', (from) => { + setup(JSON.stringify({ email: { from } })); + expect(() => readConfig(tmpDir)).toThrow( + '"email.from" in void.json must be a single email address, optionally with a display name', + ); + cleanup(); + }); + + it('rejects an unknown email.* key', () => { + setup(JSON.stringify({ email: { from: 'noreply@mail.acme.com', domain: 'mail.acme.com' } })); + expect(() => readConfig(tmpDir)).toThrow("Unknown key 'email.domain'"); + cleanup(); + }); + + it('allows email.from and inference.bindings.email to coexist', () => { + setup( + JSON.stringify({ + email: { from: 'noreply@mail.acme.com' }, + inference: { bindings: { email: true } }, + }), + ); + const config = readConfig(tmpDir); + expect(config.email?.from).toBe('noreply@mail.acme.com'); + expect(config.inference?.bindings?.email).toBe(true); + cleanup(); + }); +}); + +describe('getEmailFromHost', () => { + it('returns the lowercased host of a bare address', () => { + expect(getEmailFromHost({ email: { from: 'noreply@Mail.Acme.com' } })).toBe('mail.acme.com'); + }); + + it('returns the host from behind a display name', () => { + expect(getEmailFromHost({ email: { from: 'Acme ' } })).toBe( + 'mail.acme.com', + ); + expect(getEmailFromHost({ email: { from: '"Acme, Inc." ' } })).toBe( + 'acme.com', + ); + }); + + it('returns undefined when email.from is not set', () => { + expect(getEmailFromHost({})).toBeUndefined(); + expect(getEmailFromHost({ email: {} })).toBeUndefined(); + }); + + it('throws the config error for an unparsable address', () => { + expect(() => getEmailFromHost({ email: { from: 'noreply' } })).toThrow( + '"email.from" in void.json must be a single email address', + ); + }); +}); diff --git a/packages/void/test/unit/create-project.test.ts b/packages/void/test/unit/create-project.test.ts new file mode 100644 index 000000000..1c19d1bfe --- /dev/null +++ b/packages/void/test/unit/create-project.test.ts @@ -0,0 +1,142 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathslash'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import type { PlatformClient } from '../../src/cli/client.ts'; + +const mockText = vi.fn(); +const mockInfo = vi.fn(); +const mockError = vi.fn(); + +vi.mock('@clack/prompts', () => ({ + cancel: vi.fn(), + isCancel: () => false, + log: { + error: (...args: Array) => mockError(...args), + info: (...args: Array) => mockInfo(...args), + step: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + }, + text: (...args: Array) => mockText(...args), +})); + +function createClient() { + return { + // `promptAndCreateProject` records the platform identity behind the client's + // API URL; the hosted Void Cloud URL resolves without any local connection. + apiUrl: 'https://api.void.cloud', + createProject: vi.fn(), + } as unknown as PlatformClient & { + createProject: ReturnType; + }; +} + +describe('promptAndCreateProject', () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'void-create-project-')); + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'test-app' })); + mockText.mockReset(); + mockInfo.mockReset(); + mockError.mockReset(); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + // The platform registers the owner's own address as a recipient AFTER it + // has answered, so the create response names the address and nothing else. + // The CLI must explain the verification email that may follow without + // claiming to know whether one was sent. + it('explains the owner recipient registration when the response names one', async () => { + const { promptAndCreateProject } = await import('../../src/cli/create-project.ts'); + const client = createClient(); + client.createProject.mockResolvedValueOnce({ + id: 'proj_new', + slug: 'new-app', + owner_email_destination: { address: 'owner@example.com' }, + }); + mockText.mockResolvedValueOnce('new-app'); + + const config = await promptAndCreateProject(root, client); + + expect(config).toEqual({ projectId: 'proj_new', slug: 'new-app' }); + expect(mockInfo).toHaveBeenCalledTimes(1); + const message = mockInfo.mock.calls[0]?.[0] as string; + expect(message).toContain('owner@example.com'); + // True in every outcome: neither "already verified" nor "check your + // inbox" is asserted, and the user is pointed at where the state shows. + expect(message).toContain('unless it is already verified'); + expect(message).toContain('void email destinations'); + expect(message).not.toMatch(/is already verified as a sender/); + expect(message).not.toMatch(/check your inbox/); + }); + + it('prints nothing about email when the response carries no owner recipient', async () => { + const { promptAndCreateProject } = await import('../../src/cli/create-project.ts'); + const client = createClient(); + client.createProject.mockResolvedValueOnce({ id: 'proj_new', slug: 'new-app' }); + mockText.mockResolvedValueOnce('new-app'); + + const config = await promptAndCreateProject(root, client); + + expect(config).toEqual({ projectId: 'proj_new', slug: 'new-app' }); + expect(mockInfo).not.toHaveBeenCalled(); + expect(JSON.parse(readFileSync(join(root, '.void', 'project.json'), 'utf-8'))).toEqual({ + platform: 'void', + ...config, + // The platform identity behind the client's API URL, recorded on every new link. + applicationDomain: 'void.app', + platformUrl: 'https://api.void.cloud', + proxyUrl: 'https://proxy.void.cloud', + }); + }); + + // The platform caps slugs at 56 characters (the `+noreply@` sender + // has to fit RFC 5321's 64-octet local-part) and answers 400. Like every + // other validation 400, that must re-prompt rather than abort the deploy. + it('re-prompts when the server rejects a slug over 56 characters', async () => { + const { promptAndCreateProject } = await import('../../src/cli/create-project.ts'); + const { PlatformApiError } = await import('../../src/cli/client.ts'); + const client = createClient(); + const tooLong = `len-${'a'.repeat(53)}`; + client.createProject + .mockRejectedValueOnce( + new PlatformApiError( + 'createProject', + 400, + JSON.stringify({ error: 'slug must be at most 56 characters' }), + ), + ) + .mockResolvedValueOnce({ id: 'proj_new', slug: 'shorter-app' }); + mockText.mockResolvedValueOnce(tooLong).mockResolvedValueOnce('shorter-app'); + + const config = await promptAndCreateProject(root, client); + + expect(client.createProject).toHaveBeenNthCalledWith(1, tooLong); + expect(client.createProject).toHaveBeenNthCalledWith(2, 'shorter-app'); + expect(mockText).toHaveBeenCalledTimes(2); + // The rejected slug becomes the next prompt's initial value so the user + // edits it rather than retyping it. + const secondPrompt = mockText.mock.calls[1]?.[0] as { initialValue: string } | undefined; + expect(secondPrompt?.initialValue).toBe(tooLong); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining('slug must be at most 56 characters'), + ); + expect(config).toEqual({ projectId: 'proj_new', slug: 'shorter-app' }); + }); +}); + +describe('validateProjectSlug', () => { + it('refuses a slug over 56 characters before the round trip, and accepts one at the cap', async () => { + const { MAX_SLUG_LENGTH, validateProjectSlug } = await import('../../src/cli/project-slug.ts'); + expect(MAX_SLUG_LENGTH).toBe(56); + expect(validateProjectSlug('a'.repeat(56))).toBeUndefined(); + expect(validateProjectSlug('a'.repeat(57))).toBe( + 'Slug must be at most 56 characters (it forms the email sender +noreply@).', + ); + }); +}); diff --git a/packages/void/test/unit/deploy-args.test.ts b/packages/void/test/unit/deploy-args.test.ts index 75d83019c..667c319b7 100644 --- a/packages/void/test/unit/deploy-args.test.ts +++ b/packages/void/test/unit/deploy-args.test.ts @@ -131,6 +131,29 @@ describe('parseDeployArgs', () => { ); }); + it('parses --backend cloudflare --require-email', () => { + const result = parseDeployArgs(['--backend', 'cloudflare', '--require-email']); + expect(result.backend).toBe('cloudflare'); + expect(result.requireEmail).toBe(true); + }); + + it('leaves requireEmail undefined when --require-email is not given', () => { + const result = parseDeployArgs(['--backend', 'cloudflare']); + expect(result.requireEmail).toBeUndefined(); + }); + + it('parses --platform cloudflare --require-email', () => { + const result = parseDeployArgs(['--platform', 'cloudflare', '--require-email']); + expect(result.platform).toBe('cloudflare'); + expect(result.requireEmail).toBe(true); + }); + + it('throws when --require-email is used without --platform cloudflare', () => { + expect(() => parseDeployArgs(['--require-email'])).toThrow( + 'deploy: --require-email requires --platform cloudflare.', + ); + }); + it('throws for an invalid --backend value', () => { expect(() => parseDeployArgs(['--backend', 'node'])).toThrow( "deploy: --backend must be 'cloudflare'.", diff --git a/packages/void/test/unit/deploy-email-platform-path.test.ts b/packages/void/test/unit/deploy-email-platform-path.test.ts new file mode 100644 index 000000000..f94873c15 --- /dev/null +++ b/packages/void/test/unit/deploy-email-platform-path.test.ts @@ -0,0 +1,139 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'pathslash'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { runDeploy } from '../../src/cli/deploy.ts'; + +// The Mode B email step (design doc 0078) belongs to `void deploy --platform cloudflare` ONLY. +// This drives the MANAGED path on a project that uses email in every way the step detects +// (an `email/` handler AND `void.json` `email.from`) far enough to reach the full-deploy branch, +// and proves the email module was never invoked — no preflight, no apply, no bearer, no prompt. + +const { emailModule, mockConfirm, mockPreflight, mockListProjects, mockClientCtor } = vi.hoisted( + () => { + const never = (): never => { + throw new Error('the email module must never run on the managed platform path'); + }; + return { + emailModule: { + preflightEmail: vi.fn(never), + applyEmailSetup: vi.fn(never), + renderEmailChecklist: vi.fn(never), + renderAddressMap: vi.fn(never), + deriveAddresses: vi.fn(never), + fetchWithSessionBearer: vi.fn(never), + createNodeEmailDns: vi.fn(never), + }, + mockConfirm: vi.fn(), + mockPreflight: vi.fn(), + mockListProjects: vi.fn(), + mockClientCtor: vi.fn(), + }; + }, +); + +vi.mock('../../src/cli/email-cloudflare.ts', () => emailModule); + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + note: vi.fn(), + spinner: () => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() }), + isCI: () => false, + confirm: (...args: Array) => mockConfirm(...args), + isCancel: () => false, + log: { + error: vi.fn(), + info: vi.fn(), + step: vi.fn(), + warn: vi.fn(), + success: vi.fn(), + message: vi.fn(), + }, +})); + +vi.mock('../../src/cli/auth', () => ({ + getToken: () => 'token', + getTokenSource: () => 'manual', + getApiUrl: () => 'https://api.test', +})); + +vi.mock('../../src/cli/github-oidc', () => ({ + isGithubActionsOidcAvailable: () => false, + exchangeGithubOidcForDeployToken: vi.fn(), + maskGithubActionsSecret: vi.fn(), +})); + +vi.mock('../../src/cli/client', () => ({ + PlatformClient: class { + constructor(...args: Array) { + mockClientCtor(...args); + } + listProjects(...args: Array) { + return mockListProjects(...args); + } + preflight(...args: Array) { + return mockPreflight(...args); + } + }, + isExpiredTokenError: () => false, +})); + +const tmpDir = join(import.meta.dirname, '.tmp-deploy-email-platform-path'); + +function makeProject(files: Record): string { + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + for (const [rel, content] of Object.entries(files)) { + const abs = join(tmpDir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + return tmpDir; +} + +beforeEach(() => { + for (const spy of Object.values(emailModule)) { + spy.mockClear(); + } + mockConfirm.mockReset(); + mockClientCtor.mockReset(); + mockListProjects.mockReset(); + mockPreflight.mockReset(); + mockListProjects.mockResolvedValue([{ id: 'proj_1', slug: 'my-app', name: 'my-app' }]); + // The first UPLOAD call inside the full-deploy branch — a distinctive stop so the test proves + // how far the managed path ran without a real build or network. + mockPreflight.mockRejectedValue(new Error('stop-here: reached the managed upload')); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('managed `void deploy` never runs the Mode B email step', () => { + it('reaches the full-deploy branch on an email-using project without touching the email module', async () => { + // `dist/ssr` stands in for a built worker so `skipBuild` gets past the output check without + // running Vite; the path then runs config, inference (which sees the `void/email` import), + // the migration gates, and asset collection before its first upload call, `preflight`. + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({ email: { from: 'noreply@mail.acme.com' } }), + 'routes/index.ts': `import { sendEmail } from 'void/email';\nexport const GET = () => sendEmail({});`, + 'email/support+[ticket].ts': `export default async () => {};`, + 'wrangler.jsonc': JSON.stringify({ name: 'myapp', account_id: 'acc123' }), + 'dist/ssr/index.js': 'export default {};', + 'dist/client/index.html': '', + }); + await expect(runDeploy(root, { projectSlug: 'my-app', skipBuild: true })).rejects.toThrow( + /stop-here: reached the managed upload/, + ); + // The managed path DID run: it built a platform client and resolved the project. + expect(mockClientCtor).toHaveBeenCalledTimes(1); + expect(mockListProjects).toHaveBeenCalledTimes(1); + expect(mockPreflight).toHaveBeenCalledTimes(1); + // …and never asked the email module — or the developer — anything. + for (const [name, spy] of Object.entries(emailModule)) { + expect(spy, name).not.toHaveBeenCalled(); + } + expect(mockConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/void/test/unit/deploy-static-only.test.ts b/packages/void/test/unit/deploy-static-only.test.ts new file mode 100644 index 000000000..b5050bb80 --- /dev/null +++ b/packages/void/test/unit/deploy-static-only.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { hasNonPageBackend } from '../../src/cli/deploy.ts'; + +const NONE = { + hasRoutes: false, + hasMiddleware: false, + hasWebSockets: false, + hasEmail: false, + authEnabled: false, + hasLive: false, +} as const; + +describe('hasNonPageBackend', () => { + it('is false when pages are the only surface', () => { + expect(hasNonPageBackend({ ...NONE })).toBe(false); + }); + + it.each([ + ['hasRoutes'], + ['hasMiddleware'], + ['hasWebSockets'], + ['hasEmail'], + ['authEnabled'], + ['hasLive'], + ] as const)('is true when %s is the only surface', (key) => { + expect(hasNonPageBackend({ ...NONE, [key]: true })).toBe(true); + }); + + // An `email/` handler compiles to the worker's `email()` export. The + // static-site path uploads no worker at all, so taking it would drop every + // inbound handler and bounce the mail with no error at deploy time. + it('keeps an email-only project on the worker path', () => { + expect(hasNonPageBackend({ ...NONE, hasEmail: true })).toBe(true); + }); +}); diff --git a/packages/void/test/unit/email-args.test.ts b/packages/void/test/unit/email-args.test.ts new file mode 100644 index 000000000..68a275b5e --- /dev/null +++ b/packages/void/test/unit/email-args.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { parseEmailArgs } from '../../src/cli/email-args.ts'; + +describe('parseEmailArgs', () => { + it('parses usage', () => { + expect(parseEmailArgs(['usage'])).toEqual({ + subcommand: 'usage', + projectSlug: undefined, + limit: undefined, + address: undefined, + }); + }); + + it('parses logs with --limit', () => { + expect(parseEmailArgs(['logs', '--limit', '50'])).toEqual({ + subcommand: 'logs', + projectSlug: undefined, + limit: 50, + address: undefined, + }); + }); + + it('parses destinations with --project', () => { + expect(parseEmailArgs(['destinations', '--project', 'my-app'])).toEqual({ + subcommand: 'destinations', + projectSlug: 'my-app', + limit: undefined, + address: undefined, + }); + }); + + it('parses allow with address and --project', () => { + expect(parseEmailArgs(['allow', 'user@example.com', '--project', 'my-app'])).toEqual({ + subcommand: 'allow', + projectSlug: 'my-app', + limit: undefined, + address: 'user@example.com', + }); + }); + + it('parses disallow with address', () => { + expect(parseEmailArgs(['disallow', 'user@example.com'])).toEqual({ + subcommand: 'disallow', + projectSlug: undefined, + limit: undefined, + address: 'user@example.com', + }); + }); + + it('errors on missing subcommand', () => { + expect(() => parseEmailArgs([])).toThrow(/email: Usage:/); + }); + + it('errors on unknown subcommand', () => { + expect(() => parseEmailArgs(['nope'])).toThrow(/email: Usage:/); + }); + + it('errors when allow is missing the address', () => { + expect(() => parseEmailArgs(['allow'])).toThrow(/void email allow
/); + }); + + it('errors when disallow is missing the address', () => { + expect(() => parseEmailArgs(['disallow'])).toThrow(/void email disallow
/); + }); + + it('errors on non-numeric --limit', () => { + expect(() => parseEmailArgs(['logs', '--limit', 'abc'])).toThrow( + /--limit must be a positive integer/, + ); + }); + + it('errors on zero --limit', () => { + expect(() => parseEmailArgs(['logs', '--limit', '0'])).toThrow( + /--limit must be a positive integer/, + ); + }); + + it('errors when allow receives more than one positional argument', () => { + // Previously the second address was silently dropped — confusing UX + // for users who typed `void email allow a@x.dev b@y.dev` and didn't + // realize only the first took effect. + expect(() => parseEmailArgs(['allow', 'a@x.dev', 'b@y.dev'])).toThrow( + /takes exactly one positional argument/, + ); + }); + + it('errors when disallow receives more than one positional argument', () => { + expect(() => parseEmailArgs(['disallow', 'a@x.dev', 'b@y.dev'])).toThrow( + /takes exactly one positional argument/, + ); + }); + + // Mode B (design doc 0078): `status` and `setup` take `--platform cloudflare` and + // read the local project, never a Void project. + it('parses status and setup with --platform cloudflare (space and = forms)', () => { + expect(parseEmailArgs(['status', '--platform', 'cloudflare'])).toEqual({ + subcommand: 'status', + projectSlug: undefined, + limit: undefined, + address: undefined, + platform: 'cloudflare', + }); + expect(parseEmailArgs(['setup', '--platform=cloudflare'])).toEqual({ + subcommand: 'setup', + projectSlug: undefined, + limit: undefined, + address: undefined, + platform: 'cloudflare', + }); + }); + + // The legacy spelling `void deploy` also keeps: same field, same value. + it('accepts --backend cloudflare as an alias of --platform cloudflare (space and = forms)', () => { + expect(parseEmailArgs(['status', '--backend', 'cloudflare'])).toEqual( + parseEmailArgs(['status', '--platform', 'cloudflare']), + ); + expect(parseEmailArgs(['setup', '--backend=cloudflare'])).toMatchObject({ + subcommand: 'setup', + platform: 'cloudflare', + }); + }); + + it('parses --platform void as the platform shape (the "not available yet" exit for setup/status)', () => { + expect(parseEmailArgs(['setup', '--platform', 'void'])).toEqual({ + subcommand: 'setup', + projectSlug: undefined, + limit: undefined, + address: undefined, + platform: 'void', + }); + expect(parseEmailArgs(['status', '--platform=void']).platform).toBe('void'); + // The platform-only subcommands are the platform already, so naming it is a no-op + // there — unlike `cloudflare`, which they reject. + expect(parseEmailArgs(['usage', '--platform', 'void', '--project', 'my-app'])).toMatchObject({ + subcommand: 'usage', + projectSlug: 'my-app', + platform: 'void', + }); + }); + + it('parses status and setup without a platform (the platform shape)', () => { + expect(parseEmailArgs(['status'])).toMatchObject({ subcommand: 'status' }); + expect(parseEmailArgs(['status']).platform).toBeUndefined(); + expect(parseEmailArgs(['setup', '--project', 'my-app'])).toMatchObject({ + subcommand: 'setup', + projectSlug: 'my-app', + }); + }); + + it('errors on --platform without a value or with another value', () => { + expect(() => parseEmailArgs(['setup', '--platform'])).toThrow( + "email: --platform requires a value ('cloudflare' or 'void').", + ); + expect(() => parseEmailArgs(['setup', '--platform', '--limit', '3'])).toThrow( + /--platform requires a value/, + ); + expect(() => parseEmailArgs(['setup', '--platform=', 'cloudflare'])).toThrow( + "email: --platform must be 'cloudflare' or 'void'.", + ); + expect(() => parseEmailArgs(['setup', '--platform', 'vercel'])).toThrow( + "email: --platform must be 'cloudflare' or 'void'.", + ); + }); + + // `--project` and `--limit` take the same missing-value path as `--platform`. A bare + // flag used to fall through as a positional, which the read-only subcommands never + // reject: `void email usage --project` silently ran against the linked project and + // `void email logs --limit` silently ignored the limit. + it('errors on --project without a value', () => { + expect(() => parseEmailArgs(['usage', '--project'])).toThrow( + 'email: --project requires a value (a project name).', + ); + // A value that starts with `-` is the next flag, not a project name — previously + // `--limit` became the slug and `5` the positional. + expect(() => parseEmailArgs(['usage', '--project', '--limit', '5'])).toThrow( + 'email: --project requires a value (a project name).', + ); + expect(() => parseEmailArgs(['destinations', '--project'])).toThrow( + /--project requires a value/, + ); + }); + + // An empty slug is not nullish: `runEmailCommand` used to keep `''` over `VOID_PROJECT` + // and then fall through to the linked project, so `--project=` (or `--project "$VAR"` + // with the variable unset) silently mutated the linked project's allowlist. + it('errors on --project with an empty or whitespace-only value, same message as a bare flag', () => { + const message = 'email: --project requires a value (a project name).'; + expect(() => parseEmailArgs(['disallow', 'a@x.dev', '--project='])).toThrow(message); + expect(() => parseEmailArgs(['disallow', 'a@x.dev', '--project', ''])).toThrow(message); + expect(() => parseEmailArgs(['disallow', 'a@x.dev', '--project', ' '])).toThrow(message); + expect(() => parseEmailArgs(['allow', 'a@x.dev', '--project= '])).toThrow(message); + }); + + // Same rule as `void deploy` and this file's `--platform`: the last value never wins, + // so a wrapper's override and a caller's cannot silently disagree about the target. + it('errors when --project is repeated, even if the final value is valid', () => { + const message = 'email: --project can only be specified once.'; + expect(() => + parseEmailArgs(['disallow', 'a@x.dev', '--project', 'a', '--project', 'b']), + ).toThrow(message); + expect(() => parseEmailArgs(['allow', 'a@x.dev', '--project=a', '--project=a'])).toThrow( + message, + ); + expect(() => parseEmailArgs(['destinations', '--project', 'a', '--project=b'])).toThrow( + message, + ); + }); + + // The parser is shared, so the read-only subcommands reject the same shapes — they are + // exactly the ones that never validate a positional and used to run against the wrong + // project without a word. + it('rejects an empty or repeated --project on the read-only subcommands too', () => { + for (const sub of ['usage', 'logs']) { + expect(() => parseEmailArgs([sub, '--project='])).toThrow( + 'email: --project requires a value (a project name).', + ); + expect(() => parseEmailArgs([sub, '--project', 'a', '--project', 'b'])).toThrow( + 'email: --project can only be specified once.', + ); + } + // A single, non-empty value is still accepted as before. + expect(parseEmailArgs(['usage', '--project', 'x'])).toMatchObject({ + subcommand: 'usage', + projectSlug: 'x', + }); + expect(parseEmailArgs(['logs', '--limit', '3', '--project=x'])).toMatchObject({ + subcommand: 'logs', + projectSlug: 'x', + limit: 3, + }); + }); + + it('errors on --limit without a value', () => { + expect(() => parseEmailArgs(['logs', '--limit'])).toThrow( + 'email: --limit requires a value (a positive integer).', + ); + expect(() => parseEmailArgs(['logs', '--limit', '--project', 'my-app'])).toThrow( + 'email: --limit requires a value (a positive integer).', + ); + // The value check still applies once a value is present. + expect(() => parseEmailArgs(['logs', '--limit=abc'])).toThrow( + /--limit must be a positive integer \(got 'abc'\)/, + ); + }); + + it('parses --project= and --limit= (the = form --platform already takes)', () => { + expect(parseEmailArgs(['usage', '--project=foo'])).toEqual({ + subcommand: 'usage', + projectSlug: 'foo', + limit: undefined, + address: undefined, + platform: undefined, + }); + expect(parseEmailArgs(['logs', '--limit=5'])).toEqual({ + subcommand: 'logs', + projectSlug: undefined, + limit: 5, + address: undefined, + platform: undefined, + }); + }); + + it('still carries an unrelated stray positional on a read-only subcommand', () => { + // Positional validation is unchanged: only allow/disallow require one, and only a + // second one is rejected. A stray word is still parsed as `address` and left to the + // command, exactly as before. + expect(parseEmailArgs(['usage', 'stray'])).toMatchObject({ + subcommand: 'usage', + address: 'stray', + projectSlug: undefined, + }); + }); + + it('errors on --backend without a value or with another value', () => { + expect(() => parseEmailArgs(['setup', '--backend'])).toThrow( + /--backend requires a value \('cloudflare'\)/, + ); + expect(() => parseEmailArgs(['setup', '--backend', '--limit', '3'])).toThrow( + /--backend requires a value/, + ); + expect(() => parseEmailArgs(['setup', '--backend', 'vercel'])).toThrow( + /--backend must be 'cloudflare'/, + ); + // `void` is a --platform value, not a --backend one: the alias only ever spelled cloudflare. + expect(() => parseEmailArgs(['setup', '--backend', 'void'])).toThrow( + /--backend must be 'cloudflare'/, + ); + }); + + // Same rules as `void deploy`: each flag at most once, and never both together. + it('errors when --platform and --backend are used together', () => { + expect(() => + parseEmailArgs(['setup', '--platform', 'cloudflare', '--backend', 'cloudflare']), + ).toThrow('email: --platform and --backend cannot be used together.'); + expect(() => parseEmailArgs(['status', '--backend=cloudflare', '--platform=void'])).toThrow( + 'email: --platform and --backend cannot be used together.', + ); + }); + + it('errors when --platform or --backend is repeated, even if the final value is valid', () => { + expect(() => + parseEmailArgs(['setup', '--platform', 'cloudflare', '--platform', 'cloudflare']), + ).toThrow('email: --platform can only be specified once.'); + expect(() => parseEmailArgs(['setup', '--platform', 'other', '--platform', 'void'])).toThrow( + 'email: --platform can only be specified once.', + ); + expect(() => + parseEmailArgs(['setup', '--backend', 'cloudflare', '--backend', 'cloudflare']), + ).toThrow('email: --backend can only be specified once.'); + expect(() => parseEmailArgs(['setup', '--backend', 'node', '--backend=cloudflare'])).toThrow( + 'email: --backend can only be specified once.', + ); + }); + + it('rejects --platform cloudflare (and the alias) on the platform-only subcommands', () => { + for (const sub of ['usage', 'logs', 'destinations']) { + expect(() => parseEmailArgs([sub, '--platform', 'cloudflare'])).toThrow( + /--platform cloudflare applies to `void email status` and `void email setup` only/, + ); + expect(() => parseEmailArgs([sub, '--backend', 'cloudflare'])).toThrow( + /applies to `void email status` and `void email setup` only/, + ); + } + expect(() => parseEmailArgs(['allow', 'a@x.dev', '--platform', 'cloudflare'])).toThrow( + /applies to `void email status` and `void email setup` only/, + ); + }); + + it('rejects --project and a positional argument with --platform cloudflare', () => { + expect(() => parseEmailArgs(['setup', '--platform', 'cloudflare', '--project', 'x'])).toThrow( + /--project cannot be combined with --platform cloudflare/, + ); + expect(() => parseEmailArgs(['setup', '--backend', 'cloudflare', '--project', 'x'])).toThrow( + /--project cannot be combined with --platform cloudflare/, + ); + expect(() => parseEmailArgs(['status', 'acme.com', '--platform', 'cloudflare'])).toThrow( + /`void email status --platform cloudflare` takes no argument/, + ); + }); +}); diff --git a/packages/void/test/unit/email-cli-allow.test.ts b/packages/void/test/unit/email-cli-allow.test.ts new file mode 100644 index 000000000..32db23640 --- /dev/null +++ b/packages/void/test/unit/email-cli-allow.test.ts @@ -0,0 +1,127 @@ +import { log } from '@clack/prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import type { PlatformClient } from '../../src/cli/client.ts'; +import { emailAllow } from '../../src/cli/email.ts'; + +/** + * What `void email allow
` prints for each shape the api answers + * with. The one contract under test: "Verification email sent" is printed + * ONLY when a link actually went out. Cloudflare mails its link on create + * only, and a 201 does not prove a create — the api adopts an address + * Cloudflare already held pending (the owner's own, from another project or + * a hand add), and answers that with `verificationSent: false`. + */ + +type AllowResponse = Awaited> & { + verificationSent?: boolean; +}; + +const ADDRESS = 'owner@example.com'; + +function response(overrides: Partial): AllowResponse { + return { + id: 'edst_1', + address: ADDRESS, + status: 'pending', + verified_at: null, + created_at: '2026-09-14T00:00:00Z', + created: false, + ...overrides, + }; +} + +function client(body: AllowResponse): Pick { + return { addEmailDestination: vi.fn(async () => body) }; +} + +type LogSpy = ReturnType; + +let successSpy: LogSpy; +let infoSpy: LogSpy; +let warnSpy: LogSpy; + +beforeEach(() => { + successSpy = vi.spyOn(log, 'success').mockImplementation(() => {}); + infoSpy = vi.spyOn(log, 'info').mockImplementation(() => {}); + warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function printed(spy: LogSpy): string { + return (spy.mock.calls as Array>).map((call) => String(call[0])).join('\n'); +} + +describe('void email allow', () => { + it('reports a send when Cloudflare registered the address just now (201)', async () => { + await emailAllow(client(response({ created: true })), 'prj_1', ADDRESS); + + expect(printed(successSpy)).toBe(`Verification email sent to ${ADDRESS}.`); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('reports a send when the platform mailed its own link (first add and repeat)', async () => { + await emailAllow( + client(response({ created: true, platformVerificationPending: true })), + 'prj_1', + ADDRESS, + ); + await emailAllow( + client(response({ created: false, platformVerificationPending: true })), + 'prj_1', + ADDRESS, + ); + + expect(successSpy).toHaveBeenCalledTimes(2); + expect(printed(successSpy)).not.toContain('already verified'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does NOT claim a send when Cloudflare already held the address pending', async () => { + // The api created the project's row (201) but Cloudflare refused the + // create: the only link the owner has is the one from the first add. + await emailAllow( + client(response({ created: true, verificationSent: false })), + 'prj_1', + ADDRESS, + ); + + expect(successSpy).not.toHaveBeenCalled(); + const text = printed(warnSpy); + expect(text).toContain('no new verification email was sent'); + expect(text).toContain(`void email disallow ${ADDRESS}`); + expect(text).toContain('every project that lists it'); + }); + + it('does NOT claim a send on a repeat add of a Cloudflare-pending address', async () => { + // With and without the marker: an api that predates `verificationSent` + // answers the repeat with a bare 200, and that already means "nothing sent". + await emailAllow(client(response({ created: false })), 'prj_1', ADDRESS); + await emailAllow( + client(response({ created: false, verificationSent: false })), + 'prj_1', + ADDRESS, + ); + + expect(successSpy).not.toHaveBeenCalled(); + expect(infoSpy).toHaveBeenCalledTimes(2); + for (const call of infoSpy.mock.calls as Array>) { + expect(String(call[0])).toContain('a repeat add sends nothing new'); + expect(String(call[0])).toContain(`void email disallow ${ADDRESS}`); + } + }); + + it('reports an already-verified address as such', async () => { + await emailAllow( + client(response({ status: 'verified', verified_at: '2026-09-01T00:00:00Z' })), + 'prj_1', + ADDRESS, + ); + + expect(printed(successSpy)).toBe(`${ADDRESS} is already verified.`); + expect(infoSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/void/test/unit/email-cli-cloudflare.test.ts b/packages/void/test/unit/email-cli-cloudflare.test.ts new file mode 100644 index 000000000..a98fea36f --- /dev/null +++ b/packages/void/test/unit/email-cli-cloudflare.test.ts @@ -0,0 +1,1033 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { log } from '@clack/prompts'; +import { join } from 'pathslash'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + SENDING_FREE_PLAN_HINT, + WRANGLER_EMAIL_PROSE_VERSION, + type EmailDns, + type EmailWranglerRunResult, +} from '../../src/cli/email-cloudflare.ts'; +import { runEmailCloudflareCommand, runEmailCommand } from '../../src/cli/email.ts'; + +/** + * `void email setup|status --platform cloudflare` on the 0078 transcript project: + * `email/support+[ticket].ts` + `email/_default.ts`, `email.from = noreply@mail.acme.com`, + * zone `acme.com` (routing ready on the apex, Google MX there), nothing on `mail.acme.com`. + * Every Cloudflare touch is a fake; the wrangler prose comes from the pinned fixtures. + */ + +const FIXTURES = join( + import.meta.dirname, + '..', + 'fixtures', + 'wrangler-email', + WRANGLER_EMAIL_PROSE_VERSION, +); + +function fixture(name: string): EmailWranglerRunResult { + return { + status: Number(readFileSync(join(FIXTURES, `${name}.exit`), 'utf-8').trim()), + stdout: readFileSync(join(FIXTURES, `${name}.stdout`), 'utf-8'), + stderr: readFileSync(join(FIXTURES, `${name}.stderr`), 'utf-8'), + }; +} + +const ZONE_ID = '5b66f5cb84fd7dcfdd0170515e622181'; +const ACCOUNT = 'f721b8e5f721b8e5f721b8e5f721b8e5'; +const BEARER = 'cfoa-SECRET-BEARER-TOKEN-0123456789'; +const BETA_WARNING = '▲ [WARNING] 🚧 `wrangler email x` is an open beta command.\n'; + +function routingSettings(name: string, status: string, enabled = true): EmailWranglerRunResult { + return { + status: 0, + stdout: `Email Routing for ${name}:\n Enabled: ${enabled}\n Status: ${status}\n Tag: ${ZONE_ID}\n`, + stderr: BETA_WARNING, + }; +} + +function zoneNotFound(name: string): EmailWranglerRunResult { + return { + status: 1, + stdout: '', + stderr: `${BETA_WARNING}✘ [ERROR] Could not find zone for \`${name}\`. Make sure the domain exists in your account.\n`, + }; +} + +function sendingNotFound(name: string): EmailWranglerRunResult { + return { + status: 1, + stdout: '', + stderr: `${BETA_WARNING}✘ [ERROR] No sending subdomain found for \`${name}\`.\n`, + }; +} + +function sendingEnabled(name: string): EmailWranglerRunResult { + return { status: 0, stdout: `Email Sending enabled for ${name} (enabled: true)\n`, stderr: '' }; +} + +type Call = { args: Array; capture: boolean }; +type FetchCall = { url: string; method: string; headers: Record; body?: string }; + +function fakeRunner(table: Record, calls: Array) { + return (args: Array, opts: { capture: boolean }): EmailWranglerRunResult => { + calls.push({ args: [...args], capture: opts.capture }); + // The command appends `-c --env=` to every call; key the table on the + // command words and assert the suffix separately. + const key = args.slice(0, args.indexOf('-c') < 0 ? args.length : args.indexOf('-c')).join(' '); + const hit = table[key]; + if (!hit) { + throw new Error(`unexpected wrangler call: ${args.join(' ')}`); + } + return hit; + }; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function fakeFetch(table: Record Response>, calls: Array): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const headers: Record = {}; + for (const [k, v] of Object.entries((init?.headers ?? {}) as Record)) { + headers[k] = v; + } + const call: FetchCall = { + url, + method: init?.method ?? 'GET', + headers, + body: typeof init?.body === 'string' ? init.body : undefined, + }; + calls.push(call); + const key = `${call.method} ${url.slice(url.indexOf('/zones'))}`; + const hit = table[key]; + if (!hit) { + throw new Error(`unexpected fetch: ${key}`); + } + return hit(); + }) as typeof fetch; +} + +function fakeDns(mx: Record>): EmailDns { + const noData = (): Error => Object.assign(new Error('ENODATA'), { code: 'ENODATA' }); + return { + resolveMx: async (name) => { + const list = mx[name]; + if (!list) { + throw noData(); + } + return list.map((exchange, i) => ({ exchange, priority: (i + 1) * 10 })); + }, + resolveTxt: async () => { + throw noData(); + }, + }; +} + +/** Capture clack's `log.*` lines — the command's final verdict is one of those, not a `print` block. */ +function captureLog(): { lines: Array; restore: () => void } { + const lines: Array = []; + const spies = (['message', 'info', 'warn', 'error', 'success', 'step'] as const).map((level) => + vi.spyOn(log, level).mockImplementation(((...args: Array) => { + lines.push(args.map(String).join(' ')); + }) as never), + ); + return { + lines, + restore: () => { + for (const spy of spies) { + spy.mockRestore(); + } + }, + }; +} + +class ExitSignal extends Error { + constructor(readonly code: number) { + super(`exit ${code}`); + } +} + +/** Run the command; resolve the exit code (0 when it returned without exiting). */ +async function run( + root: string, + subcommand: 'setup' | 'status', + deps: Parameters[2], +): Promise { + try { + await runEmailCloudflareCommand( + root, + { subcommand, platform: 'cloudflare' }, + { + ...deps, + exit: (code: number): never => { + throw new ExitSignal(code); + }, + }, + ); + return 0; + } catch (err) { + if (err instanceof ExitSignal) { + return err.code; + } + throw err; + } +} + +const INITIAL_WRANGLER = `{ + // the pinned account + "account_id": "${ACCOUNT}", + "name": "acme-support", + "compatibility_date": "2026-01-01", +} +`; + +function makeProject(overrides: { from?: string; wrangler?: string } = {}): string { + const root = mkdtempSync(join(tmpdir(), 'void-email-cli-')); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ email: { from: overrides.from ?? 'noreply@mail.acme.com' } }), + ); + writeFileSync(join(root, 'wrangler.jsonc'), overrides.wrangler ?? INITIAL_WRANGLER); + mkdirSync(join(root, 'email')); + writeFileSync(join(root, 'email', 'support+[ticket].ts'), 'export default () => {};\n'); + writeFileSync(join(root, 'email', '_default.ts'), 'export default () => {};\n'); + return root; +} + +/** The transcript project's fakes, with the apply-side entries overridable. */ +function transcriptDeps( + extra: { + runnerTable?: Record; + fetchTable?: Record Response>; + mx?: Record>; + } = {}, +) { + const calls: Array = []; + const fetchCalls: Array = []; + const printed: Array = []; + const runnerTable: Record = { + 'whoami --json': fixture('whoami'), + 'email routing settings mail.acme.com': zoneNotFound('mail.acme.com'), + 'email routing settings acme.com': routingSettings('acme.com', 'ready'), + 'email routing rules list acme.com': fixture('routing-rules-list-void.cloud'), + 'email sending settings mail.acme.com': sendingNotFound('mail.acme.com'), + ...extra.runnerTable, + }; + const fetchTable: Record Response> = { + 'GET /zones?name=acme.com': () => + jsonResponse(200, { + success: true, + result: [{ id: ZONE_ID, name: 'acme.com', type: 'full', account: { id: ACCOUNT } }], + }), + [`GET /zones/${ZONE_ID}/email/routing`]: () => + jsonResponse(200, { success: true, result: { enabled: true, support_subaddress: false } }), + ...extra.fetchTable, + }; + const deps = { + runWrangler: fakeRunner(runnerTable, calls), + getSessionBearer: async () => BEARER, + fetchImpl: fakeFetch(fetchTable, fetchCalls), + dns: fakeDns(extra.mx ?? { 'acme.com': ['aspmx.l.google.com'] }), + print: (block: string) => { + printed.push(block); + }, + }; + return { deps, calls, fetchCalls, printed }; +} + +function readJsonc(path: string): Record { + // The writer preserves the comment; strip it for the structural assertion. + const text = readFileSync(path, 'utf-8') + .split('\n') + .filter((line) => !line.trim().startsWith('//')) + .join('\n') + .replace(/,(\s*[}\]])/g, '$1'); + return JSON.parse(text) as Record; +} + +const roots: Array = []; +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +/** + * Workers Free AFTER a `setup` that was refused Email Sending: the binding, `addresses` + * and the sender var are committed, routing + subaddressing are on, the rule exists, and + * `sending settings` still answers not-found — the same answer a Paid account that never + * onboarded gives. The committed binding is the memory of the refusal (`preflightEmail`), + * so every row reads ready; the onboarding is the one retry `setup` offers on its own + * prompt, and the state `status` must call set up. + */ +function freeAfterSetup(extra: { runnerTable?: Record } = {}) { + const root = makeProject({ + wrangler: `{ + "account_id": "${ACCOUNT}", + "name": "acme-support", + "send_email": [{ "name": "SEND_EMAIL" }], + "addresses": ["support@mail.acme.com"], + "vars": { "__VOID_EMAIL_FROM": "noreply@mail.acme.com" }, +} +`, + }); + roots.push(root); + const fakes = transcriptDeps({ + runnerTable: { + 'email routing rules list acme.com': { + status: 0, + stdout: + 'Rule: 1a2b3c\n Name: support\n Enabled: true\n Matchers: to:support@mail.acme.com\n Actions: worker:acme-support\n Priority: 0\n\nCatch-all rule: disabled, action: drop\n', + stderr: BETA_WARNING, + }, + ...extra.runnerTable, + }, + fetchTable: { + [`GET /zones/${ZONE_ID}/email/routing`]: () => + jsonResponse(200, { success: true, result: { enabled: true, support_subaddress: true } }), + }, + mx: { 'acme.com': ['aspmx.l.google.com'], 'mail.acme.com': ['route1.mx.cloudflare.net'] }, + }); + return { root, configPath: join(root, 'wrangler.jsonc'), ...fakes }; +} +const RETRY_PROMPT = + 'Onboard mail.acme.com for Email Sending? Inbound already works; onboarding needs Workers Paid.'; +const sendingRefused: EmailWranglerRunResult = { + status: 1, + stdout: '', + stderr: `${BETA_WARNING}✘ [ERROR] A request to the Cloudflare API (/zones/${ZONE_ID}/email/sending/subdomains) failed.\n\n something went wrong [code: 10105]\n`, +}; + +describe('void email setup --platform cloudflare', () => { + it('first run: checklist, one confirm, exact argv + REST calls, wrangler.jsonc written', async () => { + const root = makeProject(); + roots.push(root); + const configPath = join(root, 'wrangler.jsonc'); + const { deps, calls, fetchCalls, printed } = transcriptDeps({ + runnerTable: { 'email sending enable mail.acme.com': sendingEnabled('mail.acme.com') }, + fetchTable: { + [`POST /zones/${ZONE_ID}/email/routing/dns`]: () => + jsonResponse(200, { success: true, result: { name: 'mail.acme.com' } }), + [`PATCH /zones/${ZONE_ID}/email/routing`]: () => + jsonResponse(200, { success: true, result: { support_subaddress: true } }), + }, + }); + const confirms: Array = []; + + const code = await run(root, 'setup', { + ...deps, + isInteractive: true, + confirm: async (message) => { + confirms.push(message); + return true; + }, + }); + + expect(code).toBe(0); + expect(confirms).toEqual(['Set up email on mail.acme.com?']); + + // Every wrangler child carries the deploy's config pin; the writes come after the reads. + expect(calls.map((c) => c.args)).toEqual([ + ['whoami', '--json', '-c', configPath, '--env='], + ['email', 'routing', 'settings', 'mail.acme.com', '-c', configPath, '--env='], + ['email', 'routing', 'settings', 'acme.com', '-c', configPath, '--env='], + ['email', 'routing', 'rules', 'list', 'acme.com', '-c', configPath, '--env='], + ['email', 'sending', 'settings', 'mail.acme.com', '-c', configPath, '--env='], + ['email', 'sending', 'enable', 'mail.acme.com', '-c', configPath, '--env='], + ]); + expect(calls.every((c) => c.capture)).toBe(true); + expect(fetchCalls.map((c) => `${c.method} ${c.url.slice(c.url.indexOf('/zones'))}`)).toEqual([ + 'GET /zones?name=acme.com', + `GET /zones/${ZONE_ID}/email/routing`, + `POST /zones/${ZONE_ID}/email/routing/dns`, + `PATCH /zones/${ZONE_ID}/email/routing`, + ]); + for (const call of fetchCalls) { + expect(call.headers.Authorization).toBe(`Bearer ${BEARER}`); + expect(call.url).not.toContain(BEARER); + } + + // The root config: binding + addresses + sender var, comment and siblings intact. + const written = readFileSync(configPath, 'utf-8'); + expect(written).toContain('// the pinned account'); + expect(readJsonc(configPath)).toEqual({ + account_id: ACCOUNT, + name: 'acme-support', + compatibility_date: '2026-01-01', + send_email: [{ name: 'SEND_EMAIL' }], + addresses: ['support@mail.acme.com'], + vars: { __VOID_EMAIL_FROM: 'noreply@mail.acme.com' }, + }); + + // The checklist came before the prompt; the ✔ rows and the address map after. + const output = printed.join('\n'); + expect(printed[0]).toContain('Email in use email/support+[ticket].ts · email/_default.ts'); + expect(printed[0]).toContain('+ enable Email Routing on mail.acme.com'); + expect(printed[0]).toContain('! email/_default.ts'); + expect(output).toContain('✔ routing mail.acme.com enabled'); + expect(output).toContain('✔ sending mail.acme.com onboarded'); + expect(output).toContain('✔ config wrangler.jsonc updated'); + expect(printed.at(-1)).toBe( + [ + ' inbound support@mail.acme.com → email/support+[ticket].ts', + ' outbound sendEmail() from noreply@mail.acme.com', + ].join('\n'), + ); + for (const block of printed) { + expect(block).not.toContain(BEARER); + } + }); + + it('declined: nothing is written or called after the checklist', async () => { + const root = makeProject(); + roots.push(root); + const before = readFileSync(join(root, 'wrangler.jsonc'), 'utf-8'); + const { deps, calls, fetchCalls } = transcriptDeps(); + + const code = await run(root, 'setup', { + ...deps, + isInteractive: true, + confirm: async () => false, + }); + + expect(code).toBe(0); + expect(readFileSync(join(root, 'wrangler.jsonc'), 'utf-8')).toBe(before); + expect(calls.map((c) => c.args[0])).toEqual(['whoami', 'email', 'email', 'email', 'email']); + expect(calls.some((c) => c.args.includes('enable'))).toBe(false); + expect(fetchCalls.every((c) => c.method === 'GET')).toBe(true); + }); + + it('non-interactive and not ready: checklist, exit 1, no writes (CI cannot press Enter)', async () => { + const root = makeProject(); + roots.push(root); + const before = readFileSync(join(root, 'wrangler.jsonc'), 'utf-8'); + const { deps, calls, fetchCalls, printed } = transcriptDeps(); + const confirm = vi.fn(async () => true); + + const code = await run(root, 'setup', { ...deps, isInteractive: false, confirm }); + + expect(code).toBe(1); + expect(confirm).not.toHaveBeenCalled(); + expect(printed[0]).toContain( + 'This changes YOUR Cloudflare account. acme.com itself is not touched.', + ); + expect(readFileSync(join(root, 'wrangler.jsonc'), 'utf-8')).toBe(before); + expect(calls.some((c) => c.args.includes('enable'))).toBe(false); + expect(fetchCalls.every((c) => c.method === 'GET')).toBe(true); + }); + + it('a session without the email scopes: the ✘ row says `void cloudflare logout`, then `login` — exit 1, no prompt', async () => { + const root = makeProject(); + roots.push(root); + const who = JSON.parse(fixture('whoami').stdout) as { tokenPermissions: Array }; + who.tokenPermissions = who.tokenPermissions.filter((s) => !s.startsWith('email_')); + const { deps, calls, fetchCalls, printed } = transcriptDeps({ + runnerTable: { 'whoami --json': { status: 0, stdout: JSON.stringify(who), stderr: '' } }, + }); + const confirm = vi.fn(async () => true); + + const code = await run(root, 'setup', { ...deps, isInteractive: true, confirm }); + + expect(code).toBe(1); + expect(confirm).not.toHaveBeenCalled(); + expect(printed[0]).toContain( + ' ✘ the Cloudflare session lacks email_routing:write + email_sending:write — run `void cloudflare logout`, then `void cloudflare login` (one browser Allow)', + ); + expect(printed[0]).not.toMatch(/wrangler login/i); + expect(calls.map((c) => c.args.slice(0, 2).join(' '))).toEqual(['whoami --json']); + expect(fetchCalls).toEqual([]); + }); + + it('an `all` blocker (foreign MX on the apex path) exits 1 without a prompt', async () => { + const root = makeProject({ from: 'noreply@acme.com' }); + roots.push(root); + const { deps, calls, fetchCalls, printed } = transcriptDeps({ + runnerTable: { + 'email routing settings acme.com': routingSettings('acme.com', 'unconfigured', false), + 'email sending settings acme.com': sendingNotFound('acme.com'), + }, + }); + const confirm = vi.fn(async () => true); + + const code = await run(root, 'setup', { ...deps, isInteractive: true, confirm }); + + expect(code).toBe(1); + expect(confirm).not.toHaveBeenCalled(); + expect(printed[0]).toContain( + '✘ acme.com already receives mail (aspmx.l.google.com). Void never enables routing over live mail. Use something@mail.acme.com.', + ); + expect(calls.some((c) => c.args.includes('enable'))).toBe(false); + expect(fetchCalls.every((c) => c.method === 'GET')).toBe(true); + }); + + it('a bearer failure keeps going: dashboard fallback printed, addresses withheld, exit 1', async () => { + const root = makeProject(); + roots.push(root); + const configPath = join(root, 'wrangler.jsonc'); + const { deps, printed } = transcriptDeps({ + runnerTable: { 'email sending enable mail.acme.com': sendingEnabled('mail.acme.com') }, + }); + + const code = await run(root, 'setup', { + ...deps, + getSessionBearer: async () => null, + isInteractive: true, + confirm: async () => true, + }); + + expect(code).toBe(1); + const output = printed.join('\n'); + expect(output).toContain('Email → Settings → Subdomains'); + expect(output).toContain('addresses withheld this run'); + // Sending was onboarded, so the binding + sender var are written; `addresses` is not. + const config = readJsonc(configPath); + expect(config.send_email).toEqual([{ name: 'SEND_EMAIL' }]); + expect(config.vars).toEqual({ __VOID_EMAIL_FROM: 'noreply@mail.acme.com' }); + expect(config.addresses).toBeUndefined(); + }); + + it('routing came up but the subaddressing PATCH was refused: exit 1, subaddressing named, addresses withheld', async () => { + // The apex path: `wrangler email routing enable acme.com` succeeds, then the bearer + // PATCH `{ support_subaddress: true }` is refused. `addresses` is withheld (no bearer + // call may fail, decision 11), so the next deploy creates NO rule, and until the flag + // is on `support+T-42@` matches no rule for `support@` either. Routing alone is not + // "set up": the exit and the verdict line must say what is missing and to rerun. + const root = makeProject({ from: 'noreply@acme.com' }); + roots.push(root); + const configPath = join(root, 'wrangler.jsonc'); + const { deps, calls, fetchCalls, printed } = transcriptDeps({ + runnerTable: { + 'email routing settings acme.com': routingSettings('acme.com', 'unconfigured', false), + 'email sending settings acme.com': sendingNotFound('acme.com'), + 'email routing enable acme.com': { + status: 0, + stdout: 'Email Routing enabled for acme.com (status: ready)\n', + stderr: BETA_WARNING, + }, + 'email sending enable acme.com': sendingEnabled('acme.com'), + }, + fetchTable: { + [`PATCH /zones/${ZONE_ID}/email/routing`]: () => + jsonResponse(403, { + success: false, + errors: [{ code: 10000, message: 'Authentication error' }], + }), + }, + mx: {}, + }); + const logged = captureLog(); + let code: number; + try { + code = await run(root, 'setup', { ...deps, isInteractive: true, confirm: async () => true }); + } finally { + logged.restore(); + } + + expect(code).toBe(1); + expect(calls.map((c) => c.args.slice(0, 4))).toContainEqual([ + 'email', + 'routing', + 'enable', + 'acme.com', + ]); + expect(fetchCalls.map((c) => c.method)).toEqual(['GET', 'GET', 'PATCH']); + const output = printed.join('\n'); + expect(output).toContain('✔ routing acme.com enabled'); + expect(output).toContain( + '✘ routing subaddressing on acme.com: Cloudflare answered 403 [code: 10000] — Authentication error', + ); + expect(output).toContain('addresses withheld this run (session-bearer step failed)'); + // Routing DID come up, so the binding + sender var are written; `addresses` is not. + const config = readJsonc(configPath); + expect(config.send_email).toEqual([{ name: 'SEND_EMAIL' }]); + expect(config.vars).toEqual({ __VOID_EMAIL_FROM: 'noreply@acme.com' }); + expect(config.addresses).toBeUndefined(); + // The verdict: not "set up" — what is missing, and the rerun. + const verdict = logged.lines.join('\n'); + expect(verdict).not.toContain('Email set up on'); + expect(verdict).toContain('subaddressing'); + expect(verdict).toContain('rerun `void email setup --platform cloudflare`'); + }); + + it('Workers Free: routing + subaddressing on, sending refused → exit 0 with the plan hint, addresses written', async () => { + // Sending onboarding needs Workers Paid, which Void cannot buy; inbound is complete and + // `addresses` is committed, which is all `setup` exists to do (CI commits the file). + // The plan hint and the `(verified destinations only)` marker are the warning. + const root = makeProject(); + roots.push(root); + const configPath = join(root, 'wrangler.jsonc'); + const { deps, printed } = transcriptDeps({ + runnerTable: { + 'email sending enable mail.acme.com': { + status: 1, + stdout: '', + stderr: `${BETA_WARNING}✘ [ERROR] A request to the Cloudflare API (/zones/${ZONE_ID}/email/sending/subdomains) failed.\n\n something went wrong [code: 10105]\n`, + }, + }, + fetchTable: { + [`POST /zones/${ZONE_ID}/email/routing/dns`]: () => + jsonResponse(200, { success: true, result: { name: 'mail.acme.com' } }), + [`PATCH /zones/${ZONE_ID}/email/routing`]: () => + jsonResponse(200, { success: true, result: { support_subaddress: true } }), + }, + }); + const logged = captureLog(); + let code: number; + try { + code = await run(root, 'setup', { ...deps, isInteractive: true, confirm: async () => true }); + } finally { + logged.restore(); + } + + expect(code).toBe(0); + const output = printed.join('\n'); + expect(output).toContain('✔ routing mail.acme.com enabled'); + expect(output).toContain('✔ routing subaddressing on for acme.com'); + expect(output).toContain( + '! sending mail.acme.com not onboarded [code: 10105] — account is not entitled to Email Sending', + ); + expect(output).toContain(SENDING_FREE_PLAN_HINT); + expect(printed.at(-1)).toContain( + 'outbound sendEmail() from noreply@mail.acme.com (verified destinations only)', + ); + expect(readJsonc(configPath).addresses).toEqual(['support@mail.acme.com']); + expect(logged.lines.join('\n')).toContain('Email set up on mail.acme.com.'); + }); + + it('Workers Free after `setup`: the onboarding is offered as its own prompt; refused again → hint, config unchanged, exit 0', async () => { + const { root, configPath, deps, calls, fetchCalls, printed } = freeAfterSetup({ + runnerTable: { 'email sending enable mail.acme.com': sendingRefused }, + }); + const before = readFileSync(configPath, 'utf-8'); + const confirms: Array = []; + const logged = captureLog(); + let code: number; + try { + code = await run(root, 'setup', { + ...deps, + isInteractive: true, + confirm: async (message) => { + confirms.push(message); + return true; + }, + }); + } finally { + logged.restore(); + } + + expect(code).toBe(0); + expect(confirms).toEqual([RETRY_PROMPT]); + // The checklist before the prompt: the row says what works, the retry is the one change. + expect(printed[0]).toContain( + ' sending mail.acme.com not onboarded — verified destinations only; after upgrading to Workers Paid run void email setup --platform cloudflare', + ); + expect(printed[0]).toContain(' + onboard mail.acme.com for Email Sending'); + expect(printed[0]).not.toContain('+ enable Email Routing'); + expect(printed[0]).not.toContain('+ wrangler.jsonc'); + // ONE account write — the onboarding — and no bearer write. + expect(calls.filter((c) => c.args.includes('enable')).map((c) => c.args.slice(0, 4))).toEqual([ + ['email', 'sending', 'enable', 'mail.acme.com'], + ]); + expect(fetchCalls.every((c) => c.method === 'GET')).toBe(true); + const output = printed.join('\n'); + expect(output).toContain( + '! sending mail.acme.com not onboarded [code: 10105] — account is not entitled to Email Sending', + ); + expect(output).toContain(SENDING_FREE_PLAN_HINT); + expect(printed.at(-1)).toContain( + 'outbound sendEmail() from noreply@mail.acme.com (verified destinations only)', + ); + expect(readFileSync(configPath, 'utf-8')).toBe(before); + expect(logged.lines.join('\n')).toContain('Email set up on mail.acme.com.'); + }); + + it('Workers Free after `setup`, upgraded to Workers Paid: the retry onboards the domain', async () => { + const { root, configPath, deps, calls, printed } = freeAfterSetup({ + runnerTable: { 'email sending enable mail.acme.com': sendingEnabled('mail.acme.com') }, + }); + const before = readFileSync(configPath, 'utf-8'); + const logged = captureLog(); + let code: number; + try { + code = await run(root, 'setup', { ...deps, isInteractive: true, confirm: async () => true }); + } finally { + logged.restore(); + } + + expect(code).toBe(0); + expect(calls.filter((c) => c.args.includes('enable'))).toHaveLength(1); + expect(printed.join('\n')).toContain('✔ sending mail.acme.com onboarded'); + expect(printed.at(-1)).toBe( + [ + ' inbound support@mail.acme.com → email/support+[ticket].ts', + ' outbound sendEmail() from noreply@mail.acme.com', + ].join('\n'), + ); + expect(readFileSync(configPath, 'utf-8')).toBe(before); + expect(logged.lines.join('\n')).toContain('Email set up on mail.acme.com.'); + }); + + it('Workers Free after `setup`, retry declined: nothing changed, no `enable` call, no write', async () => { + const { root, configPath, deps, calls, fetchCalls } = freeAfterSetup(); + const before = readFileSync(configPath, 'utf-8'); + const confirms: Array = []; + const logged = captureLog(); + let code: number; + try { + code = await run(root, 'setup', { + ...deps, + isInteractive: true, + confirm: async (message) => { + confirms.push(message); + return false; + }, + }); + } finally { + logged.restore(); + } + + expect(code).toBe(0); + expect(confirms).toEqual([RETRY_PROMPT]); + expect(logged.lines).toContain('Nothing changed.'); + expect(calls.some((c) => c.args.includes('enable'))).toBe(false); + expect(fetchCalls.every((c) => c.method === 'GET')).toBe(true); + expect(readFileSync(configPath, 'utf-8')).toBe(before); + }); + + it('Workers Free after `setup`, non-interactive: the retry is never run unasked — exit 1, no `enable` call', async () => { + const { root, configPath, deps, calls } = freeAfterSetup(); + const before = readFileSync(configPath, 'utf-8'); + const confirm = vi.fn(async () => true); + + const code = await run(root, 'setup', { ...deps, isInteractive: false, confirm }); + + expect(code).toBe(1); + expect(confirm).not.toHaveBeenCalled(); + expect(calls.some((c) => c.args.includes('enable'))).toBe(false); + expect(readFileSync(configPath, 'utf-8')).toBe(before); + }); + + it('second run, everything ready: no prompt, zero writes, config unchanged, exit 0', async () => { + const root = makeProject({ + wrangler: `{ + "account_id": "${ACCOUNT}", + "name": "acme-support", + "send_email": [{ "name": "SEND_EMAIL" }], + "addresses": ["support@mail.acme.com"], + "vars": { "__VOID_EMAIL_FROM": "noreply@mail.acme.com" }, +} +`, + }); + roots.push(root); + const before = readFileSync(join(root, 'wrangler.jsonc'), 'utf-8'); + const { deps, calls, fetchCalls, printed } = transcriptDeps({ + runnerTable: { + 'email routing rules list acme.com': { + status: 0, + stdout: + 'Rule: 1a2b3c\n Name: support\n Enabled: true\n Matchers: to:support@mail.acme.com\n Actions: worker:acme-support\n Priority: 0\n\nCatch-all rule: disabled, action: drop\n', + stderr: BETA_WARNING, + }, + 'email sending settings mail.acme.com': { + status: 0, + stdout: `Email Sending for mail.acme.com:\n Enabled: true\n Tag: ${ZONE_ID}\n`, + stderr: BETA_WARNING, + }, + }, + fetchTable: { + [`GET /zones/${ZONE_ID}/email/routing`]: () => + jsonResponse(200, { success: true, result: { enabled: true, support_subaddress: true } }), + }, + mx: { 'acme.com': ['aspmx.l.google.com'], 'mail.acme.com': ['route1.mx.cloudflare.net'] }, + }); + const confirm = vi.fn(async () => true); + + const code = await run(root, 'setup', { ...deps, isInteractive: false, confirm }); + + expect(code).toBe(0); + expect(confirm).not.toHaveBeenCalled(); + expect(calls.some((c) => c.args.includes('enable'))).toBe(false); + expect(fetchCalls.every((c) => c.method === 'GET')).toBe(true); + expect(readFileSync(join(root, 'wrangler.jsonc'), 'utf-8')).toBe(before); + // Every row reads ready; the only remaining line is the `!` for the refused catch-all. + expect(printed[0]).toContain(' rules support@mail.acme.com → acme-support (ready)'); + expect(printed[0]).not.toContain('\n + '); + }); + + it('refuses without email.from before touching wrangler', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-email-cli-')); + roots.push(root); + writeFileSync(join(root, 'void.json'), '{}'); + writeFileSync(join(root, 'wrangler.jsonc'), INITIAL_WRANGLER); + const { deps, calls, fetchCalls } = transcriptDeps(); + + const code = await run(root, 'setup', { ...deps, isInteractive: true }); + + expect(code).toBe(1); + expect(calls).toEqual([]); + expect(fetchCalls).toEqual([]); + }); + + it('refuses without a pinned account before touching wrangler', async () => { + const root = makeProject({ wrangler: '{ "name": "acme-support" }\n' }); + roots.push(root); + const saved = process.env.CLOUDFLARE_ACCOUNT_ID; + delete process.env.CLOUDFLARE_ACCOUNT_ID; + try { + const { deps, calls } = transcriptDeps(); + const code = await run(root, 'setup', { ...deps, isInteractive: true }); + expect(code).toBe(1); + expect(calls).toEqual([]); + } finally { + if (saved !== undefined) { + process.env.CLOUDFLARE_ACCOUNT_ID = saved; + } + } + }); +}); + +describe('void email status --platform cloudflare', () => { + it('not ready: status rows + address map, exit 1, read-only', async () => { + const root = makeProject(); + roots.push(root); + const before = readFileSync(join(root, 'wrangler.jsonc'), 'utf-8'); + const { deps, calls, fetchCalls, printed } = transcriptDeps(); + const confirm = vi.fn(async () => true); + + const code = await run(root, 'status', { ...deps, isInteractive: true, confirm }); + + expect(code).toBe(1); + expect(confirm).not.toHaveBeenCalled(); + expect(calls.some((c) => c.args.includes('enable'))).toBe(false); + expect(fetchCalls.every((c) => c.method === 'GET')).toBe(true); + expect(readFileSync(join(root, 'wrangler.jsonc'), 'utf-8')).toBe(before); + + expect(printed).toHaveLength(2); + expect(printed[0]).toContain(' routing mail.acme.com not enabled · subaddressing off'); + expect(printed[0]).toContain(' binding SEND_EMAIL (absent)'); + expect(printed[1]).toBe( + [ + ' inbound support@mail.acme.com → email/support+[ticket].ts (no rule yet)', + ' outbound sendEmail() from noreply@mail.acme.com (verified destinations only)', + ].join('\n'), + ); + }); + + it('Workers Free after `setup` (binding committed, sending still not onboarded): exit 0, the row says what works and how to retry', async () => { + const { root, configPath, deps, calls, fetchCalls, printed } = freeAfterSetup(); + const before = readFileSync(configPath, 'utf-8'); + const confirm = vi.fn(async () => true); + const logged = captureLog(); + let code: number; + try { + code = await run(root, 'status', { ...deps, isInteractive: true, confirm }); + } finally { + logged.restore(); + } + + expect(code).toBe(0); + expect(confirm).not.toHaveBeenCalled(); + expect(calls.some((c) => c.args.includes('enable'))).toBe(false); + expect(fetchCalls.every((c) => c.method === 'GET')).toBe(true); + expect(readFileSync(configPath, 'utf-8')).toBe(before); + expect(printed[0]).toContain( + ' sending mail.acme.com not onboarded — verified destinations only; after upgrading to Workers Paid run void email setup --platform cloudflare', + ); + expect(printed[0]).toContain(' binding SEND_EMAIL (present)'); + expect(printed[1]).toBe( + [ + ' inbound support@mail.acme.com → email/support+[ticket].ts', + ' outbound sendEmail() from noreply@mail.acme.com (verified destinations only)', + ].join('\n'), + ); + expect(logged.lines.join('\n')).toContain('Email is set up on mail.acme.com.'); + expect(logged.lines.join('\n')).not.toContain('not set up'); + }); + + it("ready but the rule not created yet (right after `setup`): exit 0 — rules are the next deploy's", async () => { + const root = makeProject({ + wrangler: `{ + "account_id": "${ACCOUNT}", + "name": "acme-support", + "send_email": [{ "name": "SEND_EMAIL" }], + "addresses": ["support@mail.acme.com"], + "vars": { "__VOID_EMAIL_FROM": "noreply@mail.acme.com" }, +} +`, + }); + roots.push(root); + const { deps, printed } = transcriptDeps({ + runnerTable: { + 'email sending settings mail.acme.com': { + status: 0, + stdout: `Email Sending for mail.acme.com:\n Enabled: true\n Tag: ${ZONE_ID}\n`, + stderr: BETA_WARNING, + }, + }, + fetchTable: { + [`GET /zones/${ZONE_ID}/email/routing`]: () => + jsonResponse(200, { success: true, result: { enabled: true, support_subaddress: true } }), + }, + mx: { 'acme.com': ['aspmx.l.google.com'], 'mail.acme.com': ['route1.mx.cloudflare.net'] }, + }); + + const code = await run(root, 'status', { ...deps, isInteractive: false }); + + expect(code).toBe(0); + expect(printed[1]).toContain( + ' inbound support@mail.acme.com → email/support+[ticket].ts (no rule yet)', + ); + }); + + it('an API-token session reads ready like a login session does (CI on CLOUDFLARE_API_TOKEN)', async () => { + const root = makeProject({ + wrangler: `{ + "account_id": "${ACCOUNT}", + "name": "acme-support", + "send_email": [{ "name": "SEND_EMAIL" }], + "addresses": ["support@mail.acme.com"], + "vars": { "__VOID_EMAIL_FROM": "noreply@mail.acme.com" }, +} +`, + }); + roots.push(root); + const { deps, calls } = transcriptDeps({ + runnerTable: { + 'whoami --json': { + status: 0, + stdout: JSON.stringify({ + loggedIn: true, + authType: 'User API Token', + accounts: [{ id: ACCOUNT, name: 'Acme' }], + }), + stderr: '', + }, + 'email routing rules list acme.com': { + status: 0, + stdout: + 'Rule: 1a2b3c\n Name: support\n Enabled: true\n Matchers: to:support@mail.acme.com\n Actions: worker:acme-support\n Priority: 0\n\nCatch-all rule: disabled, action: drop\n', + stderr: BETA_WARNING, + }, + 'email sending settings mail.acme.com': { + status: 0, + stdout: `Email Sending for mail.acme.com:\n Enabled: true\n Tag: ${ZONE_ID}\n`, + stderr: BETA_WARNING, + }, + }, + fetchTable: { + [`GET /zones/${ZONE_ID}/email/routing`]: () => + jsonResponse(200, { success: true, result: { enabled: true, support_subaddress: true } }), + }, + mx: { 'acme.com': ['aspmx.l.google.com'], 'mail.acme.com': ['route1.mx.cloudflare.net'] }, + }); + + const code = await run(root, 'status', { ...deps, isInteractive: false }); + + expect(code).toBe(0); + expect(calls.some((c) => c.args[0] === 'email')).toBe(true); + }); + + it('not signed in: the checklist reaches the terminal as-is and says `void cloudflare login`, exit 1', async () => { + const root = makeProject(); + roots.push(root); + const { deps, calls } = transcriptDeps({ + runnerTable: { + 'whoami --json': { status: 0, stdout: JSON.stringify({ loggedIn: false }), stderr: '' }, + }, + }); + // The default block printer is clack's `log.message`, straight from the renderer — this + // path has no `formatCloudflareUserMessage` in front of it, so the text pinned here is + // exactly what the developer reads. + const { print: _print, ...withoutPrint } = deps; + const logged = captureLog(); + let code: number; + try { + code = await run(root, 'status', { ...withoutPrint, isInteractive: false }); + } finally { + logged.restore(); + } + + expect(code).toBe(1); + const output = logged.lines.join('\n'); + expect(output).toContain(' ✘ not logged in to Cloudflare — run `void cloudflare login`'); + expect(output).not.toMatch(/wrangler login/i); + expect(output).toContain('Email is not set up on mail.acme.com yet'); + expect(calls.map((c) => c.args.slice(0, 2).join(' '))).toEqual(['whoami --json']); + }); + + it('ready: exit 0 and the routed address map', async () => { + const root = makeProject({ + wrangler: `{ + "account_id": "${ACCOUNT}", + "name": "acme-support", + "send_email": [{ "name": "SEND_EMAIL" }], + "addresses": ["support@mail.acme.com"], +} +`, + }); + roots.push(root); + const { deps, printed } = transcriptDeps({ + runnerTable: { + 'email routing rules list acme.com': { + status: 0, + stdout: + 'Rule: 1a2b3c\n Name: support\n Enabled: true\n Matchers: to:support@mail.acme.com\n Actions: worker:acme-support\n Priority: 0\n\nCatch-all rule: disabled, action: drop\n', + stderr: BETA_WARNING, + }, + 'email sending settings mail.acme.com': { + status: 0, + stdout: `Email Sending for mail.acme.com:\n Enabled: true\n Tag: ${ZONE_ID}\n`, + stderr: BETA_WARNING, + }, + }, + fetchTable: { + [`GET /zones/${ZONE_ID}/email/routing`]: () => + jsonResponse(200, { success: true, result: { enabled: true, support_subaddress: true } }), + }, + mx: { 'acme.com': ['aspmx.l.google.com'], 'mail.acme.com': ['route1.mx.cloudflare.net'] }, + }); + + const code = await run(root, 'status', { ...deps, isInteractive: false }); + + expect(code).toBe(0); + expect(printed[1]).toBe( + [ + ' inbound support@mail.acme.com → email/support+[ticket].ts', + ' outbound sendEmail() from noreply@mail.acme.com', + ].join('\n'), + ); + }); +}); + +describe('void email setup|status without --platform cloudflare', () => { + let exitSpy: ReturnType; + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code: number) => { + throw new ExitSignal(code); + }) as never); + }); + afterEach(() => { + exitSpy.mockRestore(); + }); + + it('explains that both are Mode B commands today and exits 1 before any platform call', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-email-cli-')); + roots.push(root); + for (const subcommand of ['setup', 'status'] as const) { + await expect(runEmailCommand(root, { subcommand })).rejects.toBeInstanceOf(ExitSignal); + expect(exitSpy).toHaveBeenLastCalledWith(1); + // `--platform void` is that default spelled out: the same exit, still no platform call. + await expect(runEmailCommand(root, { subcommand, platform: 'void' })).rejects.toBeInstanceOf( + ExitSignal, + ); + expect(exitSpy).toHaveBeenLastCalledWith(1); + } + }); +}); diff --git a/packages/void/test/unit/email-cli-disallow.test.ts b/packages/void/test/unit/email-cli-disallow.test.ts new file mode 100644 index 000000000..97a34e539 --- /dev/null +++ b/packages/void/test/unit/email-cli-disallow.test.ts @@ -0,0 +1,78 @@ +import { log } from '@clack/prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import type { PlatformClient } from '../../src/cli/client.ts'; +import { emailDisallow } from '../../src/cli/email.ts'; + +/** + * What `void email disallow
` prints on success. The one contract + * under test: the line must not tell the user a deploy is needed. The api + * tightens the proxy's `email-allowlist:` KV mirror inside the + * DELETE (503 if that write fails) and the proxy re-reads the mirror with a + * 60s `cacheTtl` on both the send path and inbound forward/reply, so the + * revocation lands on its own within about a minute — no deploy touches + * the allowlist. + */ + +type Destination = Awaited>[number]; + +const ADDRESS = 'owner@example.com'; + +function destination(overrides: Partial = {}): Destination { + return { + id: 'edst_1', + address: ADDRESS, + status: 'verified', + verified_at: '2026-09-01T00:00:00Z', + created_at: '2026-09-01T00:00:00Z', + ...overrides, + }; +} + +function client( + rows: Array, +): Pick { + return { + listEmailDestinations: vi.fn(async () => rows), + removeEmailDestination: vi.fn(async () => ({ ok: true })), + }; +} + +type LogSpy = ReturnType; + +let successSpy: LogSpy; + +beforeEach(() => { + successSpy = vi.spyOn(log, 'success').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function printed(spy: LogSpy): string { + return (spy.mock.calls as Array>).map((call) => String(call[0])).join('\n'); +} + +describe('void email disallow', () => { + it('removes the matching destination and reports the revocation without a deploy', async () => { + const c = client([destination()]); + + await emailDisallow(c, 'prj_1', ADDRESS); + + expect(c.removeEmailDestination).toHaveBeenCalledWith('prj_1', 'edst_1'); + const text = printed(successSpy); + expect(text).toBe(`Removed ${ADDRESS}. Sends to it are refused within about a minute.`); + // The api tightens the KV allowlist in the DELETE and the proxy's + // cacheTtl bounds convergence; nothing about a revoke waits for a deploy. + expect(text).not.toMatch(/deploy/); + }); + + it('matches the address case-insensitively and echoes the stored spelling', async () => { + const c = client([destination({ address: 'Owner@Example.com' })]); + + await emailDisallow(c, 'prj_1', ADDRESS); + + expect(c.removeEmailDestination).toHaveBeenCalledWith('prj_1', 'edst_1'); + expect(printed(successSpy)).toContain('Removed Owner@Example.com.'); + }); +}); diff --git a/packages/void/test/unit/email-cli-logs.test.ts b/packages/void/test/unit/email-cli-logs.test.ts new file mode 100644 index 000000000..990c3a225 --- /dev/null +++ b/packages/void/test/unit/email-cli-logs.test.ts @@ -0,0 +1,65 @@ +import { log } from '@clack/prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import type { EmailLogsResponse, PlatformClient } from '../../src/cli/client.ts'; +import { emailLogs } from '../../src/cli/email.ts'; + +/** + * What `void email logs` prints while the api still answers + * `{ logs: [], pending: true }`. The one contract under test: the command + * never points a hosted-project user at Wrangler. The proxy's send/receive + * records are the PLATFORM worker's console output, which a project's own + * Cloudflare credentials cannot tail — so the only truthful pointer is the + * user's own worker log (`void project logs`), where the email handler's + * console output lands like any other invocation. + */ + +function client(body: EmailLogsResponse): Pick { + return { getEmailLogs: vi.fn(async () => body) }; +} + +type LogSpy = ReturnType; + +let infoSpy: LogSpy; + +beforeEach(() => { + infoSpy = vi.spyOn(log, 'info').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function printed(spy: LogSpy): string { + return (spy.mock.calls as Array>).map((call) => String(call[0])).join('\n'); +} + +describe('void email logs', () => { + it('prints the api note and points at `void project logs` while storage is pending', async () => { + const note = 'Persistent log storage lands when Analytics Engine queries are wired.'; + const c = client({ logs: [], pending: true, note }); + + await emailLogs(c, 'prj_1', 25); + + expect(c.getEmailLogs).toHaveBeenCalledWith('prj_1', 25); + const text = printed(infoSpy); + expect(text).toContain(note); + expect(text).toContain('Email activity logs are not available yet.'); + expect(text).toContain('`void project logs`'); + }); + + it('never sends a hosted-project user to Wrangler', async () => { + // The proxy is Void's worker: `wrangler tail` under the user's own + // credentials cannot reach it, and the CLI policy forbids the pointer. + await emailLogs(client({ logs: [], pending: true }), 'prj_1'); + + const text = printed(infoSpy); + expect(text).not.toMatch(/wrangler/i); + expect(text).not.toContain('proxy worker'); + }); + + it('falls back to a generic pending line when the api sends no note', async () => { + await emailLogs(client({ logs: [], pending: true }), 'prj_1'); + + expect(printed(infoSpy)).toContain('Persistent log storage is not yet wired up.'); + }); +}); diff --git a/packages/void/test/unit/email-cloudflare.test.ts b/packages/void/test/unit/email-cloudflare.test.ts new file mode 100644 index 000000000..5076d214d --- /dev/null +++ b/packages/void/test/unit/email-cloudflare.test.ts @@ -0,0 +1,1998 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'pathslash'; +import { describe, expect, it } from 'vite-plus/test'; +import { + applyEmailSetup, + deriveAddresses, + emailFromDomain, + fetchWithSessionBearer, + findEmailConfigConflicts, + parseRoutingRules, + parseRoutingSettings, + parseSendingSettings, + parseWhoamiJson, + preflightEmail, + renderAddressMap, + renderEmailChecklist, + subdomainDashboardFallback, + SENDING_FREE_PLAN_HINT, + WRANGLER_EMAIL_PROSE_VERSION, + wranglerApiErrorCode, + type EmailCloudflareInputs, + type EmailDns, + type EmailPreflight, + type EmailWranglerRunResult, +} from '../../src/cli/email-cloudflare.ts'; +import type { EmailHandlersScan } from '../../src/router/email.ts'; +import { compileEmailPattern } from '../../src/router/email.ts'; + +// ── Fixtures: verbatim stdout/stderr of the pinned wrangler (read-only captures) ── + +const FIXTURES = join( + import.meta.dirname, + '..', + 'fixtures', + 'wrangler-email', + WRANGLER_EMAIL_PROSE_VERSION, +); + +function fixture(name: string): EmailWranglerRunResult { + return { + status: Number(readFileSync(join(FIXTURES, `${name}.exit`), 'utf-8').trim()), + stdout: readFileSync(join(FIXTURES, `${name}.stdout`), 'utf-8'), + stderr: readFileSync(join(FIXTURES, `${name}.stderr`), 'utf-8'), + }; +} + +/** The wrangler-sourced prose shapes the fixtures cannot show (rules present, catch-all to a worker). */ +const RULES_WITH_ENTRIES = ` + ⛅️ wrangler 4.131.0 +──────────────────── +Rule: 1a2b3c + Name: support + Enabled: true + Matchers: to:support@acme.com + Actions: worker:acme-support + Priority: 0 + +Rule: 4d5e6f + Name: (none) + Enabled: true + Matchers: to:sales@acme.com + Actions: forward:sales@gmail.com + Priority: 1 + + +Catch-all rule: enabled, action: worker:acme-support + (use \`wrangler email routing rules get catch-all\` to view details) +`; + +const BETA_WARNING = + '\u001b[33m▲ \u001b[43;33m[\u001b[43;30mWARNING\u001b[43;33m]\u001b[0m \u001b[1m🚧 `wrangler email x` is an open beta command.\u001b[0m\n'; + +function routingSettings( + name: string, + enabled: boolean, + status: string, + tag: string, +): EmailWranglerRunResult { + return { + status: 0, + stdout: `\n ⛅️ wrangler 4.131.0\n────────────────────\nEmail Routing for ${name}:\n Enabled: ${enabled}\n Status: ${status}\n Created: 2026-03-18T02:37:48.990322Z\n Modified: 2026-03-18T02:44:54.833251Z\n Tag: ${tag}\n`, + stderr: BETA_WARNING, + }; +} + +function zoneNotFound(name: string): EmailWranglerRunResult { + return { + status: 1, + stdout: '\n ⛅️ wrangler 4.131.0\n────────────────────\n\n', + stderr: `${BETA_WARNING}\n\n\u001b[31m✘ \u001b[41;31m[\u001b[41;97mERROR\u001b[41;31m]\u001b[0m \u001b[1mCould not find zone for \`${name}\`. Make sure the domain exists in your account.\u001b[0m\n`, + }; +} + +function sendingNotFound(name: string): EmailWranglerRunResult { + return { + status: 1, + stdout: '\n ⛅️ wrangler 4.131.0\n────────────────────\n\n', + stderr: `${BETA_WARNING}\n\n\u001b[31m✘ \u001b[41;31m[\u001b[41;97mERROR\u001b[41;31m]\u001b[0m \u001b[1mNo sending subdomain found for \`${name}\`. Run \`wrangler email sending list ${name}\` to see configured subdomains.\u001b[0m\n`, + }; +} + +function apiFailure(code: number, resource: string): EmailWranglerRunResult { + return { + status: 1, + stdout: '', + stderr: `${BETA_WARNING}\n\u001b[31m✘ \u001b[41;31m[\u001b[41;97mERROR\u001b[41;31m]\u001b[0m \u001b[1mA request to the Cloudflare API (${resource}) failed.\u001b[0m\n\n something went wrong [code: ${code}]\n`, + }; +} + +// ── Scan builders ──────────────────────────────────────────────────────────────── + +function scan(files: Array, defaultHandler?: string): EmailHandlersScan { + return { + addresses: files.map((filePath) => { + const basename = filePath.replace(/\.[^.]+$/, ''); + return { address: basename.toLowerCase(), filePath, pattern: compileEmailPattern(basename)! }; + }), + defaultHandler, + }; +} + +// ── Fake seams ─────────────────────────────────────────────────────────────────── + +const ZONE_ID = '5b66f5cb84fd7dcfdd0170515e622181'; +const ACCOUNT = 'f721b8e5f721b8e5f721b8e5f721b8e5'; +const BEARER = 'cfoa-SECRET-BEARER-TOKEN-0123456789'; + +type Call = { args: Array; capture: boolean }; +type FetchCall = { + url: string; + method: string; + headers: Record; + body: string | undefined; +}; + +function fakeDns(overrides: { + mx?: Record>; + txt?: Record>; + mxError?: Record; +}): EmailDns { + const noData = (): Error => Object.assign(new Error('queryMx ENODATA'), { code: 'ENODATA' }); + return { + resolveMx: async (name) => { + if (overrides.mxError?.[name]) { + throw Object.assign(new Error(overrides.mxError[name]), { code: overrides.mxError[name] }); + } + const list = overrides.mx?.[name]; + if (!list || list.length === 0) { + throw noData(); + } + return list.map((exchange, i) => ({ exchange, priority: (i + 1) * 10 })); + }, + resolveTxt: async (name) => { + const list = overrides.txt?.[name]; + if (!list || list.length === 0) { + throw noData(); + } + return list.map((v) => [v]); + }, + }; +} + +function fakeRunner( + table: Record EmailWranglerRunResult)>, + calls: Array, +): EmailCloudflareInputs['runWrangler'] { + return (args, opts) => { + calls.push({ args: [...args], capture: opts.capture }); + const key = args.join(' '); + const hit = table[key]; + if (!hit) { + throw new Error(`unexpected wrangler call: ${key}`); + } + return typeof hit === 'function' ? hit() : hit; + }; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function fakeFetch( + table: Record Response | Promise>, + calls: Array, +): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const headers: Record = {}; + for (const [k, v] of Object.entries((init?.headers ?? {}) as Record)) { + headers[k] = v; + } + const call: FetchCall = { + url, + method: init?.method ?? 'GET', + headers, + body: typeof init?.body === 'string' ? init.body : undefined, + }; + calls.push(call); + // Key on the API path after the base (`/client/v4`), so the table reads like the docs. + const key = `${call.method} ${url.slice(url.indexOf('/zones'))}`; + const hit = table[key]; + if (!hit) { + throw new Error(`unexpected fetch: ${key}`); + } + return hit(call); + }) as typeof fetch; +} + +function zonesOk(name: string, extra: Partial<{ type: string; account: string }> = {}) { + return () => + jsonResponse(200, { + success: true, + result: [ + { + id: ZONE_ID, + name, + type: extra.type ?? 'full', + account: { id: extra.account ?? ACCOUNT }, + }, + ], + }); +} + +function routingGet(supportSubaddress: boolean) { + return () => + jsonResponse(200, { + success: true, + result: { enabled: true, support_subaddress: supportSubaddress }, + }); +} + +/** + * The 0078 transcript scenario: `email/support+[ticket].ts` + `email/_default.ts`, + * `email.from = noreply@mail.acme.com`, zone `acme.com` (routing ready on the apex, + * Google MX there), nothing yet on `mail.acme.com`. + */ +function transcriptInputs( + overrides: Partial & { + runnerTable?: Record EmailWranglerRunResult)>; + fetchTable?: Record Response | Promise>; + calls?: Array; + fetchCalls?: Array; + dns?: EmailDns; + } = {}, +): { inputs: EmailCloudflareInputs; calls: Array; fetchCalls: Array } { + const calls = overrides.calls ?? []; + const fetchCalls = overrides.fetchCalls ?? []; + const whoami = fixture('whoami'); + const runnerTable = { + 'whoami --json': whoami, + 'email routing settings mail.acme.com': zoneNotFound('mail.acme.com'), + 'email routing settings acme.com': routingSettings('acme.com', true, 'ready', ZONE_ID), + 'email routing rules list acme.com': fixture('routing-rules-list-void.cloud'), + 'email sending settings mail.acme.com': sendingNotFound('mail.acme.com'), + ...overrides.runnerTable, + }; + const fetchTable = { + 'GET /zones?name=acme.com': zonesOk('acme.com'), + [`GET /zones/${ZONE_ID}/email/routing`]: routingGet(false), + ...overrides.fetchTable, + }; + const rest: Partial = { ...overrides }; + delete (rest as Record).runnerTable; + delete (rest as Record).fetchTable; + delete (rest as Record).calls; + delete (rest as Record).fetchCalls; + const inputs: EmailCloudflareInputs = { + handlers: scan(['support+[ticket].ts'], '_default.ts'), + from: 'noreply@mail.acme.com', + workerName: 'acme-support', + accountId: ACCOUNT, + sendEmailFiles: 2, + runWrangler: fakeRunner(runnerTable, calls), + getSessionBearer: async () => BEARER, + fetchImpl: fakeFetch(fetchTable, fetchCalls), + dns: fakeDns({ mx: { 'acme.com': ['aspmx.l.google.com', 'alt1.aspmx.l.google.com'] } }), + ...rest, + }; + return { inputs, calls, fetchCalls }; +} + +// ── Derivation ─────────────────────────────────────────────────────────────────── + +describe('deriveAddresses', () => { + it('maps literal and literal+[tag] handlers to @', () => { + const out = deriveAddresses( + scan(['support+[ticket].ts', 'sales.ts', 'Support.ts']), + 'mail.acme.com', + 'subdomain', + ); + expect(out.addresses).toEqual(['support@mail.acme.com', 'sales@mail.acme.com']); + expect(out.refused).toEqual([]); + expect(out.routes).toEqual([ + { address: 'support@mail.acme.com', handler: 'email/support+[ticket].ts' }, + { address: 'sales@mail.acme.com', handler: 'email/sales.ts' }, + { address: 'support@mail.acme.com', handler: 'email/Support.ts' }, + ]); + }); + + it('_default.ts becomes *@ on the apex only', () => { + const apex = deriveAddresses(scan(['support.ts'], '_default.ts'), 'acme.com', 'apex'); + expect(apex.addresses).toEqual(['support@acme.com', '*@acme.com']); + expect(apex.refused).toEqual([]); + + const sub = deriveAddresses(scan(['support.ts'], '_default.ts'), 'mail.acme.com', 'subdomain'); + expect(sub.addresses).toEqual(['support@mail.acme.com']); + expect(sub.refused).toEqual([ + { + handler: 'email/_default.ts', + reason: 'a catch-all exists only on an apex; other @mail.acme.com mail bounces', + }, + ]); + }); + + it('[name].ts and [name]+[tag].ts need the catch-all: allowed on the apex, refused on a subdomain', () => { + const apex = deriveAddresses(scan(['[name].ts', '[user]+[tag].ts']), 'acme.com', 'apex'); + expect(apex.addresses).toEqual(['*@acme.com']); + expect(apex.refused).toEqual([]); + + const sub = deriveAddresses( + scan(['[name].ts', '[user]+[tag].ts']), + 'mail.acme.com', + 'subdomain', + ); + expect(sub.addresses).toEqual([]); + expect(sub.refused.map((r) => r.handler)).toEqual(['email/[name].ts', 'email/[user]+[tag].ts']); + for (const r of sub.refused) { + expect(r.reason).toContain('catch-all'); + expect(r.reason).toContain('mail.acme.com'); + } + }); + + it('emits the catch-all once even when several handlers need it', () => { + const out = deriveAddresses(scan(['[name].ts'], '_default.ts'), 'acme.com', 'apex'); + expect(out.addresses).toEqual(['*@acme.com']); + expect(out.routes).toEqual([ + { address: '*@acme.com', handler: 'email/[name].ts' }, + { address: '*@acme.com', handler: 'email/_default.ts' }, + ]); + }); +}); + +describe('emailFromDomain', () => { + it('extracts and lowercases the host', () => { + expect(emailFromDomain('NoReply@Mail.Acme.com')).toBe('mail.acme.com'); + expect(emailFromDomain('bad')).toBeNull(); + expect(emailFromDomain('@acme.com')).toBeNull(); + expect(emailFromDomain('a@')).toBeNull(); + expect(emailFromDomain('a@localhost')).toBeNull(); + }); + + it('validates per label: a leading `-` (a yargs flag), an empty label, a trailing `-` are refused', () => { + // The host is handed to wrangler as a positional; `--config.evil` would be parsed as an + // option and the domain positional would go missing. + expect(emailFromDomain('a@--config.evil')).toBeNull(); + expect(emailFromDomain('a@-foo.evil')).toBeNull(); + expect(emailFromDomain('a@..acme.com')).toBeNull(); + expect(emailFromDomain('a@.acme.com')).toBeNull(); + expect(emailFromDomain('a@foo-.evil')).toBeNull(); + expect(emailFromDomain('a@acme.com.')).toBeNull(); + expect(emailFromDomain('a@my-mail.acme.com')).toBe('my-mail.acme.com'); + }); + + it('reads the address inside a display-name form (the shape void.json accepts)', () => { + expect(emailFromDomain('Acme Support ')).toBe('mail.acme.com'); + expect(emailFromDomain('"Acme, Inc" ')).toBe('acme.com'); + expect(emailFromDomain('Acme ')).toBeNull(); + expect(emailFromDomain('Acme <>')).toBeNull(); + }); +}); + +// ── Prose parsers pinned to the shipped wrangler ───────────────────────────────── + +describe(`wrangler ${WRANGLER_EMAIL_PROSE_VERSION} prose parsers (fixtures)`, () => { + it('routing settings: ready zone', () => { + const f = fixture('routing-settings-void.cloud'); + expect(f.status).toBe(0); + expect(parseRoutingSettings(f.stdout)).toEqual({ + name: 'void.cloud', + enabled: true, + status: 'ready', + tag: '5b66f5cb84fd7dcfdd0170515e622181', + }); + }); + + it('routing settings: unconfigured zone', () => { + const f = fixture('routing-settings-void.app'); + expect(parseRoutingSettings(f.stdout)).toEqual({ + name: 'void.app', + enabled: false, + status: 'unconfigured', + tag: '198763c1ff7c51a4c97e5f6e6ee1d2f4', + }); + }); + + it('routing settings: a subdomain is not a zone (exit 1, message on stderr, stdout empty)', () => { + const f = fixture('routing-settings-mail.void.cloud'); + expect(f.status).toBe(1); + expect(parseRoutingSettings(f.stdout)).toBeNull(); + expect(f.stderr).toContain('Could not find zone for `mail.void.cloud`'); + }); + + it('rules list: no custom rules, catch-all disabled', () => { + const f = fixture('routing-rules-list-void.cloud'); + expect(parseRoutingRules(f.stdout)).toEqual({ + rules: [], + catchAll: { enabled: false, actions: ['drop'] }, + }); + }); + + it('rules list: rules and an enabled catch-all (shape from wrangler src/email-routing/rules/list.ts)', () => { + expect(parseRoutingRules(RULES_WITH_ENTRIES)).toEqual({ + rules: [ + { + id: '1a2b3c', + name: 'support', + enabled: true, + to: ['support@acme.com'], + actions: ['worker:acme-support'], + }, + { + id: '4d5e6f', + name: '(none)', + enabled: true, + to: ['sales@acme.com'], + actions: ['forward:sales@gmail.com'], + }, + ], + catchAll: { enabled: true, actions: ['worker:acme-support'] }, + }); + expect( + parseRoutingRules('\n ⛅️ wrangler 4.131.0\n────────────────────\nNo routing rules found.\n'), + ).toEqual({ + rules: [], + catchAll: null, + }); + }); + + it('sending settings: onboarded domain, and the not-found variant', () => { + expect(parseSendingSettings(fixture('sending-settings-void.cloud').stdout)).toEqual({ + name: 'void.cloud', + enabled: true, + tag: '19af4fdbee924a92916afea0de5a8fdd', + }); + const missing = fixture('sending-settings-nonexistent.void.cloud'); + expect(missing.status).toBe(1); + expect(parseSendingSettings(missing.stdout)).toBeNull(); + expect(missing.stderr).toContain('No sending subdomain found for `nonexistent.void.cloud`'); + }); + + it('whoami --json: scopes and accounts', () => { + const who = parseWhoamiJson(fixture('whoami').stdout)!; + expect(who.loggedIn).toBe(true); + expect(who.tokenPermissions).toContain('email_routing:write'); + expect(who.tokenPermissions).toContain('email_sending:write'); + expect(who.accounts?.map((a) => a.id)).toContain(ACCOUNT); + }); + + it('every parser fails closed on prose it does not recognise', () => { + expect(parseRoutingSettings('Email Routing for x:\n Enabled: maybe\n')).toBeNull(); + expect(parseRoutingRules('Rules:\n - stuff\n')).toBeNull(); + expect(parseSendingSettings('Email Sending enabled for x (enabled: true)')).toBeNull(); + expect(parseWhoamiJson('not json')).toBeNull(); + }); + + it('extracts wrangler API error codes through ANSI', () => { + expect(wranglerApiErrorCode(apiFailure(2040, '/zones/x/email/sending/subdomains').stderr)).toBe( + 2040, + ); + expect(wranglerApiErrorCode('plain failure')).toBeNull(); + }); +}); + +// ── Preflight ──────────────────────────────────────────────────────────────────── + +describe('preflightEmail', () => { + it('first deploy on a subdomain: every row pending, no blocker, exact read-only argv', async () => { + const { inputs, calls, fetchCalls } = transcriptInputs(); + const pre = await preflightEmail(inputs); + + expect(pre.mode).toBe('subdomain'); + expect(pre.apex).toBe('acme.com'); + expect(pre.zone).toEqual({ state: 'found', zoneId: ZONE_ID, type: 'full' }); + expect(pre.session).toMatchObject({ + state: 'ok', + email: 'dev@example.com', + accountName: 'Acme', + }); + expect(pre.apexMx).toEqual({ + state: 'foreign', + exchanges: ['aspmx.l.google.com', 'alt1.aspmx.l.google.com'], + }); + expect(pre.domainMx).toEqual({ state: 'empty', exchanges: [] }); + expect(pre.routing.state).toBe('pending'); + expect(pre.subaddressing.state).toBe('off'); + expect(pre.sending).toEqual({ state: 'pending', dnsConflicts: [] }); + expect(pre.rules).toEqual({ + state: 'pending', + mine: [], + foreign: [], + absent: ['support@mail.acme.com'], + }); + expect(pre.binding).toEqual({ state: 'absent' }); + expect(pre.addresses).toEqual(['support@mail.acme.com']); + expect(pre.derived.refused).toHaveLength(1); + expect(pre.blockers).toEqual([]); + expect(pre.ready).toBe(false); + + expect(calls.map((c) => c.args)).toEqual([ + ['whoami', '--json'], + ['email', 'routing', 'settings', 'mail.acme.com'], + ['email', 'routing', 'settings', 'acme.com'], + ['email', 'routing', 'rules', 'list', 'acme.com'], + ['email', 'sending', 'settings', 'mail.acme.com'], + ]); + expect(calls.every((c) => c.capture)).toBe(true); + expect(fetchCalls.map((c) => `${c.method} ${c.url.slice(c.url.indexOf('/zones'))}`)).toEqual([ + 'GET /zones?name=acme.com', + `GET /zones/${ZONE_ID}/email/routing`, + ]); + }); + + it('appends the deploy runner args (`-c --env=`) to every wrangler call', async () => { + const suffix = ['-c', 'wrangler.jsonc', '--env=']; + const base = transcriptInputs(); + const seen: Array> = []; + const { inputs } = transcriptInputs({ + wranglerArgs: suffix, + // The base table keys on the bare argv; strip the suffix before delegating. + runWrangler: (args, opts) => { + seen.push([...args]); + return base.inputs.runWrangler(args.slice(0, -suffix.length), opts); + }, + }); + await preflightEmail(inputs); + expect(seen).toHaveLength(5); + for (const args of seen) { + expect(args.slice(-suffix.length)).toEqual(suffix); + } + }); + + it('second deploy, everything set up: ready, zero changes', async () => { + const { inputs } = transcriptInputs({ + runnerTable: { + 'email routing rules list acme.com': { + status: 0, + stdout: RULES_WITH_ENTRIES.replace('to:support@acme.com', 'to:support@mail.acme.com'), + stderr: BETA_WARNING, + }, + 'email sending settings mail.acme.com': { + status: 0, + stdout: `Email Sending for mail.acme.com:\n Enabled: true\n Tag: 19af4fdbee924a92916afea0de5a8fdd\n`, + stderr: BETA_WARNING, + }, + }, + fetchTable: { [`GET /zones/${ZONE_ID}/email/routing`]: routingGet(true) }, + dns: fakeDns({ + mx: { + 'acme.com': ['aspmx.l.google.com'], + 'mail.acme.com': ['route1.mx.cloudflare.net', 'route2.mx.cloudflare.net'], + }, + }), + handlers: scan(['support+[ticket].ts']), + existingSendEmail: [{ name: 'SEND_EMAIL' }], + existingAddresses: ['support@mail.acme.com'], + existingVars: { __VOID_EMAIL_FROM: 'noreply@mail.acme.com' }, + }); + const pre = await preflightEmail(inputs); + expect(pre.routing.state).toBe('ready'); + expect(pre.subaddressing.state).toBe('on'); + expect(pre.sending.state).toBe('ready'); + expect(pre.rules).toEqual({ + state: 'ready', + mine: ['support@mail.acme.com'], + foreign: [], + absent: [], + }); + expect(pre.binding.state).toBe('present'); + expect(pre.sender.state).toBe('ready'); + expect(pre.addressesOwnership).toEqual({ state: 'mine', foreign: [] }); + expect(pre.addressesStale).toBe(false); + expect(pre.rulesPending).toEqual([]); + expect(pre.blockers).toEqual([]); + expect(pre.ready).toBe(true); + expect(renderEmailChecklist(pre)).toContain('Nothing to change — email is set up.'); + }); + + /** The "second deploy" account state, with the committed config overridable per case. */ + function readyAccount( + overrides: Partial & { + runnerTable?: Record EmailWranglerRunResult)>; + calls?: Array; + fetchCalls?: Array; + rules?: string; + } = {}, + ) { + const { rules, runnerTable, ...rest } = overrides; + return transcriptInputs({ + runnerTable: { + 'email routing rules list acme.com': { + status: 0, + stdout: + rules ?? RULES_WITH_ENTRIES.replace('to:support@acme.com', 'to:support@mail.acme.com'), + stderr: BETA_WARNING, + }, + 'email sending settings mail.acme.com': { + status: 0, + stdout: `Email Sending for mail.acme.com:\n Enabled: true\n Tag: 19af4fdbee924a92916afea0de5a8fdd\n`, + stderr: BETA_WARNING, + }, + ...runnerTable, + }, + fetchTable: { [`GET /zones/${ZONE_ID}/email/routing`]: routingGet(true) }, + dns: fakeDns({ + mx: { + 'acme.com': ['aspmx.l.google.com'], + 'mail.acme.com': ['route1.mx.cloudflare.net', 'route2.mx.cloudflare.net'], + }, + }), + handlers: scan(['support+[ticket].ts']), + existingSendEmail: [{ name: 'SEND_EMAIL' }], + existingAddresses: ['support@mail.acme.com'], + existingVars: { __VOID_EMAIL_FROM: 'noreply@mail.acme.com' }, + ...rest, + }); + } + + it('an API-token session (CLOUDFLARE_API_TOKEN) is not gated on `tokenPermissions`: reads run, ready reads ready', async () => { + // wrangler fills `tokenPermissions` from the stored login file only, so under an API token + // it is absent (or describes a stale login file, never the token). The gate is the reads. + const calls: Array = []; + const fetchCalls: Array = []; + const { inputs } = readyAccount({ + calls, + fetchCalls, + runnerTable: { + 'whoami --json': { + status: 0, + stdout: JSON.stringify({ + loggedIn: true, + authType: 'User API Token', + email: 'dev@example.com', + accounts: [{ id: ACCOUNT, name: 'Acme' }], + }), + stderr: '', + }, + }, + }); + const pre = await preflightEmail(inputs); + expect(pre.session).toMatchObject({ + state: 'ok', + authType: 'User API Token', + missingScopes: [], + }); + expect(calls.map((c) => c.args.join(' '))).toEqual([ + 'whoami --json', + 'email routing settings mail.acme.com', + 'email routing settings acme.com', + 'email routing rules list acme.com', + 'email sending settings mail.acme.com', + ]); + expect(fetchCalls).toHaveLength(2); + expect(pre.blockers).toEqual([]); + expect(pre.ready).toBe(true); + expect(renderEmailChecklist(pre)).toContain('session dev@example.com (User API Token)'); + }); + + it('an API-token session whose read is refused fails closed with the token-permission hint', async () => { + const { inputs } = transcriptInputs({ + runnerTable: { + 'whoami --json': { + status: 0, + stdout: JSON.stringify({ loggedIn: true, authType: 'Account API Token' }), + stderr: '', + }, + 'email routing settings mail.acme.com': apiFailure(10000, '/zones'), + }, + }); + const pre = await preflightEmail(inputs); + expect(pre.session.state).toBe('ok'); + expect(pre.blockers).toEqual([ + { + scope: 'all', + message: expect.stringContaining( + 'an API token needs Email Routing Edit + Email Sending Edit', + ), + }, + ]); + expect(pre.ready).toBe(false); + }); + + it('a Global API Key session is refused by name — there is no bearer to borrow', async () => { + const calls: Array = []; + const { inputs } = transcriptInputs({ + calls, + runnerTable: { + 'whoami --json': { + status: 0, + stdout: JSON.stringify({ loggedIn: true, authType: 'Global API Key' }), + stderr: '', + }, + }, + }); + const pre = await preflightEmail(inputs); + expect(pre.session.state).toBe('api-key'); + expect(pre.blockers[0]?.message).toContain('CLOUDFLARE_API_TOKEN'); + expect(calls.map((c) => c.args.join(' '))).toEqual(['whoami --json']); + }); + + it('a committed config whose rules wrangler has not created yet still reads ready (rules are `wrangler deploy` work)', async () => { + // Right after `void email setup`, before the first deploy: the array is committed, the + // rule is absent. Apply has nothing to do; the deploy creates the rule. + const { inputs } = readyAccount({ rules: fixture('routing-rules-list-void.cloud').stdout }); + const pre = await preflightEmail(inputs); + expect(pre.rules.state).toBe('pending'); + expect(pre.rules.absent).toEqual(['support@mail.acme.com']); + expect(pre.rulesPending).toEqual(['support@mail.acme.com']); + expect(pre.addressesStale).toBe(false); + expect(pre.ready).toBe(true); + const text = renderEmailChecklist(pre); + expect(text).not.toContain('+ wrangler.jsonc'); + expect(text).toContain('+ routing rule (created on deploy)'); + }); + + it('flags a committed `addresses` array that is not the pruned set as stale, without losing ready', async () => { + // A rule Void created was later re-pointed at a forward: the entry is pruned, and the + // committed array still listing it would reach wrangler's plan as a `conflict`. + const forwarded = RULES_WITH_ENTRIES.replace( + 'to:support@acme.com', + 'to:support@mail.acme.com', + ).replace('to:sales@acme.com', 'to:sales@mail.acme.com'); + const retarget = await preflightEmail( + readyAccount({ + rules: forwarded, + handlers: scan(['support.ts', 'sales.ts']), + existingAddresses: ['support@mail.acme.com', 'sales@mail.acme.com'], + }).inputs, + ); + expect(retarget.ready).toBe(true); + expect(retarget.addresses).toEqual(['support@mail.acme.com']); + expect(retarget.addressesStale).toBe(true); + + // Worker rename: every rule now belongs to the old name, so nothing is Void's to list. + const renamed = await preflightEmail( + readyAccount({ workerName: 'acme-mail', existingAddresses: ['support@mail.acme.com'] }) + .inputs, + ); + expect(renamed.ready).toBe(true); + expect(renamed.addresses).toEqual([]); + expect(renamed.addressesStale).toBe(true); + expect(renderEmailChecklist(renamed)).toContain('addresses: (removed)'); + + // A hand-emptied array with a derived address, and an absent array, are stale too. + expect( + (await preflightEmail(readyAccount({ existingAddresses: [] }).inputs)).addressesStale, + ).toBe(true); + expect( + (await preflightEmail(readyAccount({ existingAddresses: undefined }).inputs)).addressesStale, + ).toBe(true); + // Equal to the pruned set (order-insensitive): not stale. + expect( + (await preflightEmail(readyAccount({ existingAddresses: ['SUPPORT@mail.acme.com'] }).inputs)) + .addressesStale, + ).toBe(false); + }); + + it('reads the sender var as ready / stale / absent, as a config row that does not gate ready', async () => { + const stale = await preflightEmail( + readyAccount({ existingVars: { __VOID_EMAIL_FROM: 'hello@mail.acme.com' } }).inputs, + ); + expect(stale.sender.state).toBe('stale'); + expect(stale.ready).toBe(true); + expect(renderEmailChecklist(stale)).toContain( + ' + wrangler.jsonc vars.__VOID_EMAIL_FROM: "noreply@mail.acme.com"', + ); + const absent = await preflightEmail(readyAccount({ existingVars: undefined }).inputs); + expect(absent.sender.state).toBe('absent'); + expect(absent.ready).toBe(true); + expect(renderEmailChecklist(absent)).toContain( + ' sender noreply@mail.acme.com (vars.__VOID_EMAIL_FROM absent)', + ); + }); + + it('turns a writer refusal the caller precomputed into an `all` blocker, so apply makes zero calls', async () => { + const calls: Array = []; + const fetchCalls: Array = []; + const { inputs } = transcriptInputs({ + calls, + fetchCalls, + configConflicts: findEmailConfigConflicts({ vars: { SEND_EMAIL: 'x' } }), + }); + const pre = await preflightEmail(inputs); + expect(pre.blockers).toEqual([ + { + scope: 'all', + message: + 'wrangler.jsonc already uses SEND_EMAIL as a Environment Variable binding; rename it (Void writes SEND_EMAIL into send_email)', + }, + ]); + calls.length = 0; + fetchCalls.length = 0; + const out = await applyEmailSetup(pre, inputs); + expect(calls).toEqual([]); + expect(fetchCalls).toEqual([]); + expect(out.patch).toEqual({}); + }); + + it('refuses the apex when foreign MX already receives mail there', async () => { + const { inputs } = transcriptInputs({ + from: 'noreply@acme.com', + runnerTable: { + 'email routing settings acme.com': routingSettings( + 'acme.com', + false, + 'unconfigured', + ZONE_ID, + ), + 'email sending settings acme.com': sendingNotFound('acme.com'), + }, + }); + const pre = await preflightEmail(inputs); + expect(pre.mode).toBe('apex'); + expect(pre.blockers).toEqual([ + { + scope: 'all', + message: + 'acme.com already receives mail (aspmx.l.google.com, alt1.aspmx.l.google.com). Void never enables routing over live mail. Use something@mail.acme.com.', + }, + ]); + expect(pre.ready).toBe(false); + }); + + it('allows the apex with an empty MX (decision 10), catch-all included', async () => { + const { inputs } = transcriptInputs({ + from: 'noreply@acme.com', + runnerTable: { + 'email routing settings acme.com': routingSettings( + 'acme.com', + false, + 'unconfigured', + ZONE_ID, + ), + 'email sending settings acme.com': sendingNotFound('acme.com'), + }, + dns: fakeDns({}), + }); + const pre = await preflightEmail(inputs); + expect(pre.blockers).toEqual([]); + expect(pre.addresses).toEqual(['support@acme.com', '*@acme.com']); + expect(pre.derived.refused).toEqual([]); + expect(pre.rules.absent).toEqual(['support@acme.com', '*@acme.com']); + }); + + it('blocks on a session without the email scopes (`void cloudflare logout`, then `login`)', async () => { + const who = JSON.parse(fixture('whoami').stdout) as { tokenPermissions: Array }; + who.tokenPermissions = who.tokenPermissions.filter((s) => !s.startsWith('email_')); + const calls: Array = []; + const { inputs } = transcriptInputs({ + calls, + runnerTable: { 'whoami --json': { status: 0, stdout: JSON.stringify(who), stderr: '' } }, + }); + const pre = await preflightEmail(inputs); + expect(pre.session.state).toBe('missing-scopes'); + expect(pre.session.missingScopes).toEqual(['email_routing:write', 'email_sending:write']); + // `void cloudflare login` re-uses a session `whoami` reads cleanly (it never re-consents + // one), so a readable-but-insufficient session must be signed out first. + expect(pre.blockers[0]?.message).toBe( + 'the Cloudflare session lacks email_routing:write + email_sending:write — run `void cloudflare logout`, then `void cloudflare login` (one browser Allow)', + ); + // No other read runs on a session that cannot do the work. + expect(calls.map((c) => c.args.join(' '))).toEqual(['whoami --json']); + }); + + it('every session blocker routes Cloudflare login through Void, never through wrangler', async () => { + // AGENTS.md: user-facing output never asks for a wrangler command. These rows print raw + // from `void email setup` / `status`, and through `formatCloudflareUserMessage` on the + // deploy path, where a `wrangler …` command would render as "the underlying Cloudflare + // operation" — no guidance at all. + const who = JSON.parse(fixture('whoami').stdout) as { tokenPermissions: Array }; + who.tokenPermissions = who.tokenPermissions.filter((s) => !s.startsWith('email_')); + const sessions: Array<{ + state: EmailPreflight['session']['state']; + whoami: EmailWranglerRunResult; + }> = [ + { state: 'unknown', whoami: { status: 1, stdout: '', stderr: '✘ [ERROR] boom\n' } }, + { state: 'unknown', whoami: { status: 0, stdout: 'not json', stderr: '' } }, + { + state: 'not-logged-in', + whoami: { status: 0, stdout: JSON.stringify({ loggedIn: false }), stderr: '' }, + }, + { + state: 'api-key', + whoami: { + status: 0, + stdout: JSON.stringify({ loggedIn: true, authType: 'Global API Key' }), + stderr: '', + }, + }, + { state: 'missing-scopes', whoami: { status: 0, stdout: JSON.stringify(who), stderr: '' } }, + ]; + for (const session of sessions) { + const { inputs } = transcriptInputs({ runnerTable: { 'whoami --json': session.whoami } }); + const pre = await preflightEmail(inputs); + expect(pre.session.state).toBe(session.state); + expect(pre.blockers.map((b) => b.scope)).toEqual(['all']); + const message = pre.blockers[0]!.message; + expect(message).toContain('`void cloudflare login`'); + expect(message).not.toMatch(/wrangler/i); + // The checklist carries the row verbatim on both printing paths. + expect(renderEmailChecklist(pre)).toContain(` ✘ ${message}`); + } + // A Global API Key wins over both an OAuth session and CLOUDFLARE_API_TOKEN in wrangler's + // env precedence, so neither alternative works until the key is unset. + const apiKey = await preflightEmail( + transcriptInputs({ runnerTable: { 'whoami --json': sessions[3]!.whoami } }).inputs, + ); + expect(apiKey.blockers[0]?.message).toBe( + 'the Cloudflare session is a Global API Key (CLOUDFLARE_API_KEY), which has no bearer to borrow for the subaddressing calls — unset CLOUDFLARE_API_KEY, then run `void cloudflare login` or set CLOUDFLARE_API_TOKEN to a token with Email Routing Edit + Email Sending Edit', + ); + const notLoggedIn = await preflightEmail( + transcriptInputs({ runnerTable: { 'whoami --json': sessions[2]!.whoami } }).inputs, + ); + expect(notLoggedIn.blockers[0]?.message).toBe( + 'not logged in to Cloudflare — run `void cloudflare login`', + ); + const unreadable = await preflightEmail( + transcriptInputs({ runnerTable: { 'whoami --json': sessions[0]!.whoami } }).inputs, + ); + expect(unreadable.blockers[0]?.message).toBe( + 'could not read the Cloudflare session — run `void cloudflare login`', + ); + }); + + it('blocks when not logged in', async () => { + const { inputs } = transcriptInputs({ + runnerTable: { + 'whoami --json': { status: 0, stdout: JSON.stringify({ loggedIn: false }), stderr: '' }, + }, + }); + const pre = await preflightEmail(inputs); + expect(pre.session.state).toBe('not-logged-in'); + expect(pre.blockers.map((b) => b.scope)).toEqual(['all']); + }); + + it('blocks on routing status unlocked / misconfigured — never re-locks', async () => { + for (const status of ['unlocked', 'misconfigured', 'misconfigured_locked']) { + const { inputs } = transcriptInputs({ + runnerTable: { + 'email routing settings acme.com': routingSettings('acme.com', true, status, ZONE_ID), + }, + }); + const pre = await preflightEmail(inputs); + expect( + pre.blockers.some( + (b) => b.message.includes(status) && b.message.includes('never re-locks'), + ), + ).toBe(true); + } + }); + + it('refuses sending onboarding when _dmarc / cf-bounce TXT already exist (sending-scoped)', async () => { + const { inputs } = transcriptInputs({ + dns: fakeDns({ + mx: { 'acme.com': ['aspmx.l.google.com'] }, + txt: { + '_dmarc.mail.acme.com': ['v=DMARC1; p=none'], + 'cf-bounce.mail.acme.com': ['v=spf1'], + }, + }), + }); + const pre = await preflightEmail(inputs); + expect(pre.sending.dnsConflicts).toEqual(['_dmarc.mail.acme.com', 'cf-bounce.mail.acme.com']); + expect(pre.blockers).toEqual([ + { + scope: 'sending', + message: expect.stringContaining( + '_dmarc.mail.acme.com, cf-bounce.mail.acme.com already exists', + ), + }, + ]); + // Routing is still on the table — the sending blocker is not an `all` blocker. + expect(pre.addresses).toEqual(['support@mail.acme.com']); + }); + + it('prunes an address already routed elsewhere and reports the owner (decision 12)', async () => { + const { inputs } = transcriptInputs({ + handlers: scan(['support.ts', 'sales.ts']), + runnerTable: { + 'email routing rules list acme.com': { + status: 0, + stdout: RULES_WITH_ENTRIES.replace( + 'to:support@acme.com', + 'to:support@mail.acme.com', + ).replace('to:sales@acme.com', 'to:sales@mail.acme.com'), + stderr: BETA_WARNING, + }, + }, + }); + const pre = await preflightEmail(inputs); + expect(pre.rules.mine).toEqual(['support@mail.acme.com']); + expect(pre.rules.foreign).toEqual([ + { address: 'sales@mail.acme.com', owner: 'forward:sales@gmail.com' }, + ]); + expect(pre.addresses).toEqual(['support@mail.acme.com']); + expect(pre.blockers).toEqual([]); + expect(renderEmailChecklist(pre)).toContain( + ` ! ${'sales@mail.acme.com'.padEnd(44)}already routed to forward:sales@gmail.com; left alone, not in addresses`, + ); + }); + + it('treats a disabled custom rule as absent, like the disabled catch-all', async () => { + // A rule to this worker that Cloudflare has switched off routes no mail: the row is + // `absent`, so the deploy's `addresses` entry stays and wrangler's plan turns it back on. + const disabled = RULES_WITH_ENTRIES.replace( + 'to:support@acme.com', + 'to:support@mail.acme.com', + ).replace( + 'Enabled: true\n Matchers: to:support@mail.acme.com', + 'Enabled: false\n Matchers: to:support@mail.acme.com', + ); + expect(disabled).toContain('Enabled: false'); + const pre = await preflightEmail(readyAccount({ rules: disabled }).inputs); + expect(pre.rules.mine).toEqual([]); + expect(pre.rules.absent).toEqual(['support@mail.acme.com']); + expect(pre.rules.state).toBe('pending'); + expect(pre.rulesPending).toEqual(['support@mail.acme.com']); + expect(pre.addresses).toEqual(['support@mail.acme.com']); + const text = renderEmailChecklist(pre); + expect(text).toContain('support@mail.acme.com → acme-support (absent)'); + expect(text).toContain('+ routing rule (created on deploy)'); + + // A disabled rule is skipped, not matched: an enabled sibling on the same address still + // decides the row — here a forward that owns the address, so it is pruned (decision 12). + const sibling = disabled.replace( + '\n\nCatch-all rule:', + ` +Rule: 7g8h9i + Name: (none) + Enabled: true + Matchers: to:support@mail.acme.com + Actions: forward:support@gmail.com + Priority: 2 + + +Catch-all rule:`, + ); + expect(sibling).toContain('Rule: 7g8h9i'); + const preSibling = await preflightEmail(readyAccount({ rules: sibling }).inputs); + expect(preSibling.rules.absent).toEqual([]); + expect(preSibling.rules.foreign).toEqual([ + { address: 'support@mail.acme.com', owner: 'forward:support@gmail.com' }, + ]); + + // A disabled FOREIGN rule is still somebody else's matcher: the row stays `foreign` + // and the address is pruned, exactly as when the rule is enabled — Void does not + // take over another owner's rule because it is switched off. + const disabledForeign = disabled.replace( + 'Actions: worker:acme-support', + 'Actions: forward:support@gmail.com', + ); + expect(disabledForeign).toContain('Actions: forward:support@gmail.com'); + const preForeign = await preflightEmail(readyAccount({ rules: disabledForeign }).inputs); + expect(preForeign.rules.absent).toEqual([]); + expect(preForeign.rules.mine).toEqual([]); + expect(preForeign.rules.foreign).toEqual([ + { address: 'support@mail.acme.com', owner: 'forward:support@gmail.com' }, + ]); + expect(preForeign.addresses).toEqual([]); + }); + + it('treats a catch-all owned by another worker as foreign, by this worker as mine', async () => { + const base = { + from: 'noreply@acme.com', + runnerTable: { + 'email sending settings acme.com': sendingNotFound('acme.com'), + }, + dns: fakeDns({ mx: { 'acme.com': ['route1.mx.cloudflare.net'] } }), + handlers: scan([], '_default.ts'), + }; + const foreign = transcriptInputs({ + ...base, + runnerTable: { + ...base.runnerTable, + 'email routing rules list acme.com': { + status: 0, + stdout: RULES_WITH_ENTRIES.replace( + 'worker:acme-support\n (use', + 'worker:other-worker\n (use', + ), + stderr: BETA_WARNING, + }, + }, + }); + const preForeign = await preflightEmail(foreign.inputs); + expect(preForeign.rules.foreign).toEqual([ + { address: '*@acme.com', owner: 'worker:other-worker' }, + ]); + expect(preForeign.addresses).toEqual([]); + + const mine = transcriptInputs({ + ...base, + runnerTable: { + ...base.runnerTable, + 'email routing rules list acme.com': { + status: 0, + stdout: RULES_WITH_ENTRIES, + stderr: BETA_WARNING, + }, + }, + }); + const preMine = await preflightEmail(mine.inputs); + expect(preMine.rules.mine).toEqual(['*@acme.com']); + }); + + it('refuses a hand-written `addresses` array holding entries Void did not derive (decision 13)', async () => { + const { inputs } = transcriptInputs({ + existingAddresses: ['support@mail.acme.com', 'billing@mail.acme.com'], + }); + const pre = await preflightEmail(inputs); + expect(pre.addressesOwnership).toEqual({ + state: 'foreign', + foreign: ['billing@mail.acme.com'], + }); + expect(pre.blockers).toEqual([ + { scope: 'all', message: expect.stringContaining('billing@mail.acme.com') }, + ]); + expect(pre.blockers[0]?.message).toContain('skipping email setup this deploy'); + // The exact edit is named, so the developer does not have to guess which entries. + expect(pre.blockers[0]?.message).toContain('remove "billing@mail.acme.com" from addresses'); + }); + + it('refuses an existing send_email binding not named SEND_EMAIL, with the rename', async () => { + const { inputs } = transcriptInputs({ existingSendEmail: [{ name: 'EMAIL' }] }); + const pre = await preflightEmail(inputs); + expect(pre.binding).toEqual({ state: 'foreign', foreignName: 'EMAIL' }); + expect(pre.blockers[0]?.message).toBe( + 'wrangler.jsonc already has a send_email binding named "EMAIL"; rename it to SEND_EMAIL (a second binding would be silent)', + ); + }); + + it('zone not on Cloudflare in this account: blocker, no rules/sending reads', async () => { + const calls: Array = []; + const { inputs } = transcriptInputs({ + calls, + runnerTable: { 'email routing settings acme.com': zoneNotFound('acme.com') }, + fetchTable: { + 'GET /zones?name=mail.acme.com': () => jsonResponse(200, { success: true, result: [] }), + 'GET /zones?name=acme.com': () => jsonResponse(200, { success: true, result: [] }), + }, + }); + const pre = await preflightEmail(inputs); + expect(pre.zone.state).toBe('not-found'); + expect(pre.blockers).toEqual([ + { scope: 'all', message: expect.stringContaining('no zone for mail.acme.com') }, + ]); + expect(calls.map((c) => c.args.join(' '))).toEqual([ + 'whoami --json', + 'email routing settings mail.acme.com', + 'email routing settings acme.com', + ]); + }); + + it('zone in another account is told apart from not-on-Cloudflare via GET /zones?name= without account.id', async () => { + const fetchCalls: Array = []; + const { inputs } = transcriptInputs({ + fetchCalls, + runnerTable: { 'email routing settings acme.com': zoneNotFound('acme.com') }, + fetchTable: { + 'GET /zones?name=mail.acme.com': () => jsonResponse(200, { success: true, result: [] }), + 'GET /zones?name=acme.com': zonesOk('acme.com', { + account: '00000000000000000000000000000000', + }), + }, + }); + const pre = await preflightEmail(inputs); + expect(pre.zone.state).toBe('other-account'); + expect(pre.blockers[0]?.message).toContain('different Cloudflare account'); + for (const call of fetchCalls) { + expect(call.url).not.toContain('account.id'); + } + }); + + it('refuses a partial (CNAME-setup) zone', async () => { + const { inputs } = transcriptInputs({ + fetchTable: { 'GET /zones?name=acme.com': zonesOk('acme.com', { type: 'partial' }) }, + }); + const pre = await preflightEmail(inputs); + expect(pre.zone).toEqual({ state: 'partial', zoneId: ZONE_ID, type: 'partial' }); + expect(pre.blockers[0]?.message).toContain('partial zone'); + }); + + it('a parser miss fails closed: unknown row + `run void email setup`', async () => { + const { inputs } = transcriptInputs({ + runnerTable: { + 'email routing rules list acme.com': { + status: 0, + stdout: 'Rules:\n totally new prose\n', + stderr: '', + }, + }, + }); + const pre = await preflightEmail(inputs); + expect(pre.rules.state).toBe('unknown'); + expect(pre.blockers).toEqual([ + { + scope: 'all', + message: + 'could not read Email Routing rules — unknown — run `void email setup --platform cloudflare`', + }, + ]); + }); + + it('works without a bearer: subaddressing unknown, zone from the settings Tag, no fetch', async () => { + const fetchCalls: Array = []; + const { inputs } = transcriptInputs({ fetchCalls, getSessionBearer: async () => null }); + const pre = await preflightEmail(inputs); + expect(fetchCalls).toEqual([]); + expect(pre.zone).toEqual({ state: 'found', zoneId: ZONE_ID }); + expect(pre.subaddressing.state).toBe('unknown'); + expect(pre.blockers).toEqual([]); + }); + + it('a refused handler or a DMARC conflict does not keep a set-up deploy from reading ready', async () => { + const { inputs } = transcriptInputs({ + runnerTable: { + 'email routing rules list acme.com': { + status: 0, + stdout: RULES_WITH_ENTRIES.replace('to:support@acme.com', 'to:support@mail.acme.com'), + stderr: BETA_WARNING, + }, + }, + fetchTable: { [`GET /zones/${ZONE_ID}/email/routing`]: routingGet(true) }, + dns: fakeDns({ + mx: { 'acme.com': ['aspmx.l.google.com'], 'mail.acme.com': ['route1.mx.cloudflare.net'] }, + txt: { '_dmarc.mail.acme.com': ['v=DMARC1; p=none'] }, + }), + // `_default.ts` stays refused on the subdomain; sending stays blocked by the TXT. + existingSendEmail: [{ name: 'SEND_EMAIL' }], + existingAddresses: ['support@mail.acme.com'], + }); + const pre = await preflightEmail(inputs); + expect(pre.derived.refused).toHaveLength(1); + expect(pre.blockers.map((b) => b.scope)).toEqual(['sending']); + expect(pre.ready).toBe(true); + }); + + it('remembers a refused sending onboarding by the committed binding: pending + binding present reads ready, retry on offer', async () => { + // Workers Free after `void email setup`: routing + subaddressing on, the binding, + // `addresses` and the sender var committed, and `sending settings` still answering + // not-found — the same answer a Paid account that never onboarded gives. The binding + // is written only by a run that already attempted `sending enable`, so it is the + // memory of the refusal: settled, never asked again by the deploy, and the retry is + // what `void email setup` offers. + const notOnboarded = { + 'email sending settings mail.acme.com': sendingNotFound('mail.acme.com'), + }; + const remembered = await preflightEmail(readyAccount({ runnerTable: notOnboarded }).inputs); + expect(remembered.sending.state).toBe('pending'); + expect(remembered.binding.state).toBe('present'); + expect(remembered.blockers).toEqual([]); + expect(remembered.ready).toBe(true); + expect(remembered.sendingRetryable).toBe(true); + const text = renderEmailChecklist(remembered); + expect(text).toContain( + ' sending mail.acme.com not onboarded — verified destinations only; after upgrading to Workers Paid run void email setup --platform cloudflare', + ); + // The onboarding stays listed as the one account change — the retry prompt's row. + expect(text).toContain(' + onboard mail.acme.com for Email Sending'); + expect(text).not.toContain('Nothing to change'); + + // No binding: nothing has attempted the onboarding yet, so the row is plain work. + const untried = await preflightEmail( + readyAccount({ runnerTable: notOnboarded, existingSendEmail: undefined }).inputs, + ); + expect(untried.binding.state).toBe('absent'); + expect(untried.ready).toBe(false); + expect(untried.sendingRetryable).toBe(true); + expect(renderEmailChecklist(untried)).toContain(' sending mail.acme.com not onboarded\n'); + + // A sending-scoped blocker settles the row on its own and leaves nothing to retry: + // the ✘ row carries the reason, the sending row keeps the bare word. + const conflict = await preflightEmail( + readyAccount({ + runnerTable: notOnboarded, + dns: fakeDns({ + mx: { + 'acme.com': ['aspmx.l.google.com'], + 'mail.acme.com': ['route1.mx.cloudflare.net', 'route2.mx.cloudflare.net'], + }, + txt: { '_dmarc.mail.acme.com': ['v=DMARC1; p=none'] }, + }), + }).inputs, + ); + expect(conflict.blockers.map((b) => b.scope)).toEqual(['sending']); + expect(conflict.ready).toBe(true); + expect(conflict.sendingRetryable).toBe(false); + expect(renderEmailChecklist(conflict)).toContain( + ' sending mail.acme.com not onboarded\n', + ); + + // Onboarded: nothing to retry. + const onboarded = await preflightEmail(readyAccount().inputs); + expect(onboarded.sending.state).toBe('ready'); + expect(onboarded.sendingRetryable).toBe(false); + }); + + it('an MX lookup that fails outright is unknown, and blocks (fail closed)', async () => { + const { inputs } = transcriptInputs({ + dns: fakeDns({ + mx: { 'acme.com': ['aspmx.l.google.com'] }, + mxError: { 'mail.acme.com': 'ETIMEOUT' }, + }), + }); + const pre = await preflightEmail(inputs); + expect(pre.domainMx.state).toBe('unknown'); + expect(pre.blockers).toEqual([ + { + scope: 'all', + message: + 'could not read the MX records of mail.acme.com — unknown — run `void email setup --platform cloudflare`', + }, + ]); + }); + + it('rejects an email.from that is not an address', async () => { + const { inputs } = transcriptInputs({ from: 'nope' }); + await expect(preflightEmail(inputs)).rejects.toThrow('email.from must be an address'); + }); +}); + +// ── Checklist ──────────────────────────────────────────────────────────────────── + +describe('renderEmailChecklist', () => { + it('renders the 0078 transcript block for the first subdomain deploy', async () => { + const who = JSON.parse(fixture('whoami').stdout) as { + email: string; + accounts: Array<{ id: string; name: string }>; + }; + who.email = 'dev@acme.com'; + who.accounts = [{ id: ACCOUNT, name: 'Acme' }]; + const { inputs } = transcriptInputs({ + runnerTable: { 'whoami --json': { status: 0, stdout: JSON.stringify(who), stderr: '' } }, + }); + const pre = await preflightEmail(inputs); + expect(renderEmailChecklist(pre)).toBe( + [ + ' Email in use email/support+[ticket].ts · email/_default.ts · sendEmail() in 2 files', + ' domain mail.acme.com (void.json email.from)', + ' zone acme.com account Acme (f721b8e5…) · session dev@acme.com', + ' apex MX aspmx.l.google.com left alone — mail lives on the subdomain', + ' routing mail.acme.com not enabled · subaddressing off', + ' sending mail.acme.com not onboarded', + ' rules support@mail.acme.com → acme-support (absent)', + ' binding SEND_EMAIL (absent)', + ' sender noreply@mail.acme.com (vars.__VOID_EMAIL_FROM absent)', + '', + ' This changes YOUR Cloudflare account. acme.com itself is not touched.', + ' + enable Email Routing on mail.acme.com Cloudflare writes and locks 3 MX + 1 SPF record there', + ' + turn on subaddressing for acme.com support+anything@ reaches support@', + ' + onboard mail.acme.com for Email Sending MX/SPF/DKIM on cf-bounce.mail.acme.com, _dmarc.mail.acme.com (p=reject)', + ' + wrangler.jsonc send_email: [{ name: "SEND_EMAIL" }], addresses: ["support@mail.acme.com"], vars.__VOID_EMAIL_FROM: "noreply@mail.acme.com"', + ' + routing rule (created on deploy) support@mail.acme.com → acme-support', + ' ! email/_default.ts a catch-all exists only on an apex; other @mail.acme.com mail bounces', + ].join('\n'), + ); + }); + + it('renders blockers as ✘ rows so the developer sees why nothing is asked', async () => { + const { inputs } = transcriptInputs({ existingSendEmail: [{ name: 'EMAIL' }] }); + const pre = await preflightEmail(inputs); + const text = renderEmailChecklist(pre); + expect(text).toContain(' binding SEND_EMAIL (found EMAIL instead)'); + expect(text).toContain(' ✘ wrangler.jsonc already has a send_email binding named "EMAIL"'); + }); +}); + +// ── Apply ──────────────────────────────────────────────────────────────────────── + +const ROUTING_DNS_POST = `POST /zones/${ZONE_ID}/email/routing/dns`; +const ROUTING_PATCH = `PATCH /zones/${ZONE_ID}/email/routing`; + +function sendingEnabled(name: string): EmailWranglerRunResult { + return { + status: 0, + stdout: `Email Sending enabled for ${name} (enabled: true)\n`, + stderr: BETA_WARNING, + }; +} + +describe('applyEmailSetup', () => { + it('happy subdomain path: exact argv + exact REST calls, bearer in Authorization and nowhere else', async () => { + const calls: Array = []; + const fetchCalls: Array = []; + const { inputs } = transcriptInputs({ + calls, + fetchCalls, + runnerTable: { 'email sending enable mail.acme.com': sendingEnabled('mail.acme.com') }, + fetchTable: { + [ROUTING_DNS_POST]: () => + jsonResponse(200, { success: true, result: { name: 'mail.acme.com' } }), + [ROUTING_PATCH]: () => + jsonResponse(200, { success: true, result: { support_subaddress: true } }), + }, + }); + const pre = await preflightEmail(inputs); + calls.length = 0; + fetchCalls.length = 0; + + const out = await applyEmailSetup(pre, inputs); + expect(out.routingReady).toBe(true); + expect(out.subaddressingReady).toBe(true); + expect(out.sendingReady).toBe(true); + expect(out.addressesWithheld).toBe(false); + expect(out.inboundReady).toBe(true); + expect(out.patch).toEqual({ + sendEmail: { name: 'SEND_EMAIL' }, + vars: { __VOID_EMAIL_FROM: 'noreply@mail.acme.com' }, + addresses: ['support@mail.acme.com'], + }); + expect(out.lines).toEqual([ + ' ✔ routing mail.acme.com enabled', + ' ✔ routing subaddressing on for acme.com', + ' ✔ sending mail.acme.com onboarded', + ' ✔ config wrangler.jsonc updated', + ]); + + expect(calls.map((c) => c.args)).toEqual([['email', 'sending', 'enable', 'mail.acme.com']]); + expect(fetchCalls).toEqual([ + { + url: `https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/email/routing/dns`, + method: 'POST', + headers: { Authorization: `Bearer ${BEARER}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'mail.acme.com' }), + }, + { + url: `https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/email/routing`, + method: 'PATCH', + headers: { Authorization: `Bearer ${BEARER}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ support_subaddress: true }), + }, + ]); + for (const call of fetchCalls) { + expect(call.url).not.toContain(BEARER); + expect(call.body).not.toContain(BEARER); + } + }); + + it('apex path: `wrangler email routing enable `, then the subaddressing PATCH through the bearer', async () => { + const calls: Array = []; + const fetchCalls: Array = []; + const { inputs } = transcriptInputs({ + calls, + fetchCalls, + from: 'noreply@acme.com', + dns: fakeDns({}), + runnerTable: { + 'email routing settings acme.com': routingSettings( + 'acme.com', + false, + 'unconfigured', + ZONE_ID, + ), + 'email sending settings acme.com': sendingNotFound('acme.com'), + 'email routing enable acme.com': { + status: 0, + stdout: 'Email Routing enabled for acme.com (status: ready)\n', + stderr: BETA_WARNING, + }, + 'email sending enable acme.com': sendingEnabled('acme.com'), + }, + fetchTable: { + [ROUTING_PATCH]: () => + jsonResponse(200, { success: true, result: { support_subaddress: true } }), + }, + }); + const pre = await preflightEmail(inputs); + calls.length = 0; + fetchCalls.length = 0; + const out = await applyEmailSetup(pre, inputs); + expect(calls.map((c) => c.args)).toEqual([ + ['email', 'routing', 'enable', 'acme.com'], + ['email', 'sending', 'enable', 'acme.com'], + ]); + expect(fetchCalls.map((c) => `${c.method} ${c.url.slice(c.url.indexOf('/zones'))}`)).toEqual([ + ROUTING_PATCH, + ]); + expect(out.patch.addresses).toEqual(['support@acme.com', '*@acme.com']); + expect(out.lines[0]).toBe(' ✔ routing acme.com enabled'); + }); + + it('routing enabled but the subaddressing PATCH refused → routingReady, NOT inboundReady; addresses withheld', async () => { + // The one partial state where `routingReady` is true and inbound is still not set up: + // the PATCH is a bearer call, so its refusal withholds `addresses` (decision 11) — no + // rule is created by the next deploy — and `+tag` mail matches no rule until the flag + // is on. Both callers must read `inboundReady`, not `routingReady`. + const fetchCalls: Array = []; + const { inputs } = transcriptInputs({ + fetchCalls, + runnerTable: { 'email sending enable mail.acme.com': sendingEnabled('mail.acme.com') }, + fetchTable: { + [ROUTING_DNS_POST]: () => + jsonResponse(200, { success: true, result: { name: 'mail.acme.com' } }), + [ROUTING_PATCH]: () => + jsonResponse(403, { + success: false, + errors: [{ code: 10000, message: 'Authentication error' }], + }), + }, + }); + const pre = await preflightEmail(inputs); + fetchCalls.length = 0; + const out = await applyEmailSetup(pre, inputs); + expect(fetchCalls.map((c) => c.method)).toEqual(['POST', 'PATCH']); + expect(out.routingReady).toBe(true); + expect(out.subaddressingReady).toBe(false); + expect(out.sendingReady).toBe(true); + expect(out.addressesWithheld).toBe(true); + expect(out.inboundReady).toBe(false); + expect(out.patch).toEqual({ + sendEmail: { name: 'SEND_EMAIL' }, + vars: { __VOID_EMAIL_FROM: 'noreply@mail.acme.com' }, + addresses: null, + }); + expect(out.lines).toEqual([ + ' ✔ routing mail.acme.com enabled', + ' ✘ routing subaddressing on acme.com: Cloudflare answered 403 [code: 10000] — Authentication error', + ' turn it on in the Cloudflare dashboard → acme.com → Email → Settings → Subaddressing', + ' ✔ sending mail.acme.com onboarded', + ' ! config addresses withheld this run (session-bearer step failed); no routing rule is created for support@mail.acme.com', + ' ✔ config wrangler.jsonc updated', + ]); + }); + + it('bearer failure → dashboard fallback line, addresses withheld (deleted), deploy continues', async () => { + const calls: Array = []; + const { inputs } = transcriptInputs({ + calls, + getSessionBearer: async () => null, + runnerTable: { 'email sending enable mail.acme.com': sendingEnabled('mail.acme.com') }, + }); + const pre = await preflightEmail(inputs); + calls.length = 0; + const out = await applyEmailSetup(pre, inputs); + expect(out.routingReady).toBe(false); + expect(out.addressesWithheld).toBe(true); + expect(out.inboundReady).toBe(false); + expect(out.patch.addresses).toBeNull(); + // Sending still onboarded → the binding is still written. + expect(out.sendingReady).toBe(true); + expect(out.patch.sendEmail).toEqual({ name: 'SEND_EMAIL' }); + expect(out.lines).toContain( + ' ✘ routing mail.acme.com: could not borrow the Cloudflare session (session-bearer step failed)', + ); + expect(out.lines).toContain(` ${subdomainDashboardFallback('acme.com', 'mail.acme.com')}`); + expect(out.lines.some((l) => l.includes('addresses withheld this run'))).toBe(true); + expect(calls.map((c) => c.args)).toEqual([['email', 'sending', 'enable', 'mail.acme.com']]); + }); + + it('non-2xx on the subdomain POST → fallback line + withheld; the PATCH is not attempted', async () => { + const fetchCalls: Array = []; + const { inputs } = transcriptInputs({ + fetchCalls, + runnerTable: { 'email sending enable mail.acme.com': sendingEnabled('mail.acme.com') }, + fetchTable: { + [ROUTING_DNS_POST]: () => + jsonResponse(400, { + success: false, + errors: [{ code: 2062, message: 'routing not enabled' }], + }), + }, + }); + const pre = await preflightEmail(inputs); + fetchCalls.length = 0; + const out = await applyEmailSetup(pre, inputs); + expect(fetchCalls.map((c) => c.method)).toEqual(['POST']); + expect(out.addressesWithheld).toBe(true); + expect(out.inboundReady).toBe(false); + expect(out.patch.addresses).toBeNull(); + expect(out.lines[0]).toBe( + ' ✘ routing mail.acme.com: Cloudflare answered 400 [code: 2062] — Email Routing is not enabled on this zone', + ); + expect(out.lines[1]).toBe(` ${subdomainDashboardFallback('acme.com', 'mail.acme.com')}`); + }); + + it('non-2xx on the subdomain POST with a bearer in hand names the failed call, not the bearer', async () => { + const { inputs } = transcriptInputs({ + runnerTable: { 'email sending enable mail.acme.com': sendingEnabled('mail.acme.com') }, + fetchTable: { + [ROUTING_DNS_POST]: () => + jsonResponse(400, { success: false, errors: [{ code: 2062, message: 'nope' }] }), + }, + }); + const pre = await preflightEmail(inputs); + const out = await applyEmailSetup(pre, inputs); + expect(out.lines).toContain( + ' ✘ routing subaddressing on acme.com: skipped (the subdomain routing call failed)', + ); + expect(out.lines.some((l) => l.includes('session bearer unavailable'))).toBe(false); + }); + + it('never posts the subdomain routing call against an apex whose Email Routing is off', async () => { + // What `POST …/routing/dns { name }` does on a zone that does not route is unmeasured; + // the one outcome that matters — enabling the zone and locking MX over the apex's live + // mail — is what the apex refusal exists to prevent. No call; addresses withheld. + const calls: Array = []; + const fetchCalls: Array = []; + const { inputs } = transcriptInputs({ + calls, + fetchCalls, + runnerTable: { + 'email routing settings acme.com': routingSettings( + 'acme.com', + false, + 'unconfigured', + ZONE_ID, + ), + 'email sending enable mail.acme.com': sendingEnabled('mail.acme.com'), + }, + }); + const pre = await preflightEmail(inputs); + expect(pre.routing).toEqual({ state: 'pending', status: 'unconfigured', enabled: false }); + const checklist = renderEmailChecklist(pre); + expect(checklist).not.toContain('acme.com itself is not touched'); + expect(checklist).toContain( + ` ! enable Email Routing on mail.acme.com Email Routing is not enabled on acme.com; Void never enables it on the apex from the subdomain path — ${subdomainDashboardFallback('acme.com', 'mail.acme.com')}`, + ); + calls.length = 0; + fetchCalls.length = 0; + const out = await applyEmailSetup(pre, inputs); + expect(fetchCalls).toEqual([]); + expect(calls.map((c) => c.args)).toEqual([['email', 'sending', 'enable', 'mail.acme.com']]); + expect(out.routingReady).toBe(false); + expect(out.subaddressingReady).toBe(false); + expect(out.addressesWithheld).toBe(true); + expect(out.inboundReady).toBe(false); + expect(out.patch.addresses).toBeNull(); + expect(out.lines).toEqual([ + ' ✘ routing mail.acme.com: Email Routing is not enabled on acme.com — a subdomain can only be added to a zone that already routes; Void never enables routing on the apex from the subdomain path', + ` ${subdomainDashboardFallback('acme.com', 'mail.acme.com')}`, + ' ✘ routing subaddressing on acme.com: skipped (Email Routing is not enabled on acme.com)', + ' ✔ sending mail.acme.com onboarded', + ' ! config addresses withheld this run (routing not ready); no routing rule is created for support@mail.acme.com', + ' ✔ config wrangler.jsonc updated', + ]); + }); + + it('`[code: 2040]` on sending enable means already onboarded → ok', async () => { + const { inputs } = transcriptInputs({ + runnerTable: { + 'email sending enable mail.acme.com': apiFailure( + 2040, + `/zones/${ZONE_ID}/email/sending/subdomains`, + ), + }, + fetchTable: { + [ROUTING_DNS_POST]: () => jsonResponse(200, { success: true, result: {} }), + [ROUTING_PATCH]: () => jsonResponse(200, { success: true, result: {} }), + }, + }); + const pre = await preflightEmail(inputs); + const out = await applyEmailSetup(pre, inputs); + expect(out.sendingReady).toBe(true); + expect(out.lines).toContain(' ✔ sending mail.acme.com onboarded'); + expect(out.patch.sendEmail).toEqual({ name: 'SEND_EMAIL' }); + }); + + it('sending enable fails on Workers Free (10105) → hint line, continue with inbound', async () => { + const { inputs } = transcriptInputs({ + runnerTable: { + 'email sending enable mail.acme.com': apiFailure( + 10105, + `/zones/${ZONE_ID}/email/sending/subdomains`, + ), + }, + fetchTable: { + [ROUTING_DNS_POST]: () => jsonResponse(200, { success: true, result: {} }), + [ROUTING_PATCH]: () => jsonResponse(200, { success: true, result: {} }), + }, + }); + const pre = await preflightEmail(inputs); + const out = await applyEmailSetup(pre, inputs); + expect(out.sendingReady).toBe(false); + expect(out.routingReady).toBe(true); + expect(out.lines).toContain( + ' ! sending mail.acme.com not onboarded [code: 10105] — account is not entitled to Email Sending', + ); + expect(out.lines).toContain(` ${SENDING_FREE_PLAN_HINT}`); + expect(out.patch).toEqual({ + sendEmail: { name: 'SEND_EMAIL' }, + vars: { __VOID_EMAIL_FROM: 'noreply@mail.acme.com' }, + addresses: ['support@mail.acme.com'], + }); + + // Round trip: the patch that refused run writes, fed back into the next preflight against + // the account it left behind (routing + subaddressing on, sending still not-found), reads + // ready — the binding is the memory of the refusal — with the retry on offer for `setup`. + const next = await preflightEmail( + transcriptInputs({ + runnerTable: { + 'email routing rules list acme.com': { + status: 0, + stdout: RULES_WITH_ENTRIES.replace('to:support@acme.com', 'to:support@mail.acme.com'), + stderr: BETA_WARNING, + }, + }, + fetchTable: { [`GET /zones/${ZONE_ID}/email/routing`]: routingGet(true) }, + dns: fakeDns({ + mx: { + 'acme.com': ['aspmx.l.google.com'], + 'mail.acme.com': ['route1.mx.cloudflare.net'], + }, + }), + existingSendEmail: [out.patch.sendEmail!], + existingAddresses: out.patch.addresses, + existingVars: out.patch.vars, + }).inputs, + ); + expect(next.sending.state).toBe('pending'); + expect(next.ready).toBe(true); + expect(next.sendingRetryable).toBe(true); + expect(next.addressesStale).toBe(false); + expect(next.sender.state).toBe('ready'); + }); + + it('a sending-scoped blocker (DMARC present) skips sending enable, routing still applies', async () => { + const calls: Array = []; + const { inputs } = transcriptInputs({ + calls, + dns: fakeDns({ + mx: { 'acme.com': ['aspmx.l.google.com'] }, + txt: { '_dmarc.mail.acme.com': ['v=DMARC1'] }, + }), + fetchTable: { + [ROUTING_DNS_POST]: () => jsonResponse(200, { success: true, result: {} }), + [ROUTING_PATCH]: () => jsonResponse(200, { success: true, result: {} }), + }, + }); + const pre = await preflightEmail(inputs); + calls.length = 0; + const out = await applyEmailSetup(pre, inputs); + expect(calls).toEqual([]); + expect(out.sendingReady).toBe(false); + expect(out.routingReady).toBe(true); + expect( + out.lines.some((l) => l.startsWith(' ! sending _dmarc.mail.acme.com already exists')), + ).toBe(true); + }); + + it('re-run with everything ready → zero wrangler calls, zero fetches, same patch', async () => { + const calls: Array = []; + const fetchCalls: Array = []; + const { inputs } = transcriptInputs({ + calls, + fetchCalls, + runnerTable: { + 'email routing rules list acme.com': { + status: 0, + stdout: RULES_WITH_ENTRIES.replace('to:support@acme.com', 'to:support@mail.acme.com'), + stderr: BETA_WARNING, + }, + 'email sending settings mail.acme.com': { + status: 0, + stdout: `Email Sending for mail.acme.com:\n Enabled: true\n Tag: 19af4fdbee924a92916afea0de5a8fdd\n`, + stderr: BETA_WARNING, + }, + }, + fetchTable: { [`GET /zones/${ZONE_ID}/email/routing`]: routingGet(true) }, + dns: fakeDns({ + mx: { 'acme.com': ['aspmx.l.google.com'], 'mail.acme.com': ['route1.mx.cloudflare.net'] }, + }), + handlers: scan(['support+[ticket].ts']), + existingSendEmail: [{ name: 'SEND_EMAIL' }], + existingAddresses: ['support@mail.acme.com'], + }); + const pre = await preflightEmail(inputs); + expect(pre.ready).toBe(true); + calls.length = 0; + fetchCalls.length = 0; + const out = await applyEmailSetup(pre, inputs); + expect(calls).toEqual([]); + expect(fetchCalls).toEqual([]); + expect(out.patch).toEqual({ + sendEmail: { name: 'SEND_EMAIL' }, + vars: { __VOID_EMAIL_FROM: 'noreply@mail.acme.com' }, + addresses: ['support@mail.acme.com'], + }); + }); + + it('a blocked preflight applies nothing and returns an empty patch', async () => { + const calls: Array = []; + const fetchCalls: Array = []; + const { inputs } = transcriptInputs({ + calls, + fetchCalls, + existingSendEmail: [{ name: 'EMAIL' }], + }); + const pre = await preflightEmail(inputs); + calls.length = 0; + fetchCalls.length = 0; + const out = await applyEmailSetup(pre, inputs); + expect(calls).toEqual([]); + expect(fetchCalls).toEqual([]); + expect(out.patch).toEqual({}); + expect(out.lines[0]).toContain('✘ wrangler.jsonc already has a send_email binding'); + }); + + it('never surfaces the bearer in a printed line, a thrown error, or the returned object', async () => { + // Every transport outcome that could carry the token: a fetch that throws with it in + // the message, a non-2xx whose error message echoes it, and a happy path. + const leaky = (call: FetchCall): Response => { + throw new Error(`boom ${call.headers.Authorization}`); + }; + const echo = () => + jsonResponse(403, { + success: false, + errors: [{ code: 10000, message: `bad token Bearer ${BEARER}` }], + }); + const tables: Array Response>> = [ + { [ROUTING_DNS_POST]: leaky }, + { [ROUTING_DNS_POST]: echo }, + { + [ROUTING_DNS_POST]: () => jsonResponse(200, { success: true, result: {} }), + [ROUTING_PATCH]: leaky, + }, + ]; + for (const table of tables) { + const { inputs } = transcriptInputs({ + runnerTable: { 'email sending enable mail.acme.com': sendingEnabled('mail.acme.com') }, + fetchTable: table, + }); + const pre = await preflightEmail(inputs); + let out: unknown; + let thrown: unknown; + try { + out = await applyEmailSetup(pre, inputs); + } catch (err) { + thrown = err; + } + expect(thrown).toBeUndefined(); + const serialised = JSON.stringify(out); + // The fakes echo the header back on purpose; `fetchWithSessionBearer` scrubs it. + for (const line of (out as { lines: Array }).lines) { + expect(line).not.toContain(BEARER); + } + expect(serialised).not.toContain(BEARER); + expect(JSON.stringify(pre)).not.toContain(BEARER); + } + }); +}); + +describe('findEmailConfigConflicts', () => { + it('names a cross-type owner of either name, and a malformed target field', () => { + expect(findEmailConfigConflicts({})).toEqual([]); + expect(findEmailConfigConflicts({ send_email: [{ name: 'SEND_EMAIL' }] })).toEqual([]); + expect( + findEmailConfigConflicts({ kv_namespaces: [{ binding: 'SEND_EMAIL', id: 'x' }] }), + ).toEqual([ + 'wrangler.jsonc already uses SEND_EMAIL as a KV Namespace binding; rename it (Void writes SEND_EMAIL into send_email)', + ]); + expect( + findEmailConfigConflicts({ kv_namespaces: [{ binding: '__VOID_EMAIL_FROM', id: 'x' }] }), + ).toEqual([ + 'wrangler.jsonc already uses __VOID_EMAIL_FROM as a KV Namespace binding; rename it (Void writes __VOID_EMAIL_FROM into vars)', + ]); + expect(findEmailConfigConflicts({ send_email: { name: 'SEND_EMAIL' } })).toEqual([ + expect.stringContaining('"send_email"'), + ]); + expect(findEmailConfigConflicts({ vars: 'oops' })).toEqual([expect.stringContaining('"vars"')]); + }); +}); + +describe('fetchWithSessionBearer', () => { + it('puts the token only in the Authorization header and never throws', async () => { + const fetchCalls: Array = []; + const f = fakeFetch({ 'GET /zones?name=acme.com': zonesOk('acme.com') }, fetchCalls); + const ok = await fetchWithSessionBearer(f, BEARER, 'GET', '/zones?name=acme.com'); + expect(ok.ok).toBe(true); + expect(ok.status).toBe(200); + expect(fetchCalls[0]?.headers).toEqual({ Authorization: `Bearer ${BEARER}` }); + expect(fetchCalls[0]?.body).toBeUndefined(); + + const boom = await fetchWithSessionBearer( + (async () => { + throw new Error('ECONNRESET'); + }) as unknown as typeof fetch, + BEARER, + 'POST', + '/zones/x/email/routing/dns', + { name: 'x' }, + ); + expect(boom).toEqual({ + ok: false, + status: 0, + result: null, + errors: [{ code: 0, message: 'ECONNRESET' }], + }); + + const html = await fetchWithSessionBearer( + (async () => new Response('', { status: 502 })) as unknown as typeof fetch, + BEARER, + 'GET', + '/zones', + ); + expect(html).toEqual({ ok: false, status: 502, result: null, errors: [] }); + }); + + it('honours a custom API base (the pinned transport)', async () => { + const fetchCalls: Array = []; + const f = fakeFetch({ 'GET /zones?name=acme.com': zonesOk('acme.com') }, fetchCalls); + await fetchWithSessionBearer( + f, + BEARER, + 'GET', + '/zones?name=acme.com', + undefined, + 'https://api.example.test/v4', + ); + expect(fetchCalls[0]?.url).toBe('https://api.example.test/v4/zones?name=acme.com'); + }); +}); + +// ── Address map ────────────────────────────────────────────────────────────────── + +describe('renderAddressMap', () => { + it('renders the post-deploy block', () => { + expect( + renderAddressMap({ + from: 'support@mail.acme.com', + routes: [{ address: 'support@mail.acme.com', handler: 'email/support+[ticket].ts' }], + addresses: ['support@mail.acme.com'], + sendingReady: true, + }), + ).toBe( + [ + ' inbound support@mail.acme.com → email/support+[ticket].ts', + ' outbound sendEmail() from support@mail.acme.com', + ].join('\n'), + ); + }); + + it('marks routes outside the written addresses and a sending that is not onboarded', () => { + const text = renderAddressMap({ + from: 'noreply@acme.com', + routes: [ + { address: 'support@acme.com', handler: 'email/support.ts' }, + { address: '*@acme.com', handler: 'email/_default.ts' }, + ], + addresses: ['support@acme.com'], + sendingReady: false, + }); + expect(text).toBe( + [ + ' inbound support@acme.com → email/support.ts', + ' inbound *@acme.com → email/_default.ts (no rule this deploy)', + ' outbound sendEmail() from noreply@acme.com (verified destinations only)', + ].join('\n'), + ); + }); + + it('marks a written address whose rule this deploy creates', () => { + const text = renderAddressMap({ + from: 'noreply@acme.com', + routes: [ + { address: 'support@acme.com', handler: 'email/support.ts' }, + { address: 'sales@acme.com', handler: 'email/sales.ts' }, + ], + addresses: ['support@acme.com', 'sales@acme.com'], + sendingReady: true, + creating: ['sales@acme.com'], + }); + expect(text).toBe( + [ + ' inbound support@acme.com → email/support.ts', + ' inbound sales@acme.com → email/sales.ts (rule created by this deploy)', + ' outbound sendEmail() from noreply@acme.com', + ].join('\n'), + ); + }); + + it('takes a pending label that does not name a deploy (`void email status`)', () => { + const text = renderAddressMap({ + from: 'noreply@acme.com', + routes: [{ address: 'support@acme.com', handler: 'email/support.ts' }], + addresses: [], + sendingReady: true, + pendingLabel: '(no rule yet)', + }); + expect(text).toBe( + [ + ' inbound support@acme.com → email/support.ts (no rule yet)', + ' outbound sendEmail() from noreply@acme.com', + ].join('\n'), + ); + }); +}); diff --git a/packages/void/test/unit/email-dev-inbox.test.ts b/packages/void/test/unit/email-dev-inbox.test.ts new file mode 100644 index 000000000..57ae970ad --- /dev/null +++ b/packages/void/test/unit/email-dev-inbox.test.ts @@ -0,0 +1,803 @@ +/** + * The `void dev` email inbox spans TWO OS processes: + * + * Node (Vite dev server) workerd (miniflare child) + * ---------------------- ------------------------- + * emailDevInboxPlugin user route -> void/email + * registers the buffer sink sendEmail() -> hasActiveSink() + * serves GET /__void/inbox globalThis.__voidEmailSinks__ is + * EMPTY here (different isolate) + * + * So `registerEmailSink` can never be the transport for a real `void dev` + * send. These tests drive the wire boundary instead: `sendEmail` runs with a + * raw env shaped like the one workerd sees (no sink registered), and its + * capture POST is routed into the REAL dev-inbox middleware through a fake + * `ASSETS` fetcher — which is exactly what `@cloudflare/vite-plugin` binds + * `env.ASSETS` to in dev (`{ node: (req, res) => viteDevServer.middlewares(req, res) }`). + */ +import { EventEmitter } from 'node:events'; +import { Readable } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { shouldInjectEmailDevInboxVar } from '../../src/index.ts'; +import { emailDevInboxPlugin } from '../../src/plugin-email-dev-inbox.ts'; +import { sendEmail } from '../../src/runtime/email.ts'; +import { + captureMessage, + clearDevBuffer, + DEV_INBOX_ACK_HEADER, + DEV_INBOX_ACK_VALUE, + DEV_INBOX_CAPTURE_URL, + DEV_INBOX_ENV_VAR, + DEV_INBOX_MAX_CAPTURE_BYTES, + DEV_INBOX_TOKEN_ENV_VAR, + DEV_INBOX_TOKEN_HEADER, + DEV_INBOX_TOKEN_QUERY, + hasActiveSink, + listDevMessages, + parseCapturedMessage, + pushToDevBuffer, + serializeCapturedMessage, + type CapturedMessage, +} from '../../src/runtime/email/dev-inbox.ts'; +import { + buildMime, + MAX_ADDRESS_LENGTH, + MAX_MESSAGE_BYTES, + MAX_RECIPIENTS, + normalizeEmail, +} from '../../src/runtime/email/mime.ts'; +import { withRuntimeEnv } from '../../src/runtime/env.ts'; + +/** The per-project dev-trigger token `voidPlugin()` hands the plugin. */ +const TOKEN = 'dev-token-test'; + +/** The worker env `mergeBindings` produces under `void dev`: binding var + token var. */ +function devEnv(assets: unknown, bindingName = 'ASSETS'): Record { + return { + [DEV_INBOX_ENV_VAR]: bindingName, + [DEV_INBOX_TOKEN_ENV_VAR]: TOKEN, + [bindingName]: assets, + }; +} + +/** A request that presents the token the way curl and the worker do. */ +function authed(url: string, init: RequestInit = {}): Request { + const headers = new Headers(init.headers); + headers.set(DEV_INBOX_TOKEN_HEADER, TOKEN); + return new Request(url, { ...init, headers }); +} + +// --------------------------------------------------------------------------- +// A minimal connect-style middleware stack, so the plugin's real +// `configureServer` handler runs against real Node req/res objects. +// --------------------------------------------------------------------------- + +type NodeHandler = (req: unknown, res: unknown, next: () => void) => void; + +interface FakeServer { + dispatch(request: Request): Promise; +} + +function createFakeViteServer(): FakeServer { + const stack: Array<{ route: string; handle: NodeHandler }> = []; + const server = { + config: { command: 'serve' as const }, + httpServer: undefined, + middlewares: { + use(route: string | NodeHandler, handle?: NodeHandler) { + if (typeof route === 'function') { + stack.push({ route: '/', handle: route }); + } else { + stack.push({ route, handle: handle as NodeHandler }); + } + }, + }, + }; + + const plugin = emailDevInboxPlugin({ devTriggerToken: TOKEN }); + const configureServer = plugin.configureServer as (this: unknown, s: unknown) => void; + configureServer.call(plugin, server); + + return { + async dispatch(request: Request): Promise { + const url = new URL(request.url); + const entry = stack.find( + (s) => url.pathname === s.route || url.pathname.startsWith(`${s.route}/`), + ); + if (!entry) { + return new Response('no middleware', { status: 404 }); + } + // connect strips the mounted prefix from req.url. + const rest = url.pathname.slice(entry.route.length) || '/'; + const bodyBytes = new Uint8Array(await request.arrayBuffer()); + + const req = Readable.from( + bodyBytes.byteLength > 0 ? [Buffer.from(bodyBytes)] : [], + ) as Readable & { + url: string; + method: string; + headers: Record; + }; + req.url = rest + url.search; + req.method = request.method; + req.headers = Object.fromEntries(request.headers.entries()); + + return await new Promise((resolve) => { + let status = 200; + let headers: Record = {}; + const chunks: Array = []; + const res = { + writeHead(code: number, h?: Record) { + status = code; + if (h) { + headers = { ...headers, ...h }; + } + return res; + }, + setHeader(k: string, v: string) { + headers[k] = v; + }, + end(chunk?: Buffer | string) { + if (chunk) { + chunks.push(Buffer.from(chunk)); + } + // A WHATWG Response rejects a body on 204/304, which the plugin's + // DELETE answers with; node:http has no such rule. + const body = status === 204 || status === 304 ? null : Buffer.concat(chunks); + resolve(new Response(body, { status, headers })); + }, + }; + entry.handle(req, res, () => { + resolve(new Response('next()', { status: 404 })); + }); + }); + }, + }; +} + +/** + * Stand-in for the dev `env.ASSETS` binding. In a real `void dev` this is a + * miniflare service binding whose implementation is the Vite dev server's Node + * middleware stack; here it is the same middleware stack, reached over a real + * Request/Response pair. + */ +function fakeAssetsBinding(server: FakeServer): { + fetch(req: Request | string, init?: RequestInit): Promise; + calls: Array<{ url: string; method: string }>; +} { + const calls: Array<{ url: string; method: string }> = []; + return { + calls, + async fetch(req, init) { + const request = typeof req === 'string' ? new Request(req, init) : req; + calls.push({ url: request.url, method: request.method }); + return server.dispatch(request); + }, + }; +} + +function subjectsInListHtml(html: string): Array { + return [...html.matchAll(/([^<]*)<\/a>/g)].map((m) => m[1]); +} + +function hrefsInHtml(html: string): Array { + return [...html.matchAll(/href="([^"]+)"/g)].map((m) => m[1]); +} + +beforeEach(() => { + clearDevBuffer(); +}); +afterEach(() => { + clearDevBuffer(); +}); + +describe('dev inbox capture wire format', () => { + it('round-trips a captured message through JSON', () => { + const original: CapturedMessage = { + id: 'dev_abc', + capturedAt: 1234, + raw: new Uint8Array([0, 1, 2, 250, 255]), + messageId: '', + from: { email: 'a@x.dev', name: 'A' }, + to: [{ email: 'b@y.dev' }], + cc: [], + bcc: [], + subject: 'hi', + text: 'body', + headers: { 'X-Custom': '1' }, + attachments: [ + { filename: 'a.txt', contentType: 'text/plain', size: 3, disposition: 'attachment' }, + ], + }; + const parsed = parseCapturedMessage(JSON.parse(serializeCapturedMessage(original))); + expect(parsed).toBeDefined(); + expect(parsed?.id).toBe('dev_abc'); + expect(parsed?.subject).toBe('hi'); + expect([...(parsed?.raw ?? [])]).toEqual([0, 1, 2, 250, 255]); + expect(parsed?.attachments[0].filename).toBe('a.txt'); + expect(parsed?.headers['X-Custom']).toBe('1'); + }); + + it('rejects a malformed payload instead of throwing', () => { + expect(parseCapturedMessage(null)).toBeUndefined(); + expect(parseCapturedMessage({ id: 1 })).toBeUndefined(); + expect(parseCapturedMessage({ id: 'x', raw: 'not-base64!!!', to: 'nope' })).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// The capture cap is sized for the SERIALIZED capture, not for the MIME. The +// wire format carries the raw MIME as base64 and every caller string again as +// JSON, and JSON escapes a control character to six bytes where the MIME +// spends three (`=01`, quoted-printable body) or 1.6 (base64 encoded-word +// header). So a message `buildMime` accepts — legal in production — can +// serialize to several times MAX_MESSAGE_BYTES, and a cap that ignores that +// makes `void dev` refuse what a deploy would send. +// --------------------------------------------------------------------------- + +/** Serialized capture size of a message, alongside the MIME it was built from. */ +async function serializedSizeOf( + opts: Parameters[0], +): Promise<{ mimeBytes: number; jsonBytes: number }> { + const normalized = await normalizeEmail(opts); + const { raw, messageId } = buildMime(normalized); + const json = serializeCapturedMessage(captureMessage(normalized, raw, messageId)); + return { mimeBytes: raw.byteLength, jsonBytes: Buffer.byteLength(json, 'utf8') }; +} + +describe('dev inbox capture cap (sized for the serialized capture)', () => { + // 3.31M x \x01. Quoted-printable renders each as `=01` plus a soft break + // every 25, so the MIME is 10.3 MB — under MAX_MESSAGE_BYTES, so production + // sends it — while the capture renders each as `\u0001` (19.9 MB) next to + // the base64 of that MIME (13.8 MB): 33.6 MB, over the 32 MiB the cap used + // to be. The dev server answered 413 and `sendEmail` reported the legal + // message as UPSTREAM_ERROR. The smallest such body is 3,303,000 characters; + // the margin keeps the test off the edge of the old cap. + it('captures a legal message whose serialized capture exceeds 32 MiB', async () => { + const text = '\x01'.repeat(3_310_000); + const server = createFakeViteServer(); + const assets = fakeAssetsBinding(server); + const result = await withRuntimeEnv(devEnv(assets), () => + sendEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 'control-heavy', text }), + ); + expect(result).toMatchObject({ ok: true }); + const [message] = listDevMessages(); + expect(message.subject).toBe('control-heavy'); + expect(message.raw.byteLength).toBeLessThanOrEqual(MAX_MESSAGE_BYTES); + expect(message.text).toBe(text); + // What crossed the wire: the same JSON the worker POSTed (ASCII, so its + // length is its byte size), and it is over the former cap. + expect(serializeCapturedMessage(message).length).toBeGreaterThan(32 * 1024 * 1024); + }); + + // The bound behind the constant: base64 raw (4/3) plus every caller string + // at its worst JSON expansion over its own MIME octets (3.75x, a control in + // an encoded-word header), plus the fields with no MIME footprint (`bcc` + // addresses, framing). Pinned against the real builder and serializer at a + // small scale — the ratio is per octet, so it holds at any size — so a new + // capture field, or a MIME path that stops rendering a string, changes + // this test before it changes the inbox. + it('is derived from MAX_MESSAGE_BYTES: base64 raw (4/3) plus caller strings at 3.75x', async () => { + const worstExpansion = 6 / 1.6; + expect(DEV_INBOX_MAX_CAPTURE_BYTES).toBe(6 * MAX_MESSAGE_BYTES); + const framing = 1024; + const bound = + (4 / 3 + worstExpansion) * MAX_MESSAGE_BYTES + MAX_RECIPIENTS * MAX_ADDRESS_LENGTH + framing; + expect(DEV_INBOX_MAX_CAPTURE_BYTES).toBeGreaterThan(bound); + // ...and the smallest integer multiple that does. + expect(5 * MAX_MESSAGE_BYTES).toBeLessThan(bound); + + const base = { from: 'a@x.dev', to: 'b@y.dev', subject: 's' }; + const control = '\x01'.repeat(30_000); + const encodedHeaderShapes: Array<[string, Parameters[0]]> = [ + // encoded-word header: base64 plus framing (1.6) in the MIME, 6 in JSON — 3.75x + ['control subject', { ...base, subject: control, text: 't' }], + ['control display name', { ...base, to: [{ email: 'b@y.dev', name: control }], text: 't' }], + ['control custom header value', { ...base, headers: { 'X-Note': control }, text: 't' }], + ]; + const shapes: Array<[string, Parameters[0]]> = [ + // body: `=01` (3) in the MIME, `\u0001` (6) in JSON — 2x + ['control text', { ...base, text: control }], + // body, 7bit path: CRLF (2) in the MIME, `\r\n` (4) in JSON — exactly 2x + ['crlf text', { ...base, text: '\r\n'.repeat(30_000) }], + // body, quoted-printable path: a mid-line HTAB is 1 octet, `\t` in JSON is 2 — 2x + ['tab text', { ...base, text: '\t'.repeat(30_000) }], + // custom header names: printable ASCII, `"` is 1 in the MIME and 2 in JSON — 2x + [ + 'quote header names', + { + ...base, + text: 't', + headers: Object.fromEntries( + Array.from({ length: 50 }, (_, i) => [`${'"'.repeat(900)}${i}`, 'v']), + ), + }, + ], + ...encodedHeaderShapes, + ]; + for (const [label, opts] of shapes) { + const { mimeBytes, jsonBytes } = await serializedSizeOf(opts); + expect(mimeBytes, label).toBeLessThanOrEqual(MAX_MESSAGE_BYTES); + expect(jsonBytes, label).toBeLessThanOrEqual((4 / 3 + worstExpansion) * mimeBytes + framing); + } + + // The 3.75x term is load-bearing, not slack: every encoded-header shape + // serializes past the body-only figure (4/3 + 2), which is why that + // figure could not have been the ratio. + for (const [label, opts] of encodedHeaderShapes) { + const { mimeBytes, jsonBytes } = await serializedSizeOf(opts); + expect(jsonBytes, label).toBeGreaterThan((4 / 3 + 2) * mimeBytes); + } + }); +}); + +describe('sendEmail -> dev inbox (cross-process transport)', () => { + it('POSTs the captured message to the dev inbox when no in-process sink exists', async () => { + // This is the shape workerd sees during `void dev`: the Node-side sink + // registry is unreachable, so hasActiveSink() is false. + expect(hasActiveSink()).toBe(false); + + const server = createFakeViteServer(); + const assets = fakeAssetsBinding(server); + + const result = await withRuntimeEnv(devEnv(assets), () => + sendEmail({ + from: 'Acme ', + to: 'user@example.com', + subject: 'Cross-process hello', + text: 'Hi there', + html: '

Hi there

', + }), + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.ids).toHaveLength(1); + expect(result.ids[0].recipient).toBe('user@example.com'); + expect(result.ids[0].messageId).toMatch(/^dev_/); + } + + expect(assets.calls).toEqual([ + { url: `http://localhost${DEV_INBOX_CAPTURE_URL}`, method: 'POST' }, + ]); + + // ...and the Node-side buffer that `/__void/inbox` renders now has it. + const messages = listDevMessages(); + expect(messages).toHaveLength(1); + expect(messages[0].subject).toBe('Cross-process hello'); + expect(messages[0].from.email).toBe('hello@acme.dev'); + expect(messages[0].to[0].email).toBe('user@example.com'); + expect(messages[0].html).toBe('

Hi there

'); + expect(messages[0].raw.byteLength).toBeGreaterThan(0); + + const listed = await server.dispatch(authed('http://localhost/__void/inbox')); + expect(subjectsInListHtml(await listed.text())).toContain('Cross-process hello'); + }); + + // The cross-process capture answers with the same `ids` the in-process + // sink does — one per unique address, lowercased, as production sends — + // while the buffered message the inbox renders keeps every field as given. + it('reports one id per unique address while the buffered message keeps the fields', async () => { + const server = createFakeViteServer(); + const assets = fakeAssetsBinding(server); + const result = await withRuntimeEnv(devEnv(assets), () => + sendEmail({ + from: 'a@x.dev', + to: ['A@x.dev'], + cc: ['a@x.dev'], + bcc: ['Bob ', 'b@x.dev'], + subject: 'dedup-check', + text: 't', + }), + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.ids.map((d) => d.recipient)).toEqual(['a@x.dev', 'b@x.dev']); + } + const [message] = listDevMessages(); + expect(message.to).toEqual([{ email: 'A@x.dev' }]); + expect(message.cc).toEqual([{ email: 'a@x.dev' }]); + expect(message.bcc).toEqual([{ email: 'B@x.dev', name: 'Bob' }, { email: 'b@x.dev' }]); + }); + + it('serves the captured .eml back from the raw route', async () => { + const server = createFakeViteServer(); + const assets = fakeAssetsBinding(server); + await withRuntimeEnv(devEnv(assets), () => + sendEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 'raw-check', text: 'body text' }), + ); + const id = listDevMessages()[0].id; + const raw = await server.dispatch(authed(`http://localhost/__void/inbox/${id}/raw`)); + expect(raw.status).toBe(200); + expect(raw.headers.get('content-type')).toBe('message/rfc822'); + expect(await raw.text()).toContain('Subject: raw-check'); + }); + + it('acknowledges the capture with a marker header so a plain asset 200 is not mistaken for success', async () => { + const server = createFakeViteServer(); + const res = await server.dispatch( + authed(`http://localhost${DEV_INBOX_CAPTURE_URL}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: serializeCapturedMessage({ + id: 'dev_marker', + capturedAt: Date.now(), + raw: new Uint8Array([65]), + messageId: '', + from: { email: 'a@x.dev' }, + to: [{ email: 'b@y.dev' }], + cc: [], + bcc: [], + subject: 'marker', + headers: {}, + attachments: [], + }), + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get(DEV_INBOX_ACK_HEADER)).toBe(DEV_INBOX_ACK_VALUE); + expect(listDevMessages()[0].subject).toBe('marker'); + }); + + it('reports an honest error when the dev inbox is unreachable — no hang, no crash', async () => { + const broken = { + async fetch() { + throw new Error('socket hang up'); + }, + }; + const result = await withRuntimeEnv(devEnv(broken), () => + sendEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('UPSTREAM_ERROR'); + expect(result.error.message).toContain('/__void/inbox'); + } + }); + + it('does not mistake a non-inbox 200 (a real asset response) for a capture', async () => { + const assetOnly = { + async fetch() { + return new Response('', { + status: 200, + headers: { 'content-type': 'text/html' }, + }); + }, + }; + const result = await withRuntimeEnv(devEnv(assetOnly), () => + sendEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('UPSTREAM_ERROR'); + } + expect(listDevMessages()).toHaveLength(0); + }); + + it('falls back to the proxy path when the dev-inbox var is absent (production shape)', async () => { + const server = createFakeViteServer(); + const assets = fakeAssetsBinding(server); + const result = await withRuntimeEnv({ ASSETS: assets }, () => + sendEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('BINDING_MISSING'); + } + expect(assets.calls).toHaveLength(0); + expect(listDevMessages()).toHaveLength(0); + }); + + it('honors sendInDev: true by skipping the dev inbox entirely', async () => { + const server = createFakeViteServer(); + const assets = fakeAssetsBinding(server); + const result = await withRuntimeEnv(devEnv(assets), () => + sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + sendInDev: true, + }), + ); + expect(assets.calls).toHaveLength(0); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('BINDING_MISSING'); + } + }); + + it('uses the binding name the var names, not a hardcoded ASSETS', async () => { + const server = createFakeViteServer(); + const assets = fakeAssetsBinding(server); + await withRuntimeEnv(devEnv(assets, 'STATIC_FILES'), () => + sendEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 'named-binding', text: 't' }), + ); + expect(assets.calls).toHaveLength(1); + expect(listDevMessages()[0].subject).toBe('named-binding'); + }); +}); + +// --------------------------------------------------------------------------- +// Every inbox route requires the dev-trigger token. Vite's host validation +// admits any IP-literal `Host`, so under `--host` (or a shared port) the +// unauthenticated inbox handed every network peer the raw MIME — Bcc and +// attachments included — plus DELETE and a capture endpoint to plant messages. +// --------------------------------------------------------------------------- + +function seeded(): CapturedMessage { + return { + id: 'dev_seeded', + capturedAt: Date.now(), + raw: new TextEncoder().encode('Subject: seeded\r\nBcc: hidden@bcc.dev\r\n\r\nbody'), + messageId: '', + from: { email: 'a@x.dev' }, + to: [{ email: 'b@y.dev' }], + cc: [], + bcc: [{ email: 'hidden@bcc.dev' }], + subject: 'seeded', + text: 'body', + headers: {}, + attachments: [], + }; +} + +describe('dev inbox auth (dev-trigger token on every route)', () => { + const captureInit = (): RequestInit => ({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: serializeCapturedMessage({ ...seeded(), id: 'dev_planted' }), + }); + + it.each([ + ['GET list', 'http://localhost/__void/inbox', {}], + ['GET detail', 'http://localhost/__void/inbox/dev_seeded', {}], + ['GET raw', 'http://localhost/__void/inbox/dev_seeded/raw', {}], + ['DELETE clear', 'http://localhost/__void/inbox', { method: 'DELETE' }], + ['POST capture', `http://localhost${DEV_INBOX_CAPTURE_URL}`, captureInit()], + ] as Array<[string, string, RequestInit]>)( + '%s: 401 without the token, 401 with a wrong one, and nothing happens', + async (_label, url, init) => { + const server = createFakeViteServer(); + pushToDevBuffer(seeded()); + + const missing = await server.dispatch(new Request(url, init)); + expect(missing.status).toBe(401); + expect(missing.headers.get(DEV_INBOX_ACK_HEADER)).toBeNull(); + const body = await missing.text(); + expect(body.split('\n')).toHaveLength(1); + expect(body).toContain(DEV_INBOX_TOKEN_HEADER); + expect(body).toContain(`?${DEV_INBOX_TOKEN_QUERY}=`); + expect(body).not.toContain(TOKEN); + + const wrongHeader = await server.dispatch( + new Request(url, { + ...init, + headers: { ...init.headers, [DEV_INBOX_TOKEN_HEADER]: 'nope' }, + }), + ); + expect(wrongHeader.status).toBe(401); + const wrongQuery = await server.dispatch( + new Request(`${url}?${DEV_INBOX_TOKEN_QUERY}=nope`, init), + ); + expect(wrongQuery.status).toBe(401); + // A header that is wrong is not rescued by a query that is right — the + // header is what the worker and curl send, so it is what gets checked. + const wrongHeaderRightQuery = await server.dispatch( + new Request(`${url}?${DEV_INBOX_TOKEN_QUERY}=${TOKEN}`, { + ...init, + headers: { ...init.headers, [DEV_INBOX_TOKEN_HEADER]: 'nope' }, + }), + ); + expect(wrongHeaderRightQuery.status).toBe(401); + + // The refusal had no side effect: the seeded message is still the only one. + expect(listDevMessages().map((m) => m.id)).toEqual(['dev_seeded']); + }, + ); + + it('serves every route with the token in the header (worker, curl)', async () => { + const server = createFakeViteServer(); + pushToDevBuffer(seeded()); + + const list = await server.dispatch(authed('http://localhost/__void/inbox')); + expect(list.status).toBe(200); + expect(subjectsInListHtml(await list.text())).toContain('seeded'); + + const detail = await server.dispatch(authed('http://localhost/__void/inbox/dev_seeded')); + expect(detail.status).toBe(200); + expect(await detail.text()).toContain('hidden@bcc.dev'); + + const raw = await server.dispatch(authed('http://localhost/__void/inbox/dev_seeded/raw')); + expect(raw.status).toBe(200); + expect(await raw.text()).toContain('Bcc: hidden@bcc.dev'); + + const planted = await server.dispatch( + authed(`http://localhost${DEV_INBOX_CAPTURE_URL}`, captureInit()), + ); + expect(planted.status).toBe(200); + expect(planted.headers.get(DEV_INBOX_ACK_HEADER)).toBe(DEV_INBOX_ACK_VALUE); + expect(listDevMessages().map((m) => m.id)).toEqual(['dev_planted', 'dev_seeded']); + + const cleared = await server.dispatch( + authed('http://localhost/__void/inbox', { method: 'DELETE' }), + ); + expect(cleared.status).toBe(204); + expect(listDevMessages()).toHaveLength(0); + }); + + it('accepts the token as ?token= so the printed URL works in a browser, and keeps the session in its links', async () => { + const server = createFakeViteServer(); + pushToDevBuffer(seeded()); + const q = `?${DEV_INBOX_TOKEN_QUERY}=${TOKEN}`; + + const list = await server.dispatch(new Request(`http://localhost/__void/inbox${q}`)); + expect(list.status).toBe(200); + const listHrefs = hrefsInHtml(await list.text()); + expect(listHrefs).toEqual([`/__void/inbox/dev_seeded${q}`, `/__void/inbox/dev_seeded/raw${q}`]); + + const detail = await server.dispatch( + new Request(`http://localhost/__void/inbox/dev_seeded${q}`), + ); + expect(detail.status).toBe(200); + expect(hrefsInHtml(await detail.text())).toEqual([ + `/__void/inbox${q}`, + `/__void/inbox/dev_seeded/raw${q}`, + ]); + + const raw = await server.dispatch( + new Request(`http://localhost/__void/inbox/dev_seeded/raw${q}`), + ); + expect(raw.status).toBe(200); + expect(raw.headers.get('content-type')).toBe('message/rfc822'); + }); + + it('sendEmail reports the 401 by name when the worker env lacks the token, and nothing is captured', async () => { + const server = createFakeViteServer(); + const assets = fakeAssetsBinding(server); + const result = await withRuntimeEnv({ [DEV_INBOX_ENV_VAR]: 'ASSETS', ASSETS: assets }, () => + sendEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 'no-token', text: 't' }), + ); + expect(assets.calls).toHaveLength(1); + expect(listDevMessages()).toHaveLength(0); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('UPSTREAM_ERROR'); + expect(result.error.message).toContain('dev server responded 401'); + expect(result.error.message).toContain(DEV_INBOX_TOKEN_HEADER); + } + }); + + it('never hangs on a refusal whose body stalls — the excerpt read has its own deadline', async () => { + vi.useFakeTimers(); + try { + const stalled = { + async fetch() { + // Status arrives, the body never does. + return new Response(new ReadableStream({ start() {} }), { status: 401 }); + }, + }; + const pending = withRuntimeEnv(devEnv(stalled), () => + sendEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 'stall', text: 't' }), + ); + await vi.advanceTimersByTimeAsync(1000); + const result = await pending; + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('UPSTREAM_ERROR'); + expect(result.error.message).toContain('dev server responded 401'); + } + } finally { + vi.useRealTimers(); + } + }); + + it('prints the inbox URL with the token once the dev server is listening', async () => { + const httpServer = new EventEmitter() as EventEmitter & { address: () => { port: number } }; + httpServer.address = () => ({ port: 5173 }); + const server = { config: { command: 'serve' as const }, httpServer, middlewares: { use() {} } }; + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + const plugin = emailDevInboxPlugin({ devTriggerToken: TOKEN }); + (plugin.configureServer as (this: unknown, s: unknown) => void).call(plugin, server); + expect(log).not.toHaveBeenCalled(); + httpServer.emit('listening'); + await new Promise((r) => setTimeout(r, 150)); + const output = log.mock.calls.map((c) => String(c[0] ?? '')).join('\n'); + expect(output).toContain( + `Email inbox: http://localhost:5173/__void/inbox?${DEV_INBOX_TOKEN_QUERY}=${TOKEN}`, + ); + } finally { + log.mockRestore(); + } + }); +}); + +describe('shouldInjectEmailDevInboxVar (dev-only gate)', () => { + it('injects in dev when email is used and an assets binding exists', () => { + expect(shouldInjectEmailDevInboxVar('dev', true, 'ASSETS')).toBe(true); + }); + + // `prod` covers every build, `void deploy`, AND `vite preview` (which + // reports command: 'serve' but runs the built worker, whose assets binding + // is a real directory rather than a dev-server loopback). + it('never injects outside the dev server — a production wrangler.json must not carry it', () => { + expect(shouldInjectEmailDevInboxVar('prod', true, 'ASSETS')).toBe(false); + }); + + it('does not inject when the app does not use void/email', () => { + expect(shouldInjectEmailDevInboxVar('dev', false, 'ASSETS')).toBe(false); + expect(shouldInjectEmailDevInboxVar('dev', undefined, 'ASSETS')).toBe(false); + }); + + it('does not inject when there is no assets binding to loop back through', () => { + expect(shouldInjectEmailDevInboxVar('dev', true, undefined)).toBe(false); + expect(shouldInjectEmailDevInboxVar('dev', true, '')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Buffer bounds. The dev buffer lives in the Vite dev server's NODE process, +// and it retains each message's full raw MIME plus its decoded text and html. +// A count-only cap of 100 therefore bounds the buffer at ~100 x +// MAX_MESSAGE_BYTES, i.e. gigabytes — enough to OOM `void dev` itself. +// --------------------------------------------------------------------------- + +const MIB = 1024 * 1024; + +function capturedOfSize(id: string, rawBytes: number, htmlChars = 0): CapturedMessage { + return { + id, + capturedAt: Date.now(), + raw: new Uint8Array(rawBytes), + messageId: `<${id}@x>`, + from: { email: 'a@x.dev' }, + to: [{ email: 'b@y.dev' }], + cc: [], + bcc: [], + subject: id, + ...(htmlChars > 0 && { html: 'x'.repeat(htmlChars) }), + headers: {}, + attachments: [], + }; +} + +describe('dev inbox buffer bounds', () => { + it('evicts on the retained-byte budget well below the count cap', () => { + // 8 MiB of raw MIME + 8 MiB of decoded html is 16 MiB charged per message, + // so the fifth push crosses the 64 MiB budget and evicts — at a count of + // five, nowhere near the 100-item cap that used to be the only bound. + for (let i = 1; i <= 5; i++) { + pushToDevBuffer(capturedOfSize(`msg-${i}`, 8 * MIB, 8 * MIB)); + } + const retained = listDevMessages(); + expect(retained).toHaveLength(4); + // listDevMessages() is newest-first: the send that just happened survives, + // and the oldest is the one that went. + expect(retained[0].id).toBe('msg-5'); + expect(retained.map((m) => m.id)).not.toContain('msg-1'); + }); + + it('keeps the newest message even when it alone exceeds the budget', () => { + pushToDevBuffer(capturedOfSize('small', 1024)); + pushToDevBuffer(capturedOfSize('huge', 1024, 70 * MIB)); + // A send must always be visible in the inbox, whatever its size. + const retained = listDevMessages(); + expect(retained).toHaveLength(1); + expect(retained[0].id).toBe('huge'); + }); + + it('resets the byte accounting in clearDevBuffer', () => { + for (let i = 1; i <= 5; i++) { + pushToDevBuffer(capturedOfSize(`big-${i}`, 8 * MIB, 8 * MIB)); + } + clearDevBuffer(); + // A stale counter would leave the buffer permanently over budget, so every + // later push would evict its predecessor and the inbox would silently show + // exactly one message. + for (let i = 1; i <= 3; i++) { + pushToDevBuffer(capturedOfSize(`small-${i}`, 1024)); + } + expect(listDevMessages().map((m) => m.id)).toEqual(['small-3', 'small-2', 'small-1']); + }); +}); diff --git a/packages/void/test/unit/email-inbound.test.ts b/packages/void/test/unit/email-inbound.test.ts new file mode 100644 index 000000000..84d16ce07 --- /dev/null +++ b/packages/void/test/unit/email-inbound.test.ts @@ -0,0 +1,3490 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { join } from 'pathslash'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { compileRouteTable } from '../../src/router/compile.ts'; +import { compileWorkerEntry } from '../../src/router/compile.ts'; +import { + compileEmailPattern, + hasEmailHandlers, + scanEmailHandlers, + scanEmailHandlersSync, +} from '../../src/router/email.ts'; +import * as emailBarrel from '../../src/runtime/email.ts'; +import { + buildReplyMessage, + createMessageProxy, + defineEmail, + MAX_INBOUND_BYTES, + MAX_INBOUND_HEADERS, + parseEmail, + ParseEmailError, + replyEmail, + type EmailEnvelope, +} from '../../src/runtime/email/inbound.ts'; +import { MAX_MESSAGE_BYTES, MimeError } from '../../src/runtime/email/mime.ts'; + +/** The one shape message the address rule (`email-validator`) throws; it never echoes the address. */ +const INVALID_ADDRESS = + 'address is not a valid email address (ASCII local part and dotted domain, RFC 5322 dot-atom; ' + + "at most 64 characters before the '@' and 254 in total)."; +import { createInboundTestHarness } from '../../src/runtime/email/testing.ts'; +import { envContext } from '../../src/runtime/env-raw.ts'; + +const TMP = join(import.meta.dirname, '.tmp-email-inbound'); + +function setupRoot(files: Record): string { + rmSync(TMP, { recursive: true, force: true }); + mkdirSync(join(TMP, 'email'), { recursive: true }); + for (const [path, content] of Object.entries(files)) { + const abs = join(TMP, path); + mkdirSync(join(abs, '..'), { recursive: true }); + writeFileSync(abs, content); + } + return TMP; +} + +afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); +}); + +describe('scanEmailHandlers', () => { + it('returns empty when no email/ files exist', async () => { + const root = setupRoot({}); + const scan = await scanEmailHandlers(root); + expect(scan.addresses).toEqual([]); + expect(scan.defaultHandler).toBeUndefined(); + expect(hasEmailHandlers(scan)).toBe(false); + }); + + it('detects per-address handlers under email/', async () => { + const root = setupRoot({ + 'email/support.ts': 'export default () => {}', + 'email/billing.ts': 'export default () => {}', + }); + const scan = await scanEmailHandlers(root); + expect(scan.addresses.map((a) => a.address)).toEqual(['billing', 'support']); + expect(scan.addresses[0].filePath).toBe('billing.ts'); + expect(hasEmailHandlers(scan)).toBe(true); + }); + + it('detects email/_default.ts as fallback', async () => { + const root = setupRoot({ + 'email/support.ts': 'export default () => {}', + 'email/_default.ts': 'export default () => {}', + }); + const scan = await scanEmailHandlers(root); + expect(scan.defaultHandler).toBe('_default.ts'); + expect(scan.addresses.map((a) => a.address)).toEqual(['support']); + }); + + it('lowercases address names for case-insensitive routing', async () => { + const root = setupRoot({ + 'email/Support.ts': 'export default () => {}', + }); + const scan = await scanEmailHandlers(root); + expect(scan.addresses[0].address).toBe('support'); + }); + + it('ignores nested folders', async () => { + const root = setupRoot({ + 'email/sub/nested.ts': 'export default () => {}', + 'email/support.ts': 'export default () => {}', + }); + const scan = await scanEmailHandlers(root); + expect(scan.addresses.map((a) => a.address)).toEqual(['support']); + }); + + it('sync variant returns the same shape', () => { + const root = setupRoot({ + 'email/support.ts': 'export default () => {}', + }); + const scan = scanEmailHandlersSync(root); + expect(scan.addresses[0].address).toBe('support'); + }); + + it('compiles tagged + dynamic patterns and orders by specificity', async () => { + const root = setupRoot({ + 'email/support.ts': 'export default () => {}', + 'email/support+[ticket].ts': 'export default () => {}', + 'email/[user].ts': 'export default () => {}', + 'email/[user]+[tag].ts': 'export default () => {}', + }); + const scan = await scanEmailHandlers(root); + expect(scan.addresses.map((a) => a.address)).toEqual([ + 'support+[ticket]', + 'support', + '[user]+[tag]', + '[user]', + ]); + expect(scan.addresses.map((a) => a.pattern.kind)).toEqual([ + 'static-tag', + 'static', + 'dynamic-tag', + 'dynamic', + ]); + expect(scan.addresses[0].pattern.regexSource).toBe('^support\\+([^+@]+)$'); + expect(scan.addresses[0].pattern.paramNames).toEqual(['ticket']); + expect(scan.addresses[2].pattern.regexSource).toBe('^([^+@]+)\\+([^+@]+)$'); + expect(scan.addresses[2].pattern.paramNames).toEqual(['user', 'tag']); + }); + + it('sorts a literal tag ahead of a captured tag of the same kind', async () => { + // Both compile to `static-tag`, both match `support+vip@`, and the + // dispatcher returns on the first hit. Alphabetically `[` sorts before + // `v`, which used to put the capture first and leave the exact handler + // dead for every delivery. + const root = setupRoot({ + 'email/support+[tag].ts': 'export default () => {}', + 'email/support+vip.ts': 'export default () => {}', + }); + const scan = await scanEmailHandlers(root); + expect(scan.addresses.map((a) => a.address)).toEqual(['support+vip', 'support+[tag]']); + expect(scan.addresses.map((a) => a.pattern.kind)).toEqual(['static-tag', 'static-tag']); + }); + + it('sorts [user]+vip ahead of [user]+[tag]', async () => { + const root = setupRoot({ + 'email/[user]+[tag].ts': 'export default () => {}', + 'email/[user]+vip.ts': 'export default () => {}', + }); + const scan = await scanEmailHandlers(root); + expect(scan.addresses.map((a) => a.address)).toEqual(['[user]+vip', '[user]+[tag]']); + expect(scan.addresses.map((a) => a.pattern.kind)).toEqual(['dynamic-tag', 'dynamic-tag']); + }); + + it('pins the full six-shape dispatch order', async () => { + const root = setupRoot({ + 'email/[user].ts': 'export default () => {}', + 'email/[user]+[tag].ts': 'export default () => {}', + 'email/[user]+vip.ts': 'export default () => {}', + 'email/support.ts': 'export default () => {}', + 'email/support+[tag].ts': 'export default () => {}', + 'email/support+vip.ts': 'export default () => {}', + }); + const scan = await scanEmailHandlers(root); + expect(scan.addresses.map((a) => a.address)).toEqual([ + 'support+vip', + 'support+[tag]', + 'support', + '[user]+vip', + '[user]+[tag]', + '[user]', + ]); + // The generated dispatcher returns on the first regex that matches, so + // the exact handler must be the first one that matches `support+vip`. + const first = scan.addresses.find((a) => + new RegExp(a.pattern.regexSource, 'i').test('support+vip'), + ); + expect(first?.address).toBe('support+vip'); + }); + + it('skips files with invalid pattern shapes', async () => { + const root = setupRoot({ + 'email/support+ticket+extra.ts': 'export default () => {}', // two `+` + 'email/Bad Name.ts': 'export default () => {}', // space + 'email/[bad-name].ts': 'export default () => {}', // hyphen in param + 'email/ok.ts': 'export default () => {}', + }); + const scan = await scanEmailHandlers(root); + expect(scan.addresses.map((a) => a.address)).toEqual(['ok']); + }); + + it('rejects a second _default. file instead of letting glob order pick one', async () => { + const root = setupRoot({ + 'email/_default.ts': 'export default () => {}', + 'email/_default.js': 'export default () => {}', + }); + // Glob order is readdir order, so either file may be reported as the + // "second" one — the error must name both regardless. + await expect(scanEmailHandlers(root)).rejects.toThrow(/is a second default handler next to/); + await expect(scanEmailHandlers(root)).rejects.toThrow(/"_default\.ts"/); + await expect(scanEmailHandlers(root)).rejects.toThrow(/"_default\.js"/); + await expect(scanEmailHandlers(root)).rejects.toThrow( + /keep exactly one email\/_default\.\* file/, + ); + expect(() => scanEmailHandlersSync(root)).toThrow(/is a second default handler/); + }); + + it('matches _default exactly: _Default.ts and _default.test.ts are private helpers, not a collision', async () => { + const root = setupRoot({ + 'email/_default.mjs': 'export default () => {}', + 'email/_Default.ts': 'export default () => {}', + 'email/_default.test.ts': 'export default () => {}', + }); + const scan = await scanEmailHandlers(root); + expect(scan.defaultHandler).toBe('_default.mjs'); + expect(scan.addresses).toEqual([]); + }); +}); + +describe('compileEmailPattern', () => { + it('compiles static address', () => { + expect(compileEmailPattern('support')).toEqual({ + kind: 'static', + regexSource: '^support$', + paramNames: [], + }); + }); + + it('compiles static + tag', () => { + expect(compileEmailPattern('support+[ticket]')).toEqual({ + kind: 'static-tag', + regexSource: '^support\\+([^+@]+)$', + paramNames: ['ticket'], + }); + }); + + it('compiles dynamic', () => { + expect(compileEmailPattern('[user]')).toEqual({ + kind: 'dynamic', + regexSource: '^([^+@]+)$', + paramNames: ['user'], + }); + }); + + it('compiles dynamic + tag', () => { + expect(compileEmailPattern('[user]+[tag]')).toEqual({ + kind: 'dynamic-tag', + regexSource: '^([^+@]+)\\+([^+@]+)$', + paramNames: ['user', 'tag'], + }); + }); + + it('escapes literal dots', () => { + expect(compileEmailPattern('alerts.ops')).toEqual({ + kind: 'static', + regexSource: '^alerts\\.ops$', + paramNames: [], + }); + }); + + it('returns null for too many `+` separators', () => { + expect(compileEmailPattern('a+b+c')).toBeNull(); + }); + + it('returns null for invalid literal chars', () => { + expect(compileEmailPattern('hello world')).toBeNull(); + }); + + it('lowercases uppercase basenames before compilation', () => { + // Patterns are lowercased to match the lowercased recipient — uppercase + // in the filename is fine, just normalized. + expect(compileEmailPattern('[Foo]')?.paramNames).toEqual(['foo']); + expect(compileEmailPattern('Support')?.regexSource).toBe('^support$'); + }); + + it('returns null for invalid param shapes', () => { + expect(compileEmailPattern('[1abc]')).toBeNull(); // starts with digit + expect(compileEmailPattern('[]')).toBeNull(); // empty + }); +}); + +describe('defineEmail', () => { + it('is an identity helper that preserves the handler', () => { + const handler = defineEmail(async () => {}); + expect(typeof handler).toBe('function'); + }); +}); + +describe('parseEmail', () => { + it('parses a minimal RFC 5322 message via postal-mime', async () => { + const raw = + 'From: a@x.dev\r\n' + + 'To: b@y.dev\r\n' + + 'Subject: Hello\r\n' + + 'Content-Type: text/plain; charset=UTF-8\r\n' + + '\r\n' + + 'Hi there\r\n'; + const message = { + from: 'a@x.dev', + to: 'b@y.dev', + raw: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(raw)); + controller.close(); + }, + }), + } as unknown as ForwardableEmailMessage; + const parsed = await parseEmail(message); + expect(parsed.subject).toBe('Hello'); + expect(parsed.from?.address).toBe('a@x.dev'); + expect(parsed.text?.trim()).toBe('Hi there'); + }); + + it('rejects messages over the size cap before reading the stream', async () => { + const message = { + from: 'a@x.dev', + to: 'b@y.dev', + rawSize: 11 * 1024 * 1024, + raw: new ReadableStream(), + } as unknown as ForwardableEmailMessage; + await expect(parseEmail(message)).rejects.toThrow(/exceeds maximum/); + }); +}); + +describe('compileRouteTable (email)', () => { + const baseScan = { + routes: [], + middleware: [], + jobs: [], + queues: [], + pages: { pages: [], layouts: [], namedLayouts: [] }, + }; + // `emailDir` rides on `CompileOptions` rather than `compileRouteTable`'s + // positional list — see the note on the field in router/compile.ts. + const baseOptions = { + honoPath: 'hono', + responsePath: 'void/response', + handlerPath: 'void/handler', + runtimeEnvPath: 'void/_env', + projectRoot: '/project', + homeDir: '/home/test', + reactRefresh: false, + auth: null, + nodeTarget: false, + emailDir: '/e', + }; + + it('emits __emailHandler with _default only', () => { + const code = compileRouteTable( + { ...baseScan, email: { addresses: [], defaultHandler: '_default.ts' } }, + '/r', + '/m', + '/j', + '/q', + null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + baseOptions as any, + ); + expect(code).toContain('export async function __emailHandler'); + expect(code).toContain('"/e/_default.ts"'); + expect(code).toContain('__emailDefault'); + }); + + it('emits per-address dispatch via __emailPatterns regex table', () => { + const code = compileRouteTable( + { + ...baseScan, + email: { + addresses: [ + { + address: 'support', + filePath: 'support.ts', + pattern: { kind: 'static', regexSource: '^support$', paramNames: [] }, + }, + { + address: 'billing', + filePath: 'billing.ts', + pattern: { kind: 'static', regexSource: '^billing$', paramNames: [] }, + }, + ], + }, + }, + '/r', + '/m', + '/j', + '/q', + null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + baseOptions as any, + ); + expect(code).toContain('__emailPatterns'); + expect(code).toContain('^support$'); + expect(code).toContain('^billing$'); + expect(code).toContain('"/e/support.ts"'); + expect(code).toContain('"/e/billing.ts"'); + expect(code).toContain('mod.default(message, env, ctx, { params'); + }); + + it('emits param extraction for tagged + dynamic patterns', () => { + const code = compileRouteTable( + { + ...baseScan, + email: { + addresses: [ + { + address: 'support+[ticket]', + filePath: 'support+[ticket].ts', + pattern: { + kind: 'static-tag', + regexSource: '^support\\+([^+@]+)$', + paramNames: ['ticket'], + }, + }, + { + address: '[user]', + filePath: '[user].ts', + pattern: { kind: 'dynamic', regexSource: '^([^+@]+)$', paramNames: ['user'] }, + }, + ], + }, + }, + '/r', + '/m', + '/j', + '/q', + null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + baseOptions as any, + ); + expect(code).toContain('"ticket"'); + expect(code).toContain('"user"'); + expect(code).toContain('^support\\\\+([^+@]+)$'); + expect(code).toContain('^([^+@]+)$'); + }); + + it('omits the email block entirely when no handlers exist', () => { + const code = compileRouteTable( + { ...baseScan, email: { addresses: [] } }, + '/r', + '/m', + '/j', + '/q', + null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + baseOptions as any, + ); + expect(code).not.toContain('__emailHandler'); + }); +}); + +/** + * Executes the GENERATED `__emailHandler` instead of asserting on its source. + * + * Every other codegen test here is string containment, which is exactly why the + * double slug strip survived: `createMessageProxy` removed the `+` prefix + * from `message.to`, the dispatcher removed it a second time, and the resulting + * empty tag matched no pattern. Both halves were individually "correct" and the + * seam between them was untested. + */ +function buildEmailDispatcher( + code: string, + modules: Record, +): (message: unknown, env: unknown, ctx: unknown, opts?: { slug?: string }) => Promise { + const start = code.indexOf('const __email'); + const fnStart = code.indexOf('export async function __emailHandler'); + expect(start).toBeGreaterThan(-1); + expect(fnStart).toBeGreaterThan(start); + const fnEnd = code.indexOf('\n}\n', fnStart); + expect(fnEnd).toBeGreaterThan(fnStart); + + const body = code + .slice(start, fnEnd + 3) + .replace('export async function __emailHandler', 'async function __emailHandler') + .replace(/\(\) => import\((".*?")\)/g, '() => __modules[$1]'); + + // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func + return new Function('__modules', `${body}\nreturn __emailHandler;`)(modules) as never; +} + +describe('generated __emailHandler dispatch (behavioural)', () => { + const baseScan = { + routes: [], + middleware: [], + jobs: [], + queues: [], + pages: { pages: [], layouts: [], namedLayouts: [] }, + }; + + const baseOptions = { + honoPath: 'hono', + responsePath: 'void/response', + handlerPath: 'void/handler', + runtimeEnvPath: 'void/_env', + projectRoot: '/project', + homeDir: '/home/test', + reactRefresh: false, + auth: null, + nodeTarget: false, + emailDir: '/e', + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function compile(email: any): string { + return compileRouteTable( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { ...baseScan, email } as any, + '/r', + '/m', + '/j', + '/q', + null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + baseOptions as any, + ); + } + + const SUPPORT = { + addresses: [ + { + address: 'support', + filePath: 'support.ts', + pattern: { kind: 'static', regexSource: '^support$', paramNames: [] }, + }, + { + address: 'support+[ticket]', + filePath: 'support+[ticket].ts', + pattern: { + kind: 'dynamic-tag', + regexSource: '^support\\+([^+@]+)$', + paramNames: ['ticket'], + }, + }, + ], + defaultHandler: undefined, + }; + + it('routes a platform recipient to its handler after createMessageProxy strips the slug', async () => { + const calls: Array<{ to: string; params: unknown }> = []; + const dispatch = buildEmailDispatcher(compile(SUPPORT), { + '/e/support.ts': { + default: ( + message: { to: string }, + _env: unknown, + _ctx: unknown, + info: { params: unknown }, + ) => { + calls.push({ to: message.to, params: info.params }); + }, + }, + '/e/support+[ticket].ts': { default: () => calls.push({ to: 'WRONG', params: null }) }, + }); + + // The wire recipient the platform delivers, for project slug `acme`. + const { message } = createMessageProxy( + { + from: 'sender@example.com', + to: 'acme+support@mail.void.cloud', + headers: {}, + raw: new Uint8Array(), + rawSize: 0, + }, + { slug: 'acme' }, + ); + + // The user-visible address has the slug removed - that is the DX contract. + expect(message.to).toBe('support@mail.void.cloud'); + + await dispatch(message, {}, {}, { slug: 'acme' }); + + expect(calls).toEqual([{ to: 'support@mail.void.cloud', params: {} }]); + }); + + it('captures the tag parameter from the wire recipient, not the stripped one', async () => { + const seen: Array = []; + const dispatch = buildEmailDispatcher(compile(SUPPORT), { + '/e/support.ts': { default: () => seen.push('static') }, + '/e/support+[ticket].ts': { + default: (_m: unknown, _e: unknown, _c: unknown, info: { params: unknown }) => + seen.push(info.params), + }, + }); + + const { message } = createMessageProxy( + { + from: 'sender@example.com', + to: 'acme+support+T-42@mail.void.cloud', + headers: {}, + raw: new Uint8Array(), + rawSize: 0, + }, + { slug: 'acme' }, + ); + + await dispatch(message, {}, {}, { slug: 'acme' }); + + expect(seen).toEqual([{ ticket: 'T-42' }]); + }); + + it('matches the full local part when the recipient carries no + prefix', async () => { + // Own-account delivery: Cloudflare hands the worker the address the + // handler author wrote, with no platform slug in front of it. Nothing + // was stripped, so nothing is stripped again — `support@` is `support`. + const hits: Array = []; + const dispatch = buildEmailDispatcher(compile({ ...SUPPORT, defaultHandler: '_default.ts' }), { + '/e/support.ts': { default: () => hits.push('support') }, + '/e/support+[ticket].ts': { default: () => hits.push('ticket') }, + '/e/_default.ts': { default: () => hits.push('default') }, + }); + + const { message } = createMessageProxy({ + from: 'sender@example.com', + to: 'support@mail.acme.com', + headers: {}, + raw: new Uint8Array(), + rawSize: 0, + }); + + await dispatch(message, {}, {}); + + expect(hits).toEqual(['support']); + }); + + it('keeps the tag of an unprefixed recipient: support+T-42@zone reaches support+[ticket]', async () => { + // The flagship own-account case. The first `+` here belongs to the + // TAG, not to a platform slug; stripping through it (the platform rule) + // would leave `T-42`, which matches nothing, and the mail would land in + // `_default`. Case is preserved in the captured param. + const seen: Array = []; + const dispatch = buildEmailDispatcher(compile({ ...SUPPORT, defaultHandler: '_default.ts' }), { + '/e/support.ts': { default: () => seen.push('static') }, + '/e/support+[ticket].ts': { + default: (_m: unknown, _e: unknown, _c: unknown, info: { params: unknown }) => + seen.push(info.params), + }, + '/e/_default.ts': { default: () => seen.push('default') }, + }); + + const { message } = createMessageProxy({ + from: 'sender@example.com', + to: 'support+T-42@mail.acme.com', + headers: {}, + raw: new Uint8Array(), + rawSize: 0, + }); + + await dispatch(message, {}, {}); + + expect(seen).toEqual([{ ticket: 'T-42' }]); + }); + + it('routes an unprefixed recipient nothing matches to _default', async () => { + const hits: Array = []; + const dispatch = buildEmailDispatcher(compile({ ...SUPPORT, defaultHandler: '_default.ts' }), { + '/e/support.ts': { default: () => hits.push('support') }, + '/e/support+[ticket].ts': { default: () => hits.push('ticket') }, + '/e/_default.ts': { default: () => hits.push('default') }, + }); + + const { message } = createMessageProxy({ + from: 'sender@example.com', + to: 'billing@mail.acme.com', + headers: {}, + raw: new Uint8Array(), + rawSize: 0, + }); + + await dispatch(message, {}, {}); + + expect(hits).toEqual(['default']); + }); + + it('still strips the platform prefix when a slug was removed and no tag follows', async () => { + // `+@` with the slug injected: the prefix is recorded as + // `__voidPlatformTo`, the tag after it is empty, and the empty string + // must fall through to `_default` rather than match `support`. + const hits: Array = []; + const dispatch = buildEmailDispatcher(compile({ ...SUPPORT, defaultHandler: '_default.ts' }), { + '/e/support.ts': { default: () => hits.push('support') }, + '/e/support+[ticket].ts': { default: () => hits.push('ticket') }, + '/e/_default.ts': { default: () => hits.push('default') }, + }); + + const { message } = createMessageProxy( + { + from: 'sender@example.com', + to: 'acme+@mail.void.cloud', + headers: {}, + raw: new Uint8Array(), + rawSize: 0, + }, + { slug: 'acme' }, + ); + + await dispatch(message, {}, {}, { slug: 'acme' }); + + expect(hits).toEqual(['default']); + }); + + it('reads a bare @ on the platform as an EMPTY tag, never as the slug itself', async () => { + // The platform router forwards a `+`-less recipient (the bare project address) with the + // slug configured but nothing to strip, so `createMessageProxy` records no + // `__voidPlatformTo`. Keyed on the marker, the dispatcher would match the full local part + // and hand `email/[user].ts` `user = acme`; keyed on the configured slug, it is `_default`. + const hits: Array = []; + const withDynamic = { + addresses: [ + ...SUPPORT.addresses, + { + address: '[user]', + filePath: '[user].ts', + pattern: { kind: 'dynamic', regexSource: '^([^+@]+)$', paramNames: ['user'] }, + }, + ], + defaultHandler: '_default.ts', + }; + const modules = { + '/e/support.ts': { default: () => hits.push('support') }, + '/e/support+[ticket].ts': { default: () => hits.push('ticket') }, + '/e/[user].ts': { + default: (_m: unknown, _e: unknown, _c: unknown, info: { params: { user: string } }) => + hits.push(`user=${info.params.user}`), + }, + '/e/_default.ts': { default: () => hits.push('default') }, + }; + const dispatch = buildEmailDispatcher(compile(withDynamic), modules); + const bare = createMessageProxy( + { + from: 'sender@example.com', + to: 'acme@mail.void.cloud', + headers: {}, + raw: new Uint8Array(), + rawSize: 0, + }, + { slug: 'acme' }, + ).message; + expect((bare as { __voidPlatformTo?: string }).__voidPlatformTo).toBeUndefined(); + + await dispatch(bare, {}, {}, { slug: 'acme' }); + expect(hits).toEqual(['default']); + + // And with no `_default`, the bare address is rejected rather than routed to `[user]`. + const rejections: Array = []; + const strict = buildEmailDispatcher( + compile({ ...withDynamic, defaultHandler: undefined }), + modules, + ); + (bare as { setReject: (reason: string) => void }).setReject = (reason) => { + rejections.push(reason); + }; + hits.length = 0; + await strict(bare, {}, {}, { slug: 'acme' }); + expect(hits).toEqual([]); + expect(rejections).toHaveLength(1); + + // Own account (no slug configured): `acme@` IS the local part, so `[user]` gets it. + hits.length = 0; + await dispatch( + createMessageProxy({ + from: 'sender@example.com', + to: 'acme@mail.acme.com', + headers: {}, + raw: new Uint8Array(), + rawSize: 0, + }).message, + {}, + {}, + ); + expect(hits).toEqual(['user=acme']); + }); + + it('rejects an unmatched recipient when the app has no _default handler', async () => { + const dispatch = buildEmailDispatcher(compile(SUPPORT), { + '/e/support.ts': { default: () => undefined }, + '/e/support+[ticket].ts': { default: () => undefined }, + }); + + const rejections: Array = []; + const { message } = createMessageProxy( + { + from: 'sender@example.com', + to: 'acme+billing@mail.void.cloud', + headers: {}, + raw: new Uint8Array(), + rawSize: 0, + }, + { slug: 'acme' }, + ); + (message as { setReject: (reason: string) => void }).setReject = (reason) => { + rejections.push(reason); + }; + + await dispatch(message, {}, {}, { slug: 'acme' }); + + expect(rejections).toHaveLength(1); + expect(rejections[0]).toContain('no handler matched recipient'); + }); + + it('runs the exact-tag handler for support+vip@ when a capturing sibling exists (real scan)', async () => { + // Scanner → codegen → dispatch, end to end. The hand-built scans above + // fix their own order; this one takes the order `scanEmailHandlers` + // produces, which is the only thing the generated loop relies on. + const root = setupRoot({ + 'email/[user].ts': 'export default () => {}', + 'email/[user]+[tag].ts': 'export default () => {}', + 'email/[user]+vip.ts': 'export default () => {}', + 'email/support.ts': 'export default () => {}', + 'email/support+[tag].ts': 'export default () => {}', + 'email/support+vip.ts': 'export default () => {}', + }); + const scan = await scanEmailHandlers(root); + const hits: Array = []; + const dispatch = buildEmailDispatcher(compile(scan), { + '/e/[user].ts': { default: () => hits.push('[user]') }, + '/e/[user]+[tag].ts': { default: () => hits.push('[user]+[tag]') }, + '/e/[user]+vip.ts': { default: () => hits.push('[user]+vip') }, + '/e/support.ts': { default: () => hits.push('support') }, + '/e/support+[tag].ts': { default: () => hits.push('support+[tag]') }, + '/e/support+vip.ts': { default: () => hits.push('support+vip') }, + }); + + const { message } = createMessageProxy( + { + from: 'sender@example.com', + to: 'acme+support+vip@mail.void.cloud', + headers: {}, + raw: new Uint8Array(), + rawSize: 0, + }, + { slug: 'acme' }, + ); + await dispatch(message, {}, {}, { slug: 'acme' }); + expect(hits).toEqual(['support+vip']); + + const { message: other } = createMessageProxy( + { + from: 'sender@example.com', + to: 'acme+support+T-42@mail.void.cloud', + headers: {}, + raw: new Uint8Array(), + rawSize: 0, + }, + { slug: 'acme' }, + ); + await dispatch(other, {}, {}, { slug: 'acme' }); + expect(hits).toEqual(['support+vip', 'support+[tag]']); + }); +}); + +describe('compileWorkerEntry (email)', () => { + it('adds email export and import when hasEmail is true', () => { + const code = compileWorkerEntry({ hasEmail: true }); + expect(code).toContain(', __emailHandler'); + expect(code).toContain( + 'async email(envelope) { return handleEmail(envelope, this.env, this.ctx); }', + ); + expect(code).toContain('async function handleEmail'); + }); + + it('omits the email export when hasEmail is false', () => { + const code = compileWorkerEntry({ hasEmail: false }); + expect(code).not.toContain('handleEmail'); + expect(code).not.toContain('__emailHandler'); + }); + + it('co-exists with queue export when both present', () => { + const code = compileWorkerEntry({ hasQueues: true, hasEmail: true }); + expect(code).toContain('async queue(batch) { return __voidQueue(batch, this.env, this.ctx); }'); + expect(code).toContain( + 'async email(envelope) { return handleEmail(envelope, this.env, this.ctx); }', + ); + }); +}); + +describe('createInboundTestHarness', () => { + it('routes to a static handler and passes empty params', async () => { + let receivedInfo: { params: Record } | undefined; + const support = vi.fn( + async ( + _m: ForwardableEmailMessage, + _e: unknown, + _c: ExecutionContext, + info?: { params: Record }, + ) => { + receivedInfo = info; + }, + ); + const harness = createInboundTestHarness({ routes: { support } }); + // Production: every recipient is `+@mail.void.cloud`; the + // dispatcher strips through the first `+` before matching, so the + // handler-side tag is `support`. Tests mirror the wire format. + const result = await harness.deliver({ from: 'a@x.dev', to: 'myslug+support@app.dev' }); + expect(result.handler).toBe('support'); + expect(result.params).toEqual({}); + expect(support).toHaveBeenCalledTimes(1); + expect(receivedInfo?.params).toEqual({}); + }); + + it('routes to static + tag and extracts the tag param', async () => { + const handler = vi.fn(async () => {}); + const harness = createInboundTestHarness({ routes: { 'support+[ticket]': handler } }); + const result = await harness.deliver({ + from: 'u@x.dev', + to: 'myslug+support+abc-123@app.dev', + }); + expect(result.handler).toBe('support+[ticket]'); + expect(result.params).toEqual({ ticket: 'abc-123' }); + }); + + it('routes to dynamic + tag and extracts both params', async () => { + const handler = vi.fn(async () => {}); + const harness = createInboundTestHarness({ routes: { '[user]+[tag]': handler } }); + const result = await harness.deliver({ from: 'u@x.dev', to: 'myslug+alice+welcome@app.dev' }); + expect(result.params).toEqual({ user: 'alice', tag: 'welcome' }); + }); + + it('prefers static-tag over static and dynamic', async () => { + const staticTag = vi.fn(async () => {}); + const staticHandler = vi.fn(async () => {}); + const dynamic = vi.fn(async () => {}); + const harness = createInboundTestHarness({ + routes: { + 'support+[ticket]': staticTag, + support: staticHandler, + '[user]': dynamic, + }, + }); + await harness.deliver({ from: 'u@x.dev', to: 'myslug+support+1@app.dev' }); + expect(staticTag).toHaveBeenCalled(); + expect(staticHandler).not.toHaveBeenCalled(); + expect(dynamic).not.toHaveBeenCalled(); + + await harness.deliver({ from: 'u@x.dev', to: 'myslug+support@app.dev' }); + expect(staticHandler).toHaveBeenCalled(); + + await harness.deliver({ from: 'u@x.dev', to: 'myslug+random@app.dev' }); + expect(dynamic).toHaveBeenCalled(); + }); + + it('prefers an exact tag over a captured tag of the same shape', async () => { + const exact = vi.fn(async () => {}); + const captured = vi.fn(async () => {}); + // Keys are declared capture-first on purpose: object order must not + // decide, and neither may the alphabetical `[` < `v` tie-break. + const harness = createInboundTestHarness({ + routes: { 'support+[tag]': captured, 'support+vip': exact }, + }); + + const vip = await harness.deliver({ from: 'u@x.dev', to: 'myslug+support+vip@app.dev' }); + expect(vip.handler).toBe('support+vip'); + expect(vip.params).toEqual({}); + expect(exact).toHaveBeenCalledTimes(1); + expect(captured).not.toHaveBeenCalled(); + + const other = await harness.deliver({ from: 'u@x.dev', to: 'myslug+support+T-42@app.dev' }); + expect(other.handler).toBe('support+[tag]'); + expect(other.params).toEqual({ tag: 'T-42' }); + expect(exact).toHaveBeenCalledTimes(1); + }); + + it('prefers [user]+vip over [user]+[tag]', async () => { + const exact = vi.fn(async () => {}); + const captured = vi.fn(async () => {}); + const harness = createInboundTestHarness({ + routes: { '[user]+[tag]': captured, '[user]+vip': exact }, + }); + + const vip = await harness.deliver({ from: 'u@x.dev', to: 'myslug+alice+vip@app.dev' }); + expect(vip.handler).toBe('[user]+vip'); + expect(vip.params).toEqual({ user: 'alice' }); + expect(captured).not.toHaveBeenCalled(); + + const other = await harness.deliver({ from: 'u@x.dev', to: 'myslug+alice+welcome@app.dev' }); + expect(other.handler).toBe('[user]+[tag]'); + expect(other.params).toEqual({ user: 'alice', tag: 'welcome' }); + }); + + it('falls through to _default when no per-address pattern matches', async () => { + const def = vi.fn(async () => {}); + const harness = createInboundTestHarness({ routes: { _default: def } }); + const result = await harness.deliver({ from: 'u@x.dev', to: 'unknown@app.dev' }); + expect(result.handler).toBe('_default'); + expect(def).toHaveBeenCalled(); + }); + + it('prefers a matching per-address handler over _default', async () => { + const def = vi.fn(async () => {}); + const support = vi.fn(async () => {}); + const harness = createInboundTestHarness({ routes: { support, _default: def } }); + const result = await harness.deliver({ from: 'u@x.dev', to: 'myslug+support@app.dev' }); + expect(result.handler).toBe('support'); + expect(support).toHaveBeenCalled(); + expect(def).not.toHaveBeenCalled(); + }); + + it('returns undefined handler when nothing matches', async () => { + const harness = createInboundTestHarness({ routes: {} }); + const result = await harness.deliver({ from: 'u@x.dev', to: 'unknown@app.dev' }); + expect(result.handler).toBeUndefined(); + }); + + it('walks back forward/reply when setReject fires later in the same dispatch', async () => { + // Production: setReject is terminal — forward/reply queued before it + // never execute on the real ForwardableEmailMessage. The harness + // mirrors that by popping the prior entries off the public arrays so + // assertions match what the real router would actually enact. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const handler = defineEmail(async (message) => { + await message.forward('ops@x.dev'); + message.setReject('not allowed'); + }); + const harness = createInboundTestHarness({ routes: { _default: handler } }); + await harness.deliver({ from: 'u@x.dev', to: 'anything@app.dev' }); + expect(harness.rejects).toEqual(['not allowed']); + expect(harness.forwards).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('forward/reply'); + } finally { + warn.mockRestore(); + } + }); + + it('setReject is terminal: forward/reply after setReject are dropped', async () => { + // Production's createMessageProxy short-circuits forward/reply once + // setReject has fired (warns + returns). The harness must mirror + // that — without it, a buggy handler that calls setReject then + // forward passes the harness tests but in production CF only + // honors the reject and silently discards the forward. + const handler = defineEmail(async (message) => { + message.setReject('not allowed'); + await message.forward('ops@x.dev'); + await message.reply({ from: 'support@app.dev', to: 'u@x.dev' } as never); + }); + const harness = createInboundTestHarness({ routes: { _default: handler } }); + await harness.deliver({ from: 'u@x.dev', to: 'anything@app.dev' }); + expect(harness.rejects).toEqual(['not allowed']); + expect(harness.forwards).toEqual([]); + expect(harness.replies).toEqual([]); + }); + + it('setReject is idempotent — subsequent setReject calls are dropped', async () => { + const handler = defineEmail(async (message) => { + message.setReject('first reason'); + message.setReject('second reason ignored'); + }); + const harness = createInboundTestHarness({ routes: { _default: handler } }); + await harness.deliver({ from: 'u@x.dev', to: 'anything@app.dev' }); + expect(harness.rejects).toEqual(['first reason']); + }); + + it('throws on invalid route key', () => { + expect(() => createInboundTestHarness({ routes: { 'a+b+c': async () => {} } })).toThrow( + /not a valid email pattern/, + ); + }); + + it('matches the recipient case-insensitively', async () => { + const handler = vi.fn(async () => {}); + const harness = createInboundTestHarness({ routes: { support: handler } }); + const result = await harness.deliver({ from: 'u@x.dev', to: 'MYSLUG+SUPPORT@APP.DEV' }); + expect(result.handler).toBe('support'); + }); + + // The harness is the only thing a user tests their handler against, so a + // captured tag has to arrive with the case production would give it. The + // codegen twin of this assertion is 'captures the tag parameter from the + // wire recipient, not the stripped one' above; the two must agree or a + // green test suite says nothing about the deployed behavior. + it('preserves the case of a captured tag, like the generated dispatcher', async () => { + let seen: unknown; + const harness = createInboundTestHarness({ + routes: { + 'support+[ticket]': async ( + _message: unknown, + _env: unknown, + _ctx: unknown, + info?: { params: Record }, + ) => { + seen = info?.params; + }, + }, + }); + + const result = await harness.deliver({ from: 'u@x.dev', to: 'myslug+support+T-42@app.dev' }); + + expect(result.handler).toBe('support+[ticket]'); + expect(result.params).toEqual({ ticket: 'T-42' }); + expect(seen).toEqual({ ticket: 'T-42' }); + }); +}); + +describe('harness enforces the proxy action bounds, like production', () => { + // The handler under test runs against production's own `createMessageProxy` + // in both lanes (the generated `email()` entry builds it from the envelope), + // and that proxy is where the queue cap, the address cap, the header-record + // check and the reply bounds throw. A harness that recorded every call + // instead passed a handler that fails on the 101st forward after deploy — + // fourth divergence of this shape on this feature. These pin that the + // harness throws the SAME error (class, code, message) at the same call. + function harnessFor(handler: Parameters[0]) { + return createInboundTestHarness({ routes: { _default: defineEmail(handler) } }); + } + const DELIVERY = { from: 'u@x.dev', to: 'myslug+anything@app.dev' }; + + it('throws the proxy error on the 101st queued action, and keeps the 100', async () => { + let thrown: unknown; + const harness = harnessFor(async (message) => { + for (let i = 0; i < 100; i++) { + await message.forward(`box${i}@example.com`); + } + try { + await message.forward('overflow@example.com'); + } catch (e) { + thrown = e; + } + }); + await harness.deliver(DELIVERY); + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).code).toBe('MIME_ERROR'); + expect((thrown as MimeError).message).toBe( + 'email: handler queued more than 100 actions for one message.', + ); + expect(harness.forwards).toHaveLength(100); + expect(harness.forwards[99]).toEqual({ to: 'box99@example.com' }); + }); + + it('fails the delivery with that error when the handler does not catch it', async () => { + // Production enacts nothing from a handler that throws: the platform + // router rejects the message as a handler error, and on your own account + // the invocation fails before `enactEmailActions` runs. + const harness = harnessFor(async (message) => { + for (let i = 0; i <= 100; i++) { + await message.forward(`box${i}@example.com`); + } + }); + await expect(harness.deliver(DELIVERY)).rejects.toThrow(/handler queued more than 100 actions/); + expect(harness.forwards).toEqual([]); + expect(harness.deliveries).toEqual([]); + }); + + it('refuses a forward address over the address-length limit', async () => { + let thrown: unknown; + const over = `${'a'.repeat(320)}@example.com`; + const harness = harnessFor(async (message) => { + try { + await message.forward(over); + } catch (e) { + thrown = e; + } + }); + await harness.deliver(DELIVERY); + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).code).toBe('INVALID_TO'); + expect((thrown as MimeError).message).toBe( + `to address is ${over.length} characters, over the 320-character limit.`, + ); + expect(harness.forwards).toEqual([]); + }); + + it('refuses a forward address over the 254-character path limit, by the sendEmail rule', async () => { + // Under the 320 total above, so `forward()`'s own pre-check passes and + // the refusal comes from `validateAddressParts` — the rule an outbound + // `to` gets, inherited rather than mirrored (`email-validator` checks + // 254 before its regex). + let thrown: unknown; + const label = 'b'.repeat(63); + const over = `a@${[label, label, label, label].join('.')}`; + expect(over).toHaveLength(257); + const harness = harnessFor(async (message) => { + try { + await message.forward(over); + } catch (e) { + thrown = e; + } + }); + await harness.deliver(DELIVERY); + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).code).toBe('INVALID_TO'); + expect((thrown as MimeError).message).toBe(`to: ${INVALID_ADDRESS}`); + expect(harness.forwards).toEqual([]); + }); + + it('refuses a forward address carrying a control character, by the sendEmail rule', async () => { + // The platform router's `isValidEmailAddress` refuses `\u0000-\u001f` + // and `\u007f`, and used to be the first thing that did: `forward()` + // recorded the address, the harness captured it as a success, and the + // router dropped just that forward after earlier actions had fired. + // Both sides now apply `email-validator`; the message names the rule + // and never echoes the byte. + let thrown: unknown; + const harness = harnessFor(async (message) => { + try { + await message.forward('a\x01b@example.com'); + } catch (e) { + thrown = e; + } + }); + await harness.deliver(DELIVERY); + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).code).toBe('INVALID_TO'); + expect((thrown as MimeError).message).toBe(`to: ${INVALID_ADDRESS}`); + expect((thrown as MimeError).message).not.toContain('\x01'); + expect(harness.forwards).toEqual([]); + }); + + it('refuses a forward address carrying a colon, by the sendEmail rule', async () => { + // Same class as the control character above: `:` is an RFC 5322 + // §3.2.3 special the platform router's `isValidEmailAddress` refuses, + // and it was the one missing from the framework's address class — + // `forward('a:b@example.com')` was recorded, the harness captured it as + // a success, and the router dropped just that forward after the one + // before it had fired. + let thrown: unknown; + const harness = harnessFor(async (message) => { + await message.forward('first@example.com'); + try { + await message.forward('a:b@example.com'); + } catch (e) { + thrown = e; + } + }); + await harness.deliver(DELIVERY); + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).code).toBe('INVALID_TO'); + expect((thrown as MimeError).message).toBe(`to: ${INVALID_ADDRESS}`); + expect(harness.forwards).toEqual([{ to: 'first@example.com' }]); + }); + + it('refuses a forward header record with a CRLF in a value at the call, recording nothing for it', async () => { + let thrown: unknown; + const harness = harnessFor(async (message) => { + await message.forward('first@example.com'); + try { + await message.forward('archive@example.com', { + 'X-Trace': 'abc\r\nBcc: leak@evil.test', + } as unknown as Headers); + } catch (e) { + thrown = e; + } + // A record that `new Headers()` accepts is recorded, and the capture + // exposes it as the `Headers` its type declares. + await message.forward('archive@example.com', { + 'X-Forwarded-By': 'void-test', + } as unknown as Headers); + }); + await harness.deliver(DELIVERY); + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).code).toBe('MIME_ERROR'); + expect((thrown as MimeError).message).toMatch(/^forward: invalid header/); + expect(harness.forwards.map((f) => f.to)).toEqual(['first@example.com', 'archive@example.com']); + expect(harness.forwards[0].headers).toBeUndefined(); + expect(harness.forwards[1].headers).toBeInstanceOf(Headers); + expect(harness.forwards[1].headers?.get('X-Forwarded-By')).toBe('void-test'); + }); + + it('refuses a malformed forward address at the call, recording nothing for it', async () => { + // A harness that captured `Ops ` as a forward passed a + // handler whose deploy fails at native replay (Cloudflare) or loses the + // forward at the router (platform). Same error as `sendEmail`'s `to`. + let thrown: unknown; + const harness = harnessFor(async (message) => { + await message.forward('first@example.com'); + try { + await message.forward('Ops '); + } catch (e) { + thrown = e; + } + }); + await harness.deliver(DELIVERY); + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).code).toBe('INVALID_TO'); + expect((thrown as MimeError).message).toBe(`to: ${INVALID_ADDRESS}`); + expect(harness.forwards).toEqual([{ to: 'first@example.com' }]); + }); + + it("refuses a forward header bag over the router's bounds at the call, recording nothing for it", async () => { + // `new Headers()` accepts 33 headers, so the record-validity check + // passes them, and the managed router drops the WHOLE action list on + // the count (`forwardHeadersWithinBounds`). A harness that reported + // this forward passed a handler whose managed deployment silently + // performed no actions. + let thrown: unknown; + const harness = harnessFor(async (message) => { + await message.forward('first@example.com'); + const bag = new Headers(); + for (let i = 0; i < 33; i++) { + bag.set(`X-Void-${i}`, 'v'); + } + try { + await message.forward('archive@example.com', bag); + } catch (e) { + thrown = e; + } + }); + await harness.deliver(DELIVERY); + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).code).toBe('MIME_ERROR'); + expect((thrown as MimeError).message).toBe('forward: 33 headers, over the 32-header limit.'); + expect(harness.forwards).toEqual([{ to: 'first@example.com' }]); + }); + + it('reports the reject reason the router sends: sliced to 1000 and stripped of controls', async () => { + // A reason built from the parsed message carries what the remote sender + // put there — here a CRLF and a NUL smuggled through an RFC 2047 + // encoded-word, which `parseEmail` decodes. The managed router + // normalizes the reason (`validateActions`) before it reaches the SMTP + // reply; the harness must report that string, not the verbatim one a + // native replay used to hand `setReject()`. + const harness = harnessFor(async (message) => { + const parsed = await parseEmail(message); + message.setReject(`unwanted: ${parsed.subject}${'!'.repeat(2000)}`); + }); + await harness.deliver({ + ...DELIVERY, + raw: + 'From: u@x.dev\r\n' + + 'To: myslug+anything@app.dev\r\n' + + 'Subject: =?UTF-8?Q?spam=0D=0ABcc:_x=00?=\r\n' + + '\r\n' + + 'body\r\n', + }); + expect(harness.rejects).toHaveLength(1); + // "unwanted: spam\r\nBcc: x\0" is 23 characters, so the 1000-character + // window holds it plus 977 "!". The CRLF pair then collapses to ONE + // space and the NUL to another, and the window is not refilled from + // beyond the cut — 999 characters, the router's own trade. + expect(harness.rejects[0]).toBe(`unwanted: spam Bcc: x ${'!'.repeat(977)}`); + expect(harness.rejects[0]).toHaveLength(999); + }); + + it('reply() refuses a native EmailMessage with the proxy error', async () => { + let thrown: unknown; + const native = Object.freeze({ from: 'support@app.dev', to: 'u@x.dev' }); + const harness = harnessFor(async (message) => { + try { + await message.reply(native as unknown as Parameters[0]); + } catch (e) { + thrown = e; + } + }); + await harness.deliver(DELIVERY); + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).code).toBe('MIME_ERROR'); + expect((thrown as MimeError).message).toMatch(/native EmailMessage cannot be replayed/); + expect(harness.replies).toEqual([]); + }); + + it('reply() refuses a Uint8Array raw over MAX_MESSAGE_BYTES', async () => { + let thrown: unknown; + const harness = harnessFor(async (message) => { + try { + await message.reply({ + from: 'support@app.dev', + to: 'u@x.dev', + raw: new Uint8Array(MAX_MESSAGE_BYTES + 1), + } as unknown as Parameters[0]); + } catch (e) { + thrown = e; + } + }); + await harness.deliver(DELIVERY); + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).message).toMatch(/exceeds Cloudflare limit/); + expect(harness.replies).toEqual([]); + }); + + it('records nothing from a handler that throws, like production', async () => { + const harness = harnessFor(async (message) => { + await message.forward('ops@x.dev'); + throw new Error('boom'); + }); + await expect(harness.deliver(DELIVERY)).rejects.toThrow(/boom/); + expect(harness.forwards).toEqual([]); + expect(harness.deliveries).toEqual([]); + }); +}); + +describe('harness rejects an unmatched delivery, like production', () => { + // The generated dispatcher calls setReject when nothing matches and there + // is no _default, so the sender gets an NDR. The harness used to return + // silently, so a test for catch-all or anti-spam behavior could observe an + // ACCEPTED message where deployment bounces it. Same divergence class as + // the captured-tag casing bug: the harness has to mirror the dispatcher. + it('records the reject when nothing matches and there is no _default', async () => { + const harness = createInboundTestHarness({ routes: { 'support+[ticket]': async () => {} } }); + + const result = await harness.deliver({ from: 'u@x.dev', to: 'myslug+billing@app.dev' }); + + expect(result.handler).toBeUndefined(); + expect(harness.rejects).toHaveLength(1); + // The reason must match the dispatcher's byte for byte — it reaches the + // sender's DSN. `__to` is the WIRE address, case preserved. + expect(harness.rejects[0]).toBe( + 'no handler matched recipient "myslug+billing@app.dev" — add email/_default.ts to handle unmatched mail', + ); + }); + + it('uses the wire address with its original case in the reason', async () => { + const harness = createInboundTestHarness({ routes: { 'support+[ticket]': async () => {} } }); + + await harness.deliver({ from: 'u@x.dev', to: 'MySlug+Billing@App.Dev' }); + + expect(harness.rejects[0]).toContain('"MySlug+Billing@App.Dev"'); + }); + + it('does not reject when _default handles the unmatched message', async () => { + const handler = vi.fn(async () => {}); + const harness = createInboundTestHarness({ + routes: { 'support+[ticket]': async () => {}, _default: handler }, + }); + + const result = await harness.deliver({ from: 'u@x.dev', to: 'myslug+billing@app.dev' }); + + expect(result.handler).toBe('_default'); + expect(harness.rejects).toEqual([]); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('rejects a recipient with no tag when there is no _default', async () => { + const harness = createInboundTestHarness({ routes: { 'support+[ticket]': async () => {} } }); + + await harness.deliver({ from: 'u@x.dev', to: 'myslug@app.dev' }); + + expect(harness.rejects).toHaveLength(1); + expect(harness.rejects[0]).toContain('"myslug@app.dev"'); + }); + + it('`slug: null` matches the full local part, like a worker on your own Cloudflare account', async () => { + // Own-account deploys carry no platform prefix: `support@mail.acme.com` reaches + // `support`, and the first `+` belongs to the TAG. The default harness would read + // `support` as a slug and land a plain `support@` in `_default`. + const seen: Array = []; + const harness = createInboundTestHarness({ + slug: null, + routes: { + support: async () => { + seen.push('support'); + }, + 'support+[ticket]': async (_m, _e, _c, info) => { + seen.push(`ticket=${info?.params.ticket}`); + }, + _default: async () => { + seen.push('default'); + }, + }, + }); + + const plain = await harness.deliver({ from: 'u@x.dev', to: 'support@mail.acme.com' }); + expect(plain.handler).toBe('support'); + const tagged = await harness.deliver({ from: 'u@x.dev', to: 'support+T-42@mail.acme.com' }); + expect(tagged.handler).toBe('support+[ticket]'); + expect(tagged.params).toEqual({ ticket: 'T-42' }); + expect(seen).toEqual(['support', 'ticket=T-42']); + // Nothing was stripped from what the handler sees either. + const other = await harness.deliver({ from: 'u@x.dev', to: 'billing@mail.acme.com' }); + expect(other.handler).toBe('_default'); + }); +}); + +describe('harness headers match the bytes, like production', () => { + // In production `buildEnvelope` copies every wire header verbatim, so + // `message.headers` and `message.raw` agree by construction. The harness + // rebuilt `headers` from `input` alone, so its own synthetic MIME carried + // a Message-ID that its own `headers` did not expose — and `replyEmail` + // reads exactly that header, so a harness reply came out UNTHREADED while + // the same handler threaded correctly in production. Third divergence of + // this shape on this feature, after captured-param casing and the + // unmatched-delivery reject. + it('exposes the generated Message-ID, so replyEmail threads', async () => { + let seen: string | null = null; + const harness = createInboundTestHarness({ + routes: { + _default: async (message: { headers: Headers }) => { + seen = message.headers.get('Message-ID'); + }, + }, + }); + + await harness.deliver({ from: 'u@x.dev', to: 'myslug+support@app.dev', subject: 'Help' }); + + expect(seen).toMatch(/^<.+@.+>$/); + }); + + it('exposes headers from a caller-supplied raw MIME', async () => { + // The `raw:` override hands the harness exact wire bytes. Ignoring their + // headers is the sharpest form of the divergence: the caller supplied + // precisely what production would receive. + const raw = [ + 'Message-ID: ', + 'From: u@x.dev', + 'To: myslug+support@app.dev', + 'Subject: Help', + 'X-Custom: kept', + '', + 'body', + '', + ].join('\r\n'); + let seen: Record = {}; + const harness = createInboundTestHarness({ + routes: { + _default: async (message: { headers: Headers }) => { + seen = { + id: message.headers.get('Message-ID'), + custom: message.headers.get('X-Custom'), + }; + }, + }, + }); + + await harness.deliver({ from: 'u@x.dev', to: 'myslug+support@app.dev', raw }); + + expect(seen.id).toBe(''); + expect(seen.custom).toBe('kept'); + }); + + it('exposes Subject the way the wire carries it, not decoded', async () => { + // `buildMime` RFC 2047-encodes a non-ASCII subject and production hands + // the handler that encoded form. A handler comparing + // `message.headers.get('Subject')` — the pattern the guide shows — must + // see the same string in both places. + let seen: string | null = null; + const harness = createInboundTestHarness({ + routes: { + _default: async (message: { headers: Headers }) => { + seen = message.headers.get('Subject'); + }, + }, + }); + + await harness.deliver({ from: 'u@x.dev', to: 'myslug+support@app.dev', subject: 'café' }); + + expect(seen).toBe('=?UTF-8?B?Y2Fmw6k=?='); + }); + + it('lets an explicit input header win over the bytes', async () => { + let seen: string | null = null; + const harness = createInboundTestHarness({ + routes: { + _default: async (message: { headers: Headers }) => { + seen = message.headers.get('Message-ID'); + }, + }, + }); + + await harness.deliver({ + from: 'u@x.dev', + to: 'myslug+support@app.dev', + subject: 'Help', + headers: { 'Message-ID': '' }, + }); + + expect(seen).toBe(''); + }); +}); + +describe('admission checks mirrored from the managed router', () => { + // `routeInbound` refuses a message over `MAX_INBOUND_BYTES` (from + // `rawSize`, before the body is read) or with more than + // `MAX_INBOUND_HEADERS` distinct names (in `buildEnvelope`) and never + // dispatches it. The harness invoked the handler regardless, so a handler + // that forwards an oversized message without calling `parseEmail()` was + // green here and bounced after deploy. Now: same order, same reasons, no + // handler call. Platform lane only — an own-account worker has no router. + const TO = 'myslug+support@app.dev'; + const HEAD = `From: u@x.dev\r\nTo: ${TO}\r\nSubject: big\r\n\r\n`; + + /** A well-formed text message padded to exactly `total` bytes. */ + function rawOfSize(total: number): Uint8Array { + const head = new TextEncoder().encode(HEAD); + const out = new Uint8Array(total); + out.set(head, 0); + out.fill(0x61, head.byteLength); + return out; + } + + function rawWithHeaderLines(lines: Array): string { + return `${lines.join('\r\n')}\r\n\r\nbody\r\n`; + } + + /** `n` distinct names on the wire, `From:` and `To:` among them. */ + function distinctHeaderLines(n: number): Array { + return [ + 'From: u@x.dev', + `To: ${TO}`, + ...Array.from({ length: n - 2 }, (_, i) => `h${i.toString(36)}: v`), + ]; + } + + function forwarding() { + return vi.fn(async (message: ForwardableEmailMessage) => { + await message.forward('x@example.com'); + }); + } + + it('rejects a message one byte over the size cap without invoking the handler', async () => { + const handler = forwarding(); + const harness = createInboundTestHarness({ routes: { _default: handler } }); + + const result = await harness.deliver({ + from: 'u@x.dev', + to: TO, + raw: rawOfSize(MAX_INBOUND_BYTES + 1), + }); + + expect(handler).not.toHaveBeenCalled(); + // Byte-for-byte the router's reason: `: ...`, the zone being the + // recipient's domain. + expect(harness.rejects).toEqual(['app.dev: message too large (max 10485760 bytes)']); + expect(harness.forwards).toEqual([]); + // Recorded like a handler's own `setReject`: unhandled, one delivery. + expect(result).toEqual({ handler: undefined, params: {} }); + expect(harness.deliveries).toEqual([result]); + }); + + it('admits a message exactly at the size cap', async () => { + const handler = forwarding(); + const harness = createInboundTestHarness({ routes: { _default: handler } }); + + const result = await harness.deliver({ + from: 'u@x.dev', + to: TO, + raw: rawOfSize(MAX_INBOUND_BYTES), + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(result.handler).toBe('_default'); + expect(harness.rejects).toEqual([]); + expect(harness.forwards).toEqual([{ to: 'x@example.com' }]); + }); + + it('a handler that calls parseEmail() on a message at the cap still works', async () => { + // The router's admission and `parseEmail`'s own guard are both `>`, so + // the largest message the router dispatches is also one `parseEmail` + // reads. + let subject: string | undefined; + const harness = createInboundTestHarness({ + routes: { + _default: async (message) => { + subject = (await parseEmail(message)).subject; + await message.forward('x@example.com'); + }, + }, + }); + + await harness.deliver({ from: 'u@x.dev', to: TO, raw: rawOfSize(MAX_INBOUND_BYTES) }); + + expect(subject).toBe('big'); + expect(harness.rejects).toEqual([]); + expect(harness.forwards).toEqual([{ to: 'x@example.com' }]); + }); + + it('rejects a message with one distinct header over the cap without invoking the handler', async () => { + const handler = forwarding(); + const harness = createInboundTestHarness({ routes: { _default: handler } }); + + const result = await harness.deliver({ + from: 'u@x.dev', + to: TO, + raw: rawWithHeaderLines(distinctHeaderLines(MAX_INBOUND_HEADERS + 1)), + }); + + expect(handler).not.toHaveBeenCalled(); + expect(harness.rejects).toEqual(['app.dev: too many headers (max 1024)']); + expect(harness.forwards).toEqual([]); + expect(result).toEqual({ handler: undefined, params: {} }); + }); + + it('admits a message with exactly the header cap, every header visible', async () => { + let seen: Headers | undefined; + const harness = createInboundTestHarness({ + routes: { + _default: async (message) => { + seen = message.headers; + }, + }, + }); + + await harness.deliver({ + from: 'u@x.dev', + to: TO, + raw: rawWithHeaderLines(distinctHeaderLines(MAX_INBOUND_HEADERS)), + }); + + expect(harness.rejects).toEqual([]); + expect([...seen!.keys()]).toHaveLength(MAX_INBOUND_HEADERS); + expect(seen!.get(`h${(MAX_INBOUND_HEADERS - 3).toString(36)}`)).toBe('v'); + }); + + it.each([ + [ + 'repeated names, which a Headers bag folds into one entry', + Array.from({ length: MAX_INBOUND_HEADERS + 1 }, () => 'Received: from a hop'), + ], + [ + 'continuation lines, which belong to the header before them', + ['Subject: a', ...Array.from({ length: MAX_INBOUND_HEADERS + 1 }, () => ' folded')], + ], + [ + 'the same name in different case', + Array.from({ length: MAX_INBOUND_HEADERS + 1 }, (_, i) => + i % 2 ? 'x-void: b' : 'X-Void: a', + ), + ], + ])('counts distinct names, like the router: %s do not add up', async (_label, lines) => { + // The router walks the `Headers` Cloudflare built from the wire, and a + // `Headers` holds one entry per distinct (case-insensitive) name. + const handler = forwarding(); + const harness = createInboundTestHarness({ routes: { _default: handler } }); + + await harness.deliver({ from: 'u@x.dev', to: TO, raw: rawWithHeaderLines(lines) }); + + expect(harness.rejects).toEqual([]); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("checks size before headers, in the router's order", async () => { + // Over both caps at once: the router refuses on `rawSize` before it + // walks a single header, so that is the reason the sender sees. + const lines = distinctHeaderLines(MAX_INBOUND_HEADERS + 1); + const headBytes = new TextEncoder().encode(rawWithHeaderLines(lines)); + const raw = new Uint8Array(MAX_INBOUND_BYTES + 1); + raw.set(headBytes, 0); + raw.fill(0x61, headBytes.byteLength); + const handler = forwarding(); + const harness = createInboundTestHarness({ routes: { _default: handler } }); + + await harness.deliver({ from: 'u@x.dev', to: TO, raw }); + + expect(harness.rejects).toEqual(['app.dev: message too large (max 10485760 bytes)']); + expect(handler).not.toHaveBeenCalled(); + }); + + it("labels the reason with the recipient's domain, lowercased, like the router's zone", async () => { + const harness = createInboundTestHarness({ routes: { _default: forwarding() } }); + + await harness.deliver({ + from: 'u@x.dev', + to: 'MySlug+Support@Mail.Void.Cloud', + raw: rawOfSize(MAX_INBOUND_BYTES + 1), + }); + await harness.deliver({ + from: 'u@x.dev', + to: 'no-at-sign', + raw: rawOfSize(MAX_INBOUND_BYTES + 1), + }); + + expect(harness.rejects).toEqual([ + 'mail.void.cloud: message too large (max 10485760 bytes)', + // The router's own fallback label, for a recipient that names no zone. + 'void email: message too large (max 10485760 bytes)', + ]); + }); + + it('applies neither check on the own-account lane (`slug: null`)', async () => { + // There is no router in front of a worker on your own Cloudflare + // account: Cloudflare hands the handler every message it accepts, so a + // 10 MiB + 1 forward IS reachable there and the harness must not bounce + // it. (`parseEmail` keeps its own `MAX_MESSAGE_BYTES` guard.) + const handler = forwarding(); + const harness = createInboundTestHarness({ slug: null, routes: { _default: handler } }); + + await harness.deliver({ + from: 'u@x.dev', + to: 'support@mail.acme.com', + raw: rawOfSize(MAX_INBOUND_BYTES + 1), + }); + await harness.deliver({ + from: 'u@x.dev', + to: 'support@mail.acme.com', + raw: rawWithHeaderLines(distinctHeaderLines(MAX_INBOUND_HEADERS + 1)), + }); + + expect(handler).toHaveBeenCalledTimes(2); + expect(harness.rejects).toEqual([]); + expect(harness.forwards).toHaveLength(2); + }); +}); + +describe('reply threading survives a hostile inbound header', () => { + // `buildReplyMessage` threads the inbound `Message-ID` and `References` + // into the reply verbatim, and those are controlled by whoever sent the + // mail. The 998-octet line guard would therefore let a remote sender make + // an app's reply throw, with the app at fault for nothing. Threading is + // best-effort, so an unusable value is dropped and the reply still goes. + const HUGE = 'x'.repeat(1100); + + function inbound(headers: Record) { + const raw = new TextEncoder().encode('Subject: hi\r\n\r\nbody\r\n'); + return createMessageProxy( + { + from: 'sender@remote.dev', + to: 'acme+support@mail.void.cloud', + headers, + raw, + rawSize: raw.length, + }, + { slug: 'acme' }, + ).message; + } + + it('drops an unusable Message-ID rather than failing the reply', async () => { + const message = inbound({ 'message-id': `<${HUGE}@remote.dev>`, subject: 'hi' }); + + const reply = await buildReplyMessage(message, { text: 'thanks', from: 'acme+noreply@x.dev' }); + + const text = new TextDecoder().decode(reply.raw); + expect(text).toContain('Subject: Re: hi'); + expect(text).not.toContain(HUGE); + expect(Math.max(...text.split('\r\n').map((l) => l.length))).toBeLessThanOrEqual(998); + }); + + it('survives a References chain with more tokens than Math.max takes arguments', async () => { + // ~123k spread arguments is where V8 throws RangeError (measured on + // workerd and node). 130k short ids is 780 KB, far inside + // MAX_MESSAGE_BYTES, so a remote sender can reach it. + const chain = Array.from({ length: 130_000 }, () => '').join(' '); + const message = inbound({ references: chain, 'message-id': '', subject: 'hi' }); + + const reply = await buildReplyMessage(message, { text: 'thanks', from: 'acme+noreply@x.dev' }); + + const text = new TextDecoder().decode(reply.raw); + expect(text).toContain('In-Reply-To: '); + expect(Math.max(...text.split('\r\n').map((l) => l.length))).toBeLessThanOrEqual(998); + }); + + it('survives a Subject with more tokens than Math.max takes arguments', async () => { + // The cheaper trigger: `prefixReplySubject` collapses whitespace runs, so + // 130k single-character tokens is only 260 KB of ASCII. + const subject = Array.from({ length: 130_000 }, () => 'a').join(' '); + const message = inbound({ subject }); + + const reply = await buildReplyMessage(message, { text: 'thanks', from: 'acme+noreply@x.dev' }); + + const text = new TextDecoder().decode(reply.raw); + expect(Math.max(...text.split('\r\n').map((l) => l.length))).toBeLessThanOrEqual(998); + }); + + it('drops an unusable References chain rather than failing the reply', async () => { + const message = inbound({ references: `<${HUGE}@remote.dev>`, subject: 'hi' }); + + const reply = await buildReplyMessage(message, { text: 'thanks', from: 'acme+noreply@x.dev' }); + + expect( + Math.max( + ...new TextDecoder() + .decode(reply.raw) + .split('\r\n') + .map((l) => l.length), + ), + ).toBeLessThanOrEqual(998); + }); + + // The line guard measures octets, the way the wire does, and the only + // break it may count on is SP or HTAB — the whitespace `foldHeader` folds + // at. The chain is carried when every id fits and dropped for the bare + // message-id otherwise, and the answer must arrive in one pass with no + // per-id allocation: a remote sender can put 10 MiB of ids in one header. + const MESSAGE_ID = ''; + + async function replyText(references: string, messageId = MESSAGE_ID): Promise { + const message = inbound({ references, 'message-id': messageId, subject: 'hi' }); + const reply = await buildReplyMessage(message, { text: 'thanks', from: 'acme+noreply@x.dev' }); + return new TextDecoder().decode(reply.raw); + } + + function widestLineOctets(text: string): number { + const encoder = new TextEncoder(); + return text.split('\r\n').reduce((max, line) => Math.max(max, encoder.encode(line).length), 0); + } + + it('keeps an id that exactly fills the line, and drops one octet more', async () => { + // `References: ` is 12 octets, so 986 is the widest id one line carries. + // With no Message-ID to append, the header is that one id, unfolded — + // the only shape where the guard's budget is the wire line exactly. In a + // chain every id is charged the same 12 octets although a folded + // continuation costs 1, so the guard is conservative there, never loose. + const atBudget = `<${'x'.repeat(984)}>`; + const overBudget = `<${'x'.repeat(985)}>`; + + const kept = await replyText(atBudget, ''); + expect(kept).toContain(`References: ${atBudget}\r\n`); + expect(widestLineOctets(kept)).toBe(998); + + const dropped = await replyText(overBudget, ''); + expect(dropped).not.toContain('References:'); + expect(widestLineOctets(dropped)).toBeLessThanOrEqual(998); + }); + + it('measures an id in UTF-8 octets, not UTF-16 units', async () => { + // 495 units but 988 octets: a code-unit count would pass it. + const id = `<${'é'.repeat(493)}>`; + const text = await replyText(id); + expect(text).not.toContain(id); + expect(text).toContain(`References: ${MESSAGE_ID}`); + expect(widestLineOctets(text)).toBeLessThanOrEqual(998); + }); + + it('counts a surrogate pair as one four-octet code point', async () => { + // A `Headers` value is a ByteString, so nothing above U+00FF arrives this + // way in production; the count still has to agree with `TextEncoder`, + // which is what `buildMime` measures the rendered line with. + const id = `<${'😀'.repeat(247)}>`; // 496 units, 990 octets + const headers = new Map([ + ['subject', 'hi'], + ['message-id', MESSAGE_ID], + ['references', id], + ]); + const message = { + from: 'sender@remote.dev', + to: 'support@app.dev', + headers: { get: (name: string) => headers.get(name.toLowerCase()) ?? null }, + } as unknown as ForwardableEmailMessage; + const reply = await buildReplyMessage(message, { text: 'thanks', from: 'acme+noreply@x.dev' }); + const text = new TextDecoder().decode(reply.raw); + expect(text).not.toContain(id); + expect(text).toContain(`References: ${MESSAGE_ID}`); + }); + + it('keeps a multibyte id that fits once encoded', async () => { + const id = `<${'é'.repeat(492)}>`; // 494 units, 986 octets + const text = await replyText(id, ''); + expect(text).toContain(`References: ${id}\r\n`); + expect(widestLineOctets(text)).toBe(998); + }); + + it('does not count whitespace the folder cannot fold at as a break', async () => { + // `\s` matches NBSP and FF, but RFC 5322 §3.2.2 folds only at SP/HTAB and + // so does `foldHeader`. Splitting at either used to pass two fitting ids + // that then landed on one 1222-octet line, and `buildMime` threw. + const id = `<${'x'.repeat(600)}@remote.dev>`; + for (const separator of [' ', '\f']) { + const text = await replyText(`${id}${separator}${id}`); + expect(text).not.toContain(id); + expect(text).toContain(`References: ${MESSAGE_ID}`); + expect(widestLineOctets(text)).toBeLessThanOrEqual(998); + } + }); + + it('threads a chain at the raw cap’s scale, and every line still fits', async () => { + // 9 MiB of two-character ids is 3.1M tokens. The reply carries its newest + // 8 KiB, and the answer must not cost per token: a per-token allocation + // here was 34 bytes each, which is the isolate. + const chain = 'ab '.repeat((9 * 1024 * 1024) / 3).trimEnd(); + const text = await replyText(chain); + expect(text).toContain(`In-Reply-To: ${MESSAGE_ID}`); + expect(text).toContain('References: ab ab ab'); + expect(text).toContain(` ab ${MESSAGE_ID}`); + expect(widestLineOctets(text)).toBeLessThanOrEqual(998); + }); + + it('answers for a 10 MiB chain in one pass', async () => { + // The largest header a remote sender can deliver: MAX_MESSAGE_BYTES of + // two-character ids (3.5M tokens), with one over-long id at the very end + // so the whole chain is scanned before it is refused. `split(/\s+/)` + // materialized 3.5M tokens first — measured at 570 ms and 147 MB, which + // terminates a 128 MB isolate; the one-pass scan is 20 ms and 0 bytes. + const chain = 'ab '.repeat(Math.floor(MAX_MESSAGE_BYTES / 3)) + `<${HUGE}@remote.dev>`; + const started = performance.now(); + const text = await replyText(chain); + const elapsed = performance.now() - started; + expect(text).not.toContain(HUGE); + expect(text).toContain(`References: ${MESSAGE_ID}`); + // 34 ms here; a loaded 4-vCPU CI runner has measured 6x slower on this + // file. The materializing form took 570 ms locally and would not fit a + // 128 MB isolate at all — the memory is the real guard, the time a proxy. + expect(elapsed).toBeLessThan(1000); + }); + + // The line guard bounds one id, not the chain: 10 MiB of short ids passes + // it — every id folds — and the folded reply is then over + // `MAX_MESSAGE_BYTES`, so `buildMime` threw instead of sending the reply the + // guide promises, and the handler's earlier forwards went with it. A chain + // is bounded at 8192 characters, checked on `length` alone, and past that + // the reply keeps the NEWEST ids whole, the parent's id still last. + const REFERENCES_BUDGET = 8192; + + /** The `References` value of a rendered reply, unfolded; `undefined` when absent. */ + function referencesOf(text: string): string | undefined { + const headers = text.split('\r\n\r\n')[0].replace(/\r\n(?=[ \t])/g, ''); + return headers + .split('\r\n') + .find((line) => line.startsWith('References: ')) + ?.slice('References: '.length); + } + + it('threads the newest ids of a chain over the budget, inside a 128 MB heap', () => { + // The reviewer's shape: a `References` just under the inbound cap, of + // short ids. Measured before the bound: 156 ms, 55 MB of heap, then + // `MimeError: message size 10777129 bytes exceeds Cloudflare limit`. + const inboundModule = pathToFileURL( + join(import.meta.dirname, '../../src/runtime/email/inbound.ts'), + ).href; + const child = ` + const { buildReplyMessage, createMessageProxy } = await import(process.argv[1]); + const references = ' '.repeat(Math.floor((${MAX_MESSAGE_BYTES} - 200) / 6)).trimEnd(); + const raw = new TextEncoder().encode('body'); + const { message } = createMessageProxy( + { + from: 'sender@remote.dev', + to: 'acme+support@mail.void.cloud', + headers: { references, 'message-id': ${JSON.stringify(MESSAGE_ID)}, subject: 'hi' }, + raw, + rawSize: raw.length, + }, + { slug: 'acme' }, + ); + const heap0 = process.memoryUsage().heapUsed; + const t0 = performance.now(); + const reply = await buildReplyMessage(message, { text: 'thanks', from: 'acme+noreply@x.dev' }); + const ms = performance.now() - t0; + const heapDeltaMB = (process.memoryUsage().heapUsed - heap0) / (1024 * 1024); + console.log(JSON.stringify({ text: new TextDecoder().decode(reply.raw), ms, heapDeltaMB })); + `; + const stdout = execFileSync( + process.execPath, + ['--max-old-space-size=128', '--input-type=module', '-e', child, inboundModule], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }, + ); + const result = JSON.parse(stdout.trim().split('\n').at(-1) ?? '{}') as { + text: string; + ms: number; + heapDeltaMB: number; + }; + expect(result.text).toContain(`In-Reply-To: ${MESSAGE_ID}`); + const references = referencesOf(result.text); + expect(references).toBeDefined(); + expect(references!.length).toBeLessThanOrEqual(REFERENCES_BUDGET); + expect(references!.startsWith(' ')).toBe(true); + expect(references!.endsWith(` ${MESSAGE_ID}`)).toBe(true); + expect(widestLineOctets(result.text)).toBeLessThanOrEqual(998); + // 4 ms and 0 MB measured; the bound leaves room for CI's slower runner. + expect(result.ms).toBeLessThan(5000); + expect(result.heapDeltaMB).toBeLessThan(16); + }, 30_000); + + it('keeps the whole chain at the budget, and drops the oldest id one character over', async () => { + // 900 eight-character ids and one 75-character first id: 8175 characters, + // which with the parent's ` ` is the budget exactly. + const rest = Array.from({ length: 900 }, (_, i) => `<${String(i + 1).padStart(4, '0')}@r>`); + const oldest = `<${'x'.repeat(71)}@r>`; + const atBudget = [oldest, ...rest].join(' '); + expect(atBudget.length + 1 + MESSAGE_ID.length).toBe(REFERENCES_BUDGET); + + const kept = referencesOf(await replyText(atBudget)); + expect(kept).toBe(`${atBudget} ${MESSAGE_ID}`); + + const trimmed = referencesOf(await replyText(` { + // Long ids, so the character cut lands inside one: that id goes, whole, + // and the tail starts on the next boundary. + const ids = Array.from( + { length: 200 }, + (_, i) => `<${String(i).padStart(3, '0')}${'y'.repeat(96)}@r>`, + ); + const references = referencesOf(await replyText(ids.join(' '))); + expect(references).toBeDefined(); + expect(references!.length).toBeLessThanOrEqual(REFERENCES_BUDGET); + const kept = references!.split(' '); + expect(kept.at(-1)).toBe(MESSAGE_ID); + for (const id of kept.slice(0, -1)) { + expect(ids).toContain(id); + } + expect(kept.slice(0, -1)).toEqual(ids.slice(ids.length - (kept.length - 1))); + }); + + it('keeps the newest ids without a usable Message-ID', async () => { + const ids = Array.from({ length: 1000 }, (_, i) => `<${String(i).padStart(4, '0')}@r>`); + const text = await replyText(ids.join(' '), ''); + expect(text).not.toContain('In-Reply-To:'); + const references = referencesOf(text); + expect(references).toBeDefined(); + expect(references!.length).toBeLessThanOrEqual(REFERENCES_BUDGET); + expect(references).not.toContain('<0000@r>'); + expect(references!.endsWith(' <0999@r>')).toBe(true); + }); + + it('threads the newest ids when the id no line can carry is an old one', async () => { + const ids = Array.from({ length: 1000 }, (_, i) => `<${String(i).padStart(4, '0')}@r>`); + // Oldest: outside the tail, so the tail threads. Every id used to go. + const oldestHuge = referencesOf(await replyText(`<${HUGE}@r> ${ids.join(' ')}`)); + expect(oldestHuge).not.toContain(HUGE); + expect(oldestHuge!.endsWith(` <0999@r> ${MESSAGE_ID}`)).toBe(true); + // Newest: inside the tail, so only the parent's id is threaded. + const newestHuge = referencesOf(await replyText(`${ids.join(' ')} <${HUGE}@r>`)); + expect(newestHuge).toBe(MESSAGE_ID); + }); + + it('keeps the actions queued before a reply to a chain over the budget', async () => { + // What the throw cost: `handleEmail` returns the queue only once the + // handler resolves, so a forward recorded before the reply was lost with + // it — bounced as a "project handler error" on the platform, never + // enacted on a native message. + const references = ' '.repeat(Math.floor((MAX_MESSAGE_BYTES - 200) / 6)).trimEnd(); + const raw = new TextEncoder().encode('Subject: hi\r\n\r\nbody\r\n'); + const { message, getActions } = createMessageProxy( + { + from: 'sender@remote.dev', + to: 'acme+support@mail.void.cloud', + headers: { references, 'message-id': MESSAGE_ID, subject: 'hi' }, + raw, + rawSize: raw.length, + }, + { slug: 'acme' }, + ); + const handler = defineEmail(async (m) => { + await m.forward('archive@acme.dev'); + await replyEmail(m, { text: 'thanks', from: 'acme+noreply@x.dev' }); + }); + + await handler(message, {}, {} as ExecutionContext); + + const actions = getActions(); + expect(actions.map((a) => a.type)).toEqual(['forward', 'reply']); + const reply = actions[1]; + if (reply.type !== 'reply') { + throw new Error('expected reply action'); + } + expect(new TextDecoder().decode(reply.raw)).toContain(`In-Reply-To: ${MESSAGE_ID}`); + }); + + it('still threads a normal Message-ID', async () => { + const message = inbound({ 'message-id': '', subject: 'hi' }); + + const reply = await buildReplyMessage(message, { text: 'thanks', from: 'acme+noreply@x.dev' }); + + const text = new TextDecoder().decode(reply.raw); + expect(text).toContain('In-Reply-To: '); + expect(text).toContain('References: '); + }); + + describe('a control character in a threading id', () => { + // A threading value is emitted verbatim, and `\x01` or DEL pass a WHATWG + // `Headers` (which refuses only CR, LF and NUL), so a remote sender's + // `Message-ID: ` reached the reply's `In-Reply-To` + // and `References` lines raw. An RFC 5322 §3.6.4 `msg-id` admits no + // control, so such an id is unrepresentable — dropped, as an over-long + // one is, never a throw: the reply is still sent. Unlike the line + // guard, which is about the rendered line, this is about one token, so + // a bad id in the middle of a chain loses only itself. + // eslint-disable-next-line no-control-regex -- C0 controls and DEL are the point + const rawControl = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/; + + function headersOf(text: string): string { + return text.slice(0, text.indexOf('\r\n\r\n')); + } + + it('drops a parent Message-ID carrying one: no In-Reply-To, absent from References, reply still sent', async () => { + const parent = ''; + const text = await replyText('', parent); + expect(text).toContain('Subject: Re: hi'); + expect(text).not.toContain('In-Reply-To:'); + expect(referencesOf(text)).toBe(''); + expect(headersOf(text)).not.toMatch(rawControl); + // With no chain to thread either, the reply carries no threading at all. + const bare = await replyText('', parent); + expect(bare).toContain('Subject: Re: hi'); + expect(bare).not.toContain('In-Reply-To:'); + expect(bare).not.toContain('References:'); + }); + + it('drops only the id carrying DEL from a References chain; the rest and the parent stay in order', async () => { + for (const separator of [' ', '\t']) { + const chain = ['', '', ''].join(separator); + const text = await replyText(chain); + expect(text).toContain(`In-Reply-To: ${MESSAGE_ID}\r\n`); + expect(referencesOf(text)).toBe(` ${MESSAGE_ID}`); + expect(headersOf(text)).not.toMatch(rawControl); + } + }); + + it('still threads a clean chain and parent exactly as before', async () => { + const text = await replyText(' '); + expect(text).toContain(`In-Reply-To: ${MESSAGE_ID}\r\n`); + expect(referencesOf(text)).toBe(` ${MESSAGE_ID}`); + }); + }); +}); + +describe('reply subject survives a hostile inbound header', () => { + // The default reply subject is decoded from the inbound `Subject`, which + // the remote sender controls. `normalizeEmail` rejects CR, LF and NUL and + // `buildMime` rejects any line over 998 octets, so a subject carrying + // either would make the app's reply throw — and in production that + // rejects the whole delivery, replies AND forwards. Like threading, the + // subject is best-effort: repair what can be repaired, fall back to + // `Re:` for what cannot, and always send the reply. + const FROM = 'acme+noreply@x.dev'; + + function inbound(subject: string) { + const raw = new TextEncoder().encode('Subject: hi\r\n\r\nbody\r\n'); + return createMessageProxy( + { + from: 'sender@remote.dev', + to: 'acme+support@mail.void.cloud', + headers: { subject }, + raw, + rawSize: raw.length, + }, + { slug: 'acme' }, + ).message; + } + + function maxLine(reply: { raw: Uint8Array }): number { + return Math.max( + ...new TextDecoder() + .decode(reply.raw) + .split('\r\n') + .map((l) => l.length), + ); + } + + // The reviewer's case. `parseInt('0G', 16)` is 0, not NaN, so the old + // predicate synthesised a NUL from a malformed escape. RFC 2045 §6.7 says + // a robust decoder keeps the `=` and the following characters as-is. + it('keeps a malformed Q escape literal instead of decoding half of it', async () => { + const reply = await buildReplyMessage(inbound('=?UTF-8?Q?bad=0Gsubject?='), { + text: 'thanks', + from: FROM, + }); + expect(reply.subject).toBe('Re: bad=0Gsubject'); + }); + + it.each([ + ['a well-formed Q-encoded NUL', '=?UTF-8?Q?bad=00subject?='], + ['a well-formed Q-encoded LF', '=?UTF-8?Q?bad=0Asubject?='], + ['a well-formed Q-encoded CR', '=?UTF-8?Q?bad=0Dsubject?='], + // base64('bad\0subject') + ['a B-encoded NUL', '=?UTF-8?B?YmFkAHN1YmplY3Q=?='], + ])('replaces %s with a space rather than failing the reply', async (_label, subject) => { + const reply = await buildReplyMessage(inbound(subject), { text: 'thanks', from: FROM }); + expect(reply.subject).toBe('Re: bad subject'); + }); + + it('falls back to a bare Re: for an ASCII subject no line can hold', async () => { + const reply = await buildReplyMessage(inbound('x'.repeat(1100)), { + text: 'thanks', + from: FROM, + }); + expect(reply.subject).toBe('Re:'); + expect(maxLine(reply)).toBeLessThanOrEqual(998); + }); + + // The fit check must not over-reject: a non-ASCII subject is chunked + // into 75-character encoded-words that fold, however long it is. + it('keeps a long non-ASCII subject, which encodes into foldable words', async () => { + const subject = 'é'.repeat(600); + const reply = await buildReplyMessage(inbound(subject), { text: 'thanks', from: FROM }); + expect(reply.subject).toBe(`Re: ${subject}`); + expect(maxLine(reply)).toBeLessThanOrEqual(998); + }); + + it('still prefixes an ordinary subject', async () => { + const reply = await buildReplyMessage(inbound('=?UTF-8?Q?good=20subject?='), { + text: 'thanks', + from: FROM, + }); + expect(reply.subject).toBe('Re: good subject'); + }); + + it('decodes a subject of 750k encoded-words one word at a time', async () => { + // MAX_MESSAGE_BYTES of `=?UTF-8?Q?a?=` is the largest subject a remote + // sender can deliver. `replace` with a callback collected all 750k + // matches before decoding the first — measured at 138 MB on the whole + // reply path, which is the isolate; the lazy scan needs 35 MB. The + // runner's heap is not capped, so this pins the behaviour: adjacent + // encoded-words join, the decoded subject is one 750k-character ASCII + // token no line can carry, and the reply falls back to a bare `Re:`. + const subject = '=?UTF-8?Q?a?= '.repeat(Math.floor(MAX_MESSAGE_BYTES / 14)); + const reply = await buildReplyMessage(inbound(subject), { text: 'thanks', from: FROM }); + expect(reply.subject).toBe('Re:'); + }); + + // The other shape of the same header: ONE encoded-word carrying the whole + // 10 MiB. The payload pattern accepts whitespace, so a folded `Subject` + // arrives (unfolded by `Headers`) as a single Q-word and the decoder sees + // every character of it. Pushing each byte onto an `Array` first + // measured 199 MB of heap for the decode alone and 330 MB peak on the reply + // path — `FATAL ERROR: Reached heap limit` under `--max-old-space-size=128`, + // which is the tenant isolate — before `buildMime`'s projection could + // refuse the subject. Decoding into a preallocated `Uint8Array` peaks at + // 10.5 MB on the same path (42 ms). The runner's own heap is not capped, + // so the pin runs the reply in a child with the isolate's cap. + it('replies to a 10 MiB single Q-encoded word inside a 128 MB heap', () => { + const inboundModule = pathToFileURL( + join(import.meta.dirname, '../../src/runtime/email/inbound.ts'), + ).href; + const child = ` + const { buildReplyMessage, createMessageProxy } = await import(process.argv[1]); + const subject = '=?UTF-8?Q?' + 'a'.repeat(${MAX_MESSAGE_BYTES} - 20) + '?='; + const raw = new TextEncoder().encode('body'); + const { message } = createMessageProxy( + { + from: 'sender@remote.dev', + to: 'acme+support@mail.void.cloud', + headers: { subject }, + raw, + rawSize: raw.length, + }, + { slug: 'acme' }, + ); + const t0 = performance.now(); + const reply = await buildReplyMessage(message, { text: 'thanks', from: ${JSON.stringify(FROM)} }); + console.log(JSON.stringify({ subject: reply.subject, ms: performance.now() - t0 })); + `; + const stdout = execFileSync( + process.execPath, + ['--max-old-space-size=128', '--input-type=module', '-e', child, inboundModule], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }, + ); + const result = JSON.parse(stdout.trim().split('\n').at(-1) ?? '{}') as { + subject: string; + ms: number; + }; + // The decoded word is one 10 MiB ASCII token no line can carry, so the + // subject falls back — the same answer the 750k-word shape gets. + expect(result.subject).toBe('Re:'); + // 42 ms measured locally; the bound leaves room for CI's slower runner + // (about 6x), where the old `Array` shape did not finish at all. + expect(result.ms).toBeLessThan(5000); + }, 30_000); + + // A folded `Subject` reaches the decoder unfolded: `Headers` — Cloudflare's + // `ForwardableEmailMessage.headers` and the proxy's `new Headers(envelope + // .headers)` alike — rejects CR and LF, so what the payload can carry from a + // fold is the SP / HTAB that followed the CRLF. That whitespace is a literal + // byte to the Q decoder, and `prefixReplySubject` folds the run into the + // single space `_` would have produced. + it('decodes a Q-word carrying unfolded FWS to the same subject as the unfolded word', async () => { + const unfolded = await buildReplyMessage(inbound('=?UTF-8?Q?good=20sub_ject?='), { + text: 'thanks', + from: FROM, + }); + const folded = await buildReplyMessage(inbound('=?UTF-8?Q?good=20sub\t ject?='), { + text: 'thanks', + from: FROM, + }); + expect(unfolded.subject).toBe('Re: good sub ject'); + expect(folded.subject).toBe(unfolded.subject); + }); + + it.each([ + ['a lone = at the end', '=?UTF-8?Q?a=?=', 'Re: a='], + ['a one-digit escape at the end', '=?UTF-8?Q?a=4?=', 'Re: a=4'], + ['a well-formed escape at the end', '=?UTF-8?Q?a=41?=', 'Re: aA'], + ['a malformed second digit', '=?UTF-8?Q?a=4G?=', 'Re: a=4G'], + ['an escape that is the whole payload', '=?UTF-8?Q?=41?=', 'Re: A'], + ])('keeps the Q escape rules at the payload edge for %s', async (_label, subject, expected) => { + const reply = await buildReplyMessage(inbound(subject), { text: 'thanks', from: FROM }); + expect(reply.subject).toBe(expected); + }); + + /** + * Build the reply in a child Node under the tenant isolate's 128 MB heap + * cap and report what it cost there. `subjectSource` is a JS expression + * evaluated in the child, so the fixture is built inside the cap too. + */ + function replyInIsolate(subjectSource: string): { + subject: string; + ms: number; + heapDeltaMB: number; + } { + const inboundModule = pathToFileURL( + join(import.meta.dirname, '../../src/runtime/email/inbound.ts'), + ).href; + const child = ` + const { buildReplyMessage, createMessageProxy } = await import(process.argv[1]); + const subject = ${subjectSource}; + const raw = new TextEncoder().encode('body'); + const { message } = createMessageProxy( + { + from: 'sender@remote.dev', + to: 'acme+support@mail.void.cloud', + headers: { subject }, + raw, + rawSize: raw.length, + }, + { slug: 'acme' }, + ); + const heap0 = process.memoryUsage().heapUsed; + const t0 = performance.now(); + const reply = await buildReplyMessage(message, { text: 'thanks', from: ${JSON.stringify(FROM)} }); + const ms = performance.now() - t0; + const heapDeltaMB = (process.memoryUsage().heapUsed - heap0) / (1024 * 1024); + console.log(JSON.stringify({ subject: reply.subject, ms, heapDeltaMB })); + `; + const stdout = execFileSync( + process.execPath, + ['--max-old-space-size=128', '--input-type=module', '-e', child, inboundModule], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }, + ); + return JSON.parse(stdout.trim().split('\n').at(-1) ?? '{}') as { + subject: string; + ms: number; + heapDeltaMB: number; + }; + } + + // The third shape of the same header, aimed past the decoder at the + // control-character replacements: a global `replace` materializes one + // part per match before it builds the result, about 66 bytes each, so + // 5 MiB of alternating tab and letter (2.6M matches, inside + // MAX_MESSAGE_BYTES, no encoded-word for the decoder to touch) measured + // 165 MB on the first replacement alone, 191 MB and 206 ms on the reply + // path, and terminated a 128 MB isolate. The decoded subject is now + // bounded BEFORE the replacements run — 2.3 ms and 0.5 MB on the same + // path — so the pin runs in a child with the isolate's cap, where the + // old shape did not finish at all. + it('replies to a 5 MiB subject of alternating tabs and letters inside a 128 MB heap', () => { + const result = replyInIsolate(`'\\ta'.repeat(${(5 * 1024 * 1024) / 2})`); + expect(result.subject).toBe('Re:'); + expect(result.ms).toBeLessThan(200); + expect(result.heapDeltaMB).toBeLessThan(64); + }, 30_000); + + // The bound sits on the decoded subject and is checked before the + // replacements: a subject exactly at it is still repaired, one character + // over falls back. `\x01` rather than a tab, because `Headers` strips + // leading and trailing HTTP whitespace and the count must be exact. + it('repairs a subject exactly at the bound and falls back one character over', async () => { + const atBound = 'a\x01'.repeat(2048); // 4096 code units + const kept = await buildReplyMessage(inbound(atBound), { text: 'thanks', from: FROM }); + expect(kept.subject).toBe(`Re: ${'a '.repeat(2048).trim()}`); + expect(maxLine(kept)).toBeLessThanOrEqual(998); + + const over = await buildReplyMessage(inbound(`${atBound}a`), { text: 'thanks', from: FROM }); + expect(over.subject).toBe('Re:'); + }); + + // It is the DECODED length that is bounded. B-encoded, 4096 three-octet + // characters arrive as a 19,945-character header, so a bound on the raw + // header would have refused a CJK subject a quarter that long. The + // rendered `Subject` line of that subject is the widest any kept subject + // renders to — about 20 KB, against a 10 MiB message limit. + it('bounds the decoded subject, not the encoded header it arrived in', async () => { + // Fifteen three-octet characters fill a 45-octet encoded-word exactly, + // so no character is split across words (RFC 2047 §5). + const encode = (text: string) => { + const words: Array = []; + for (let i = 0; i < text.length; i += 15) { + const bytes = new TextEncoder().encode(text.slice(i, i + 15)); + words.push(`=?UTF-8?B?${btoa(String.fromCharCode(...bytes))}?=`); + } + return words.join(' '); + }; + const atBound = '日'.repeat(4096); + const header = encode(atBound); + expect(header.length).toBeGreaterThan(4 * atBound.length); + + const kept = await buildReplyMessage(inbound(header), { text: 'thanks', from: FROM }); + expect(kept.subject).toBe(`Re: ${atBound}`); + expect(maxLine(kept)).toBeLessThanOrEqual(998); + expect(kept.raw.byteLength).toBeLessThan(32 * 1024); + + const over = await buildReplyMessage(inbound(encode(`${atBound}日`)), { + text: 'thanks', + from: FROM, + }); + expect(over.subject).toBe('Re:'); + }); + + // The residual the previous round left open. A 10 MiB subject WITH spaces + // folds — every token is one character — so `fitsHeaderLine` passed it, + // `buildMime`'s projection (a lower bound) passed it, and the folded + // `Subject` alone rendered to 10,761,941 octets: the final size check + // threw `MimeError` out of `replyEmail()`, the throw this path exists to + // prevent. Bounded, the subject falls back and the reply is recorded. + it('replies to a 10 MiB subject that folds instead of throwing MimeError from the size check', async () => { + const raw = new TextEncoder().encode('body'); + const { message, getActions } = createMessageProxy( + { + from: 'sender@remote.dev', + to: 'acme+support@mail.void.cloud', + headers: { subject: 'a '.repeat(MAX_MESSAGE_BYTES / 2) }, + raw, + rawSize: raw.length, + }, + { slug: 'acme' }, + ); + + await replyEmail(message, { text: 'thanks', from: FROM }); + + const [action] = getActions(); + if (action?.type !== 'reply') { + throw new Error('expected reply action'); + } + expect(new TextDecoder().decode(action.raw)).toContain('Subject: Re:\r\n'); + }); +}); + +describe('parseEmail error contract', () => { + // `PARSE_FAILED` was declared in the code union but never constructed, so + // the one thing that does make postal-mime reject — a deeply nested + // message — escaped as a bare Error carrying parser internals. The union + // has to be honest: every code it names is reachable. + it('wraps a raw-stream read failure as PARSE_FAILED', async () => { + // An own-account delivery wraps Cloudflare's native one-shot `raw` with + // `createMessageProxy`, so the buffering read is what fails. The + // acquisition used to sit above the guarded block, and the stream error + // escaped raw. + const raw = new ReadableStream({ + start(controller) { + controller.error(new Error('stream broke mid-delivery')); + }, + }); + const { message } = createMessageProxy( + { from: 'a@b.dev', to: 'acme+x@mail.void.cloud', headers: {}, raw, rawSize: 32 }, + { slug: 'acme' }, + ); + + await expect(parseEmail(message)).rejects.toSatisfy( + (e: unknown) => e instanceof ParseEmailError && e.code === 'PARSE_FAILED', + ); + }); + + it('wraps a parser rejection as PARSE_FAILED', async () => { + // postal-mime refuses past 256 levels of nesting. Build one bomb that + // trips exactly that, and nothing else. + const depth = 300; + let body = 'boom\r\n'; + for (let i = depth; i > 0; i--) { + const boundary = `b${i}`; + body = + `Content-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n` + + `--${boundary}\r\n${body}\r\n--${boundary}--\r\n`; + } + const raw = new TextEncoder().encode(body); + const { message } = createMessageProxy( + { from: 'a@b.dev', to: 'acme+x@mail.void.cloud', headers: {}, raw, rawSize: raw.length }, + { slug: 'acme' }, + ); + + await expect(parseEmail(message)).rejects.toSatisfy( + (e: unknown) => e instanceof ParseEmailError && e.code === 'PARSE_FAILED', + ); + }); + + it('still reports an oversized message as INBOUND_TOO_LARGE', async () => { + const raw = new TextEncoder().encode('Subject: hi\r\n\r\nbody\r\n'); + const { message } = createMessageProxy( + { + from: 'a@b.dev', + to: 'acme+x@mail.void.cloud', + headers: {}, + raw, + rawSize: 100 * 1024 * 1024, + }, + { slug: 'acme' }, + ); + + await expect(parseEmail(message)).rejects.toSatisfy( + (e: unknown) => e instanceof ParseEmailError && e.code === 'INBOUND_TOO_LARGE', + ); + }); +}); + +describe('void/email barrel exports the error classes it throws', () => { + // `package.json` exposes only `./email` and `./email/testing` — there is no + // wildcard subpath — so a class a handler author cannot reach through the + // barrel is a class they cannot `instanceof` at all, however well it is + // documented. The barrel exports two throwing entrypoints (`parseEmail`, + // `createEmail`); both error classes have to come with them. + it('re-exports ParseEmailError as the same class parseEmail throws', async () => { + expect(emailBarrel.ParseEmailError).toBe(ParseEmailError); + + const raw = new TextEncoder().encode('Subject: hi\r\n\r\nbody\r\n'); + const { message } = createMessageProxy( + { + from: 'a@b.dev', + to: 'acme+x@mail.void.cloud', + headers: {}, + raw, + rawSize: 100 * 1024 * 1024, + }, + { slug: 'acme' }, + ); + + await expect(parseEmail(message)).rejects.toSatisfy( + (e: unknown) => e instanceof emailBarrel.ParseEmailError && e.code === 'INBOUND_TOO_LARGE', + ); + }); + + it('re-exports MimeError as the same class createEmail throws', () => { + expect(emailBarrel.MimeError).toBe(MimeError); + }); +}); + +describe('buildReplyMessage', () => { + function makeMessage(headers: Record): ForwardableEmailMessage { + return { + from: 'user@example.com', + to: 'support@app.dev', + headers: new Headers(headers), + raw: new ReadableStream(), + rawSize: 0, + } as unknown as ForwardableEmailMessage; + } + + // The platform mail domain is injected per environment, so the tests use a + // non-production value: asserting on it proves the reply address came from + // the binding and not from a baked-in constant. + const MAIL_DOMAIN = 'mail.staging.void.cloud'; + + // Most reply tests exercise the platform-defaulted `from = +noreply@…` + // path. Set both platform bindings once via envContext so each test inside + // this describe sees them without per-test boilerplate. + function withSlug(fn: () => T | Promise): T | Promise { + return envContext.run({ __VOID_PROJECT_SLUG: 'myslug', __VOID_EMAIL_DOMAIN: MAIL_DOMAIN }, fn); + } + + it('throws rather than falling back to message.to when the slug cannot be a sender', async () => { + // A slug over 56 characters cannot build `+noreply@`, which must + // stay inside RFC 5321's 64-octet local part. Falling through to + // `message.to` builds the slug-stripped shape the platform reply pin + // drops, so the handler would see a success and the mail would vanish. + await envContext.run( + { __VOID_PROJECT_SLUG: 'a'.repeat(57), __VOID_EMAIL_DOMAIN: MAIL_DOMAIN }, + async () => { + const message = makeMessage({ Subject: 'Help me' }); + await expect(buildReplyMessage(message, { text: 'sure' })).rejects.toThrow( + /cannot build a default reply sender/, + ); + }, + ); + }); + + it('defaults from/to/subject from the original message', async () => { + await withSlug(async () => { + const message = makeMessage({ Subject: 'Help me' }); + const built = await buildReplyMessage(message, { text: 'sure' }); + // F3: default `from` no longer mirrors `message.to` (which was the + // platform-tagged `+@…` and is now slug-stripped to the + // bare local-part). Reply-from is anchored to the platform sender + // so the slug-pin allowlist accepts it. + expect(built.from).toBe(`myslug+noreply@${MAIL_DOMAIN}`); + expect(built.to).toBe('user@example.com'); + expect(built.subject).toBe('Re: Help me'); + }); + }); + + it('does not double-prefix Re:', async () => { + await withSlug(async () => { + const message = makeMessage({ Subject: 'Re: existing thread' }); + const built = await buildReplyMessage(message, { text: 'ack' }); + expect(built.subject).toBe('Re: existing thread'); + }); + }); + + it('handles missing subject gracefully', async () => { + await withSlug(async () => { + const message = makeMessage({}); + const built = await buildReplyMessage(message, { text: 'ack' }); + expect(built.subject).toBe('Re:'); + }); + }); + + it('decodes RFC 2047 encoded-word subjects before prepending Re:', async () => { + await withSlug(async () => { + // base64('日本語') in UTF-8 = "5pel5pys6Kqe" + const message = makeMessage({ Subject: '=?UTF-8?B?5pel5pys6Kqe?=' }); + const built = await buildReplyMessage(message, { text: 'ack' }); + expect(built.subject).toBe('Re: 日本語'); + }); + }); + + it('decodes Q-encoded subjects (RFC 2047 quoted-printable variant)', async () => { + await withSlug(async () => { + // =?UTF-8?Q?caf=C3=A9?= → "café" + const message = makeMessage({ Subject: '=?UTF-8?Q?caf=C3=A9?=' }); + const built = await buildReplyMessage(message, { text: 'ack' }); + expect(built.subject).toBe('Re: café'); + }); + }); + + it('decodes ISO-8859-1 Q-encoded subjects with the actual charset', async () => { + await withSlug(async () => { + // =?iso-8859-1?Q?J=FCrgen?= → "Jürgen" (0xFC is 'ü' in Latin-1) + // Decoding the same bytes as UTF-8 would mojibake to a replacement + // char or a different glyph, so this test guards against the old + // "always UTF-8" fallback path. + const message = makeMessage({ Subject: '=?iso-8859-1?Q?J=FCrgen?=' }); + const built = await buildReplyMessage(message, { text: 'ack' }); + expect(built.subject).toBe('Re: Jürgen'); + }); + }); + + it('does not double-prefix Re: when the encoded subject already starts with Re:', async () => { + await withSlug(async () => { + // base64('Re: hi') = "UmU6IGhp" + const message = makeMessage({ Subject: '=?UTF-8?B?UmU6IGhp?=' }); + const built = await buildReplyMessage(message, { text: 'ack' }); + expect(built.subject).toBe('Re: hi'); + }); + }); + + it('threads via In-Reply-To and starts a References chain', async () => { + await withSlug(async () => { + const message = makeMessage({ + Subject: 'Hi', + 'Message-ID': '', + }); + const built = await buildReplyMessage(message, { text: 'ack' }); + const raw = new TextDecoder().decode(built.raw); + expect(raw).toContain('In-Reply-To: '); + expect(raw).toContain('References: '); + }); + }); + + it('appends to an existing References chain', async () => { + await withSlug(async () => { + const message = makeMessage({ + Subject: 'Hi', + 'Message-ID': '', + References: '', + }); + const built = await buildReplyMessage(message, { text: 'ack' }); + const raw = new TextDecoder().decode(built.raw); + expect(raw).toContain('In-Reply-To: '); + expect(raw).toContain('References: '); + }); + }); + + it('honors explicit overrides', async () => { + // No slug needed — explicit `from` skips the defaulting path. + const message = makeMessage({ Subject: 'Hi' }); + const built = await buildReplyMessage(message, { + from: 'override@app.dev', + to: 'someone@else.dev', + subject: 'Custom subject', + text: 'ack', + }); + expect(built.from).toBe('override@app.dev'); + expect(built.to).toBe('someone@else.dev'); + expect(built.subject).toBe('Custom subject'); + }); + + it('renders a dotted sender name, handed back as `to`, as one quoted-string', async () => { + // The default `to` is the bare envelope sender, so no display name is + // formatted unless the handler passes one — typically the parsed + // `From`. postal-mime gives the name as its raw text (`Dr. Smith`, the + // quotes undone), and `buildMime` quotes it again on the way out: `.` + // is an RFC 5322 special, so the name is not a phrase of atoms. + const raw = new TextEncoder().encode( + 'From: "Dr. Smith" \r\n' + + 'To: support@app.dev\r\n' + + 'Subject: Hi\r\n\r\nhello\r\n', + ); + const message = { + from: 'user@example.com', + to: 'support@app.dev', + headers: new Headers({ Subject: 'Hi' }), + raw: new ReadableStream({ + start(controller) { + controller.enqueue(raw); + controller.close(); + }, + }), + rawSize: raw.length, + } as unknown as ForwardableEmailMessage; + const parsed = await parseEmail(message); + expect(parsed.from).toEqual({ address: 'user@example.com', name: 'Dr. Smith' }); + const sender = parsed.from as { address: string; name: string }; + const built = await buildReplyMessage(message, { + from: 'override@app.dev', + to: { email: sender.address, name: sender.name }, + text: 'ack', + }); + expect(built.to).toBe('user@example.com'); + const text = new TextDecoder().decode(built.raw); + expect(text).toContain('To: "Dr. Smith" \r\n'); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end: replicates what the worker entry codegen does at runtime. +// The codegen emits a `email(envelope)` method on a WorkerEntrypoint subclass +// that: +// 1. Builds a proxy via createMessageProxy(envelope) +// 2. Runs the dispatcher (which delegates to the matching user handler) +// 3. Returns getActions() to the platform router +// This test stitches the same pieces together and asserts the contract, +// without compiling and evaluating the actual generated code (which would +// need virtual:void-routes + cloudflare:workers). +// --------------------------------------------------------------------------- + +describe('end-to-end: envelope → handler → action queue', () => { + function makeEnvelope(overrides: Partial = {}): EmailEnvelope { + const raw = new TextEncoder().encode( + 'From: sender@example.com\r\n' + + 'To: support+abc-1@app.dev\r\n' + + 'Subject: Help me\r\n' + + 'Message-ID: \r\n' + + 'Content-Type: text/plain\r\n' + + '\r\n' + + 'I have a problem\r\n', + ); + return { + from: 'sender@example.com', + to: 'support+abc-1@app.dev', + headers: { Subject: 'Help me', 'Message-ID': '' }, + raw, + rawSize: raw.byteLength, + ...overrides, + }; + } + + // Mirrors the codegen pattern: build proxy, run handler, return actions. + async function dispatchToHandler( + envelope: EmailEnvelope, + handler: ( + message: ForwardableEmailMessage, + env: unknown, + ctx: ExecutionContext, + info?: { params: Record }, + ) => void | Promise, + info: { params: Record } = { params: {} }, + ) { + const { message, getActions } = createMessageProxy(envelope); + await handler(message, {}, {} as ExecutionContext, info); + return getActions(); + } + + it('records a setReject action and returns it as the RPC result', async () => { + const handler = defineEmail(async (message) => { + message.setReject('not allowed'); + }); + const actions = await dispatchToHandler(makeEnvelope(), handler); + expect(actions).toEqual([{ type: 'reject', reason: 'not allowed' }]); + }); + + it('records a forward action with serialized headers', async () => { + const handler = defineEmail(async (message) => { + const fwd = new Headers({ 'X-Forwarded-By': 'void-test' }); + await message.forward('archive@example.com', fwd); + }); + const actions = await dispatchToHandler(makeEnvelope(), handler); + expect(actions).toEqual([ + { + type: 'forward', + to: 'archive@example.com', + headers: { 'x-forwarded-by': 'void-test' }, + }, + ]); + }); + + it('records a reply action with threaded MIME from replyEmail', async () => { + // Explicit `from` — without __VOID_PROJECT_SLUG / __VOID_EMAIL_DOMAIN + // in scope, replyEmail's defaulting path errors. Production threads both + // from env; bare unit tests pass `from` explicitly to skip that machinery. + const handler = defineEmail(async (message, _env, _ctx, info) => { + const ticket = info?.params.ticket ?? 'unknown'; + await replyEmail(message, { + from: 'support+abc-1@app.dev', + text: `Got ticket ${ticket}`, + }); + }); + const actions = await dispatchToHandler(makeEnvelope(), handler, { + params: { ticket: 'abc-1' }, + }); + expect(actions).toHaveLength(1); + const reply = actions[0]; + if (reply.type !== 'reply') { + throw new Error('expected reply action'); + } + expect(reply.from).toBe('support+abc-1@app.dev'); + expect(reply.to).toBe('sender@example.com'); + expect(reply.raw).toBeInstanceOf(Uint8Array); + const rawText = new TextDecoder().decode(reply.raw); + expect(rawText).toContain('Subject: Re: Help me'); + expect(rawText).toContain('In-Reply-To: '); + expect(rawText).toContain('References: '); + expect(rawText).toContain('Got ticket abc-1'); + }); + + it('strips the platform + prefix from message.to before user code sees it', async () => { + // Production: every recipient arrives as `+@mail.void.cloud`. + // The user-visible `message.to` should be the bare `@`, + // matching the address shape user handlers wrote in `email/.ts`. + let observedTo: string | undefined; + let platformTo: string | undefined; + const handler = defineEmail(async (message) => { + observedTo = message.to; + platformTo = (message as unknown as { __voidPlatformTo?: string }).__voidPlatformTo; + }); + const envelope: EmailEnvelope = { + from: 'sender@example.com', + to: 'myslug+support@mail.void.cloud', + headers: {}, + raw: new Uint8Array(), + rawSize: 0, + }; + const { message, getActions } = createMessageProxy(envelope, { slug: 'myslug' }); + await handler(message, {}, {} as ExecutionContext, { params: {} }); + expect(getActions()).toEqual([]); + expect(observedTo).toBe('support@mail.void.cloud'); + expect(platformTo).toBe('myslug+support@mail.void.cloud'); + }); + + it('leaves message.to untouched when no slug is provided', async () => { + // Unit-level safety net: slug-stripping is opt-in (the dispatcher + // passes the slug explicitly). Without it, the proxy must not mangle + // the address — local tests / harnesses without a known slug rely on + // this for predictable behavior. + let observedTo: string | undefined; + const handler = defineEmail(async (message) => { + observedTo = message.to; + }); + const envelope: EmailEnvelope = { + from: 'sender@example.com', + to: 'myslug+support@mail.void.cloud', + headers: {}, + raw: new Uint8Array(), + rawSize: 0, + }; + const { message } = createMessageProxy(envelope); + await handler(message, {}, {} as ExecutionContext, { params: {} }); + expect(observedTo).toBe('myslug+support@mail.void.cloud'); + }); + + function replyTestMessage(): ForwardableEmailMessage { + return { + from: 'sender@example.com', + to: 'support@mail.example.dev', + headers: new Headers({ Subject: 'Hi' }), + raw: new ReadableStream(), + rawSize: 0, + } as unknown as ForwardableEmailMessage; + } + + it('replyEmail without from replies from message.to when there is no __VOID_PROJECT_SLUG', async () => { + // No slug means no platform slug-pin to satisfy: the message reached a + // real address on the developer's own zone, and that address is the + // reply sender. A domain binding on its own does not change that. + const built = await envContext.run({ __VOID_EMAIL_DOMAIN: 'mail.staging.void.cloud' }, () => + buildReplyMessage(replyTestMessage(), { text: 'ack' }), + ); + expect(built.from).toBe('support@mail.example.dev'); + }); + + it('replyEmail without from errors when __VOID_EMAIL_DOMAIN is unavailable', async () => { + // No silent fallback: with a slug but no configured mail domain the + // reply must fail rather than being addressed from the production zone. + await expect( + envContext.run({ __VOID_PROJECT_SLUG: 'myslug' }, () => + replyEmail(replyTestMessage(), { text: 'ack' }), + ), + ).rejects.toThrow(/__VOID_EMAIL_DOMAIN/); + }); + + it('replyEmail keeps an explicit from with neither binding present', async () => { + // The bindings only feed the default; an explicit sender is untouched. + const built = await envContext.run({}, () => + buildReplyMessage(replyTestMessage(), { from: 'support@app.dev', text: 'ack' }), + ); + expect(built.from).toBe('support@app.dev'); + }); + + it('setReject is terminal: prior forwards are dropped and later forward/reply are no-ops', async () => { + // CF treats setReject as terminal — once setReject fires, anything + // queued before it never runs (router drops them) and anything called + // after is silently discarded. The proxy mirrors that contract by + // (a) walking back the action queue on setReject to remove preceding + // forward/reply entries (with one warning describing how many were + // dropped) and (b) warning + no-op for forward/reply called after. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const handler = defineEmail(async (message) => { + await message.forward('first@example.com'); + message.setReject('then reject'); + await message.forward('after-reject@example.com'); + await message.reply({ + from: 'support@app.dev', + to: 'sender@example.com', + raw: new TextEncoder().encode('Subject: late\r\n\r\nbody\r\n'), + // The proxy's reply() accepts a duck-typed { from, to, raw }, + // but the ForwardableEmailMessage interface expects EmailMessage. + // This cast keeps the runtime contract under test while + // satisfying the public type. + } as unknown as Parameters[0]); + }); + const actions = await dispatchToHandler(makeEnvelope(), handler); + // Only the reject survives — the prior forward was walked back, the + // later forward/reply are no-ops. + expect(actions.map((a) => a.type)).toEqual(['reject']); + // Three warnings: walk-back (prior forward), late forward, late reply. + expect(warn).toHaveBeenCalledTimes(3); + const warnMessages = warn.mock.calls.map((c) => String(c[0])); + expect(warnMessages.some((m) => m.includes('1 forward/reply call'))).toBe(true); + expect(warnMessages.some((m) => m.includes('forward()'))).toBe(true); + expect(warnMessages.some((m) => m.includes('reply()'))).toBe(true); + } finally { + warn.mockRestore(); + } + }); + + it('reply() refuses a native EmailMessage with a named error', async () => { + // What workerd's `EmailMessage` looks like to JS: `from` and `to` only — + // workers-types declares nothing else and the body has no accessor, so + // the proxy cannot record it as bytes. It used to die on + // `undefined.getReader()`, since the first commit of this file. + const native = Object.freeze({ from: 'support@app.dev', to: 'sender@example.com' }); + const handler = defineEmail(async (message) => { + await message.reply(native as unknown as Parameters[0]); + }); + const failure = dispatchToHandler(makeEnvelope(), handler); + await expect(failure).rejects.toBeInstanceOf(MimeError); + await expect(failure).rejects.toThrow(/native EmailMessage cannot be replayed/); + await expect(failure).rejects.toThrow(/replyEmail\(\)/); + }); + + it('reply() rejects a Uint8Array raw over MAX_MESSAGE_BYTES', async () => { + const handler = defineEmail(async (message) => { + await message.reply({ + from: 'support@app.dev', + to: 'sender@example.com', + raw: new Uint8Array(MAX_MESSAGE_BYTES + 1), + } as unknown as Parameters[0]); + }); + await expect(dispatchToHandler(makeEnvelope(), handler)).rejects.toThrow( + /exceeds Cloudflare limit/, + ); + }); + + it('reply() stops reading a stream raw once MAX_MESSAGE_BYTES is exceeded', async () => { + // A stream that never closes: without the bound, reply() would buffer forever. + let pulls = 0; + let cancelled = false; + const chunk = new Uint8Array(1024 * 1024); + const raw = new ReadableStream({ + pull(controller) { + pulls++; + controller.enqueue(chunk); + }, + cancel() { + cancelled = true; + }, + }); + const handler = defineEmail(async (message) => { + await message.reply({ + from: 'support@app.dev', + to: 'sender@example.com', + raw, + } as unknown as Parameters[0]); + }); + await expect(dispatchToHandler(makeEnvelope(), handler)).rejects.toThrow( + /exceeds Cloudflare limit/, + ); + expect(cancelled).toBe(true); + // 10 MiB limit + the chunk that crosses it + the stream's own read-ahead. + expect(pulls).toBeLessThanOrEqual(12); + }); + + it('refuses the 101st queued action and leaves the queue at 100', async () => { + // The queue crosses the RPC boundary into the SHARED proxy isolate, where + // it is walked entry by entry before any quota gate has looked at it. + // The router enforces the same 100; this is the fast, precise failure. + let thrown: unknown; + const handler = defineEmail(async (message) => { + for (let i = 0; i < 100; i++) { + await message.forward(`box${i}@example.com`); + } + try { + await message.forward('overflow@example.com'); + } catch (e) { + thrown = e; + } + }); + const actions = await dispatchToHandler(makeEnvelope(), handler); + expect(actions).toHaveLength(100); + expect(actions.every((a) => a.type === 'forward')).toBe(true); + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).message).toMatch(/more than 100 actions/); + }); + + it('refuses a third reply once the cumulative reply budget is spent', async () => { + // This is what the per-reply cap does NOT catch: each of the first two + // replies is exactly at MAX_MESSAGE_BYTES and legal on its own, and the + // third is a single byte — but 2 x MAX_MESSAGE_BYTES is the whole budget + // for one delivery, so the byte is refused. + let thrown: unknown; + const handler = defineEmail(async (message) => { + const send = (raw: Uint8Array) => + message.reply({ + from: 'support@app.dev', + to: 'sender@example.com', + raw, + } as unknown as Parameters[0]); + await send(new Uint8Array(MAX_MESSAGE_BYTES)); + await send(new Uint8Array(MAX_MESSAGE_BYTES)); + try { + await send(new Uint8Array(1)); + } catch (e) { + thrown = e; + } + }); + const actions = await dispatchToHandler(makeEnvelope(), handler); + // Both full-size replies are legal and queued; only the third is refused. + expect(actions).toHaveLength(2); + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).message).toMatch(/cumulative reply size/); + }); + + it('cancels a stream reply at the REMAINING budget, not the per-reply cap', async () => { + // 15 MiB already spent out of the 20 MiB budget, so 5 MiB is left. A + // stream bounded by the per-reply cap would keep pulling to 10 MiB and + // report the wrong limit; the pull count and the message both pin that + // the remaining budget is what stopped it. + // + // Two prior replies, not one: after a single full-size reply the + // remaining budget is exactly MAX_MESSAGE_BYTES, which cannot be told + // apart from the per-reply cap. + let pulls = 0; + let cancelled = false; + const chunk = new Uint8Array(1024 * 1024); + const raw = new ReadableStream({ + pull(controller) { + pulls++; + controller.enqueue(chunk); + }, + cancel() { + cancelled = true; + }, + }); + let thrown: unknown; + const handler = defineEmail(async (message) => { + const send = (body: Uint8Array | ReadableStream) => + message.reply({ + from: 'support@app.dev', + to: 'sender@example.com', + raw: body, + } as unknown as Parameters[0]); + await send(new Uint8Array(MAX_MESSAGE_BYTES)); + await send(new Uint8Array(5 * 1024 * 1024)); + try { + await send(raw); + } catch (e) { + thrown = e; + } + }); + const actions = await dispatchToHandler(makeEnvelope(), handler); + expect(actions).toHaveLength(2); + expect(cancelled).toBe(true); + // 5 MiB of remaining budget + the chunk that crosses it + read-ahead — + // NOT the ~11 a 10 MiB per-reply cap would have allowed. + expect(pulls).toBeLessThanOrEqual(7); + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).message).toMatch(/cumulative reply size/); + }); + + it('refuses a forward address over the address-length limit', async () => { + const handler = defineEmail(async (message) => { + await message.forward(`${'a'.repeat(320)}@example.com`); + }); + await expect(dispatchToHandler(makeEnvelope(), handler)).rejects.toThrow( + /over the 320-character limit/, + ); + }); + + it('records a plain header record verbatim, keeping the name casing', async () => { + const handler = defineEmail(async (message) => { + // The record form is a runtime affordance (JS handlers, or a cast): + // `ForwardableEmailMessage.forward` is typed against `Headers`. + const record = { 'X-Forwarded-By': 'void-test' } as unknown as Headers; + await message.forward('archive@example.com', record); + }); + const actions = await dispatchToHandler(makeEnvelope(), handler); + expect(actions).toEqual([ + { type: 'forward', to: 'archive@example.com', headers: { 'X-Forwarded-By': 'void-test' } }, + ]); + }); + + it.each([ + ['a CRLF in a value', { 'X-Trace': 'abc\r\nBcc: leak@evil.test' }], + ['a NUL in a value', { 'X-Trace': 'abc\0' }], + ['a non-token name', { 'X Trace': 'abc' }], + ])( + 'refuses a forward header record with %s at the call, recording nothing for it', + async (_label, headers) => { + // The record is replayed through `new Headers()` (natively and in the + // platform router). What that constructor rejects must fail here, in + // the handler, not after earlier actions have already fired. + const { message, getActions } = createMessageProxy(makeEnvelope()); + await message.forward('first@example.com'); + let thrown: unknown; + try { + await message.forward('archive@example.com', headers as unknown as Headers); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).code).toBe('MIME_ERROR'); + expect((thrown as MimeError).message).toMatch(/^forward: invalid header/); + expect(getActions()).toEqual([{ type: 'forward', to: 'first@example.com' }]); + }, + ); + + function headerBag(count: number): Record { + const bag: Record = {}; + for (let i = 0; i < count; i++) { + bag[`X-Void-${i}`] = 'v'; + } + return bag; + } + + it.each([ + ['33 headers in a record', headerBag(33), 'forward: 33 headers, over the 32-header limit.'], + [ + '33 headers in a Headers', + new Headers(headerBag(33)), + 'forward: 33 headers, over the 32-header limit.', + ], + [ + 'a 1025-character name', + { ['X'.repeat(1025)]: 'v' }, + 'forward: header name is 1025 characters, over the 1024-character limit.', + ], + [ + 'a 1025-character value in a record', + { 'X-Void-Tag': 'v'.repeat(1025) }, + 'forward: header "X-Void-Tag" value is 1025 characters, over the 1024-character limit.', + ], + [ + 'a 1025-character value in a Headers', + new Headers({ 'X-Void-Tag': 'v'.repeat(1025) }), + 'forward: header "x-void-tag" value is 1025 characters, over the 1024-character limit.', + ], + [ + 'a non-string value', + { 'X-Void-Tag': 123 }, + 'forward: header "X-Void-Tag" value is not a string.', + ], + ])( + 'refuses a forward header bag with %s at the call, recording nothing for it', + async (_label, headers, expected) => { + // `new Headers()` accepts every one of these, so the validity check + // above passes them. The platform router then applies its own count + // and per-field bounds (`forwardHeadersWithinBounds`) and drops the + // WHOLE action list — a handler that recorded this forward deployed as + // one whose managed delivery silently performed no actions. The same + // bounds, on the record about to be stored, fail the handler's call. + const { message, getActions } = createMessageProxy(makeEnvelope()); + await message.forward('first@example.com'); + let thrown: unknown; + try { + await message.forward('archive@example.com', headers as unknown as Headers); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).code).toBe('MIME_ERROR'); + expect((thrown as MimeError).message).toBe(expected); + expect(getActions()).toEqual([{ type: 'forward', to: 'first@example.com' }]); + }, + ); + + it("records a forward header bag exactly at the router's bounds", async () => { + // 32 headers, every name and value 1024 characters: the largest bag the + // managed router enacts. The bound is `>` on both sides, not `>=`. + const bag: Record = {}; + for (let i = 0; i < 32; i++) { + bag[`X-${'a'.repeat(1020)}${String(i).padStart(2, '0')}`] = 'v'.repeat(1024); + } + const { message, getActions } = createMessageProxy(makeEnvelope()); + await message.forward('archive@example.com', bag as unknown as Headers); + const actions = getActions(); + expect(actions).toHaveLength(1); + const recorded = (actions[0] as { headers?: Record }).headers ?? {}; + expect(Object.keys(recorded)).toHaveLength(32); + for (const [name, value] of Object.entries(recorded)) { + expect(name).toHaveLength(1024); + expect(value).toHaveLength(1024); + } + }); + + it('normalizes the reject reason as the router does: slice to 1000, then strip controls', () => { + // The router's own fixture (`email-router.test.ts`): 300 x "line\r\n" is + // 1800 characters; the first 1000 code units are 166 full lines plus + // "line", and each CRLF then becomes one space — 834 characters, not + // 1000. Recorded this way, the native replay hands `setReject()` the + // string the platform router would, and a harness reports it. + const { message, getActions } = createMessageProxy(makeEnvelope()); + message.setReject('line\r\n'.repeat(300)); + expect(getActions()).toEqual([{ type: 'reject', reason: 'line '.repeat(166) + 'line' }]); + }); + + it('bounds a multi-megabyte control-laden reject reason before it is recorded', () => { + // A reason built from the inbound message can be as large as the message + // and carry a remote sender's CRLF; both used to be recorded verbatim and, + // on your own account, replayed verbatim into the SMTP reply. + const { message, getActions } = createMessageProxy(makeEnvelope()); + message.setReject(`${'x'.repeat(5 * 1024 * 1024)}\r\n\x00`); + const [action] = getActions(); + expect(action).toEqual({ type: 'reject', reason: 'x'.repeat(1000) }); + }); + + it("records the router's substitute for an empty reason, and a space for a controls-only one", () => { + // The router checks for empty BEFORE it strips (`validateActions`), so a + // reason that is only controls becomes one space, not the substitute. + const empty = createMessageProxy(makeEnvelope()); + empty.message.setReject(''); + expect(empty.getActions()).toEqual([{ type: 'reject', reason: 'rejected by handler' }]); + const controls = createMessageProxy(makeEnvelope()); + controls.message.setReject('\r\n'); + expect(controls.getActions()).toEqual([{ type: 'reject', reason: ' ' }]); + }); + + it.each([ + ['no @', 'not-an-address'], + // `forward(rcptTo)` takes a bare address — Cloudflare's "verified + // destination address" — so the display-name form `sendEmail` parses + // is not an address here. + ['a display name', 'Ops '], + ['two @', 'a@b@example.com'], + ['an empty domain', 'user@'], + ['an empty local part', '@example.com'], + ['a list separator', 'a@example.com,b@example.com'], + ['whitespace', 'a b@example.com'], + ['a 65-character local part', `${'a'.repeat(65)}@example.com`], + // Inverted: these used to be recorded here and dropped by the platform + // router alone, which always wanted a dot in the domain. One package + // (`email-validator`) on both sides now, so the call refuses them. + ['a dot-less domain', 'user@localhost'], + ['a one-letter domain', 'x@y'], + // Both sides used to accept these; no dot-atom does, and an MTA refuses + // them after the handler's test reported a success. + ['doubled dots in the local part', 'a..b@example.com'], + ['a leading dot', '.a@example.com'], + ['a trailing dot before the @', 'a.@example.com'], + ['doubled dots in the domain', 'a@example..com'], + ['a non-ASCII local part', 'jöhn@example.com'], + ['a quoted local part', '"a b"@example.com'], + ])( + 'refuses a forward address with %s at the call, recording nothing for it', + async (_label, to) => { + // Same class as the header record above, for the address: Cloudflare + // refuses it at native replay, the platform router before enaction. + // Checked here with the rule `sendEmail` applies to its `to` — and + // the rule the router drops on — so a handler's forward refuses + // exactly what an outbound send refuses and nothing the router loses. + const { message, getActions } = createMessageProxy(makeEnvelope()); + await message.forward('first@example.com'); + let thrown: unknown; + try { + await message.forward(to); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).code).toBe('INVALID_TO'); + expect((thrown as MimeError).message).toBe(`to: ${INVALID_ADDRESS}`); + expect(getActions()).toEqual([{ type: 'forward', to: 'first@example.com' }]); + }, + ); + + it('refuses a hand-built reply with a malformed address at the call, recording nothing for it', async () => { + // The `{ from, to, raw }` reply form bypasses `normalizeEmail`, so it + // gets the same record-time rule as `forward(to)`: a bad address fails + // in the handler's own call, before any native replay could fire the + // actions queued ahead of it. + const cases: Array<[Record<'from' | 'to', string>, 'INVALID_FROM' | 'INVALID_TO', string]> = [ + [ + { from: 'not-an-address', to: 'ops@example.com' }, + 'INVALID_FROM', + `from: ${INVALID_ADDRESS}`, + ], + [ + { from: 'me@example.com', to: 'Ops ' }, + 'INVALID_TO', + `to: ${INVALID_ADDRESS}`, + ], + [ + { from: `${'a'.repeat(65)}@example.com`, to: 'ops@example.com' }, + 'INVALID_FROM', + `from: ${INVALID_ADDRESS}`, + ], + [{ from: 'me@example.com', to: 'a..b@example.com' }, 'INVALID_TO', `to: ${INVALID_ADDRESS}`], + ]; + for (const [addrs, code, expected] of cases) { + const { message, getActions } = createMessageProxy(makeEnvelope()); + await message.forward('first@example.com'); + let thrown: unknown; + try { + await message.reply({ + ...addrs, + raw: new TextEncoder().encode('Subject: x\r\n\r\nhi'), + } as unknown as Parameters[0]); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(MimeError); + expect((thrown as MimeError).code).toBe(code); + expect((thrown as MimeError).message).toBe(expected); + expect(getActions()).toEqual([{ type: 'forward', to: 'first@example.com' }]); + } + }); + + it('records every forward address sendEmail would accept', async () => { + // The same rule as the platform router's `isValidEmailAddress` + // (`email-validator` on both sides), so everything recorded here is + // enacted there. A local part of exactly 64 characters, a `+` tag, dots + // between atoms, a punycode IDN domain and a numeric second character + // in the TLD are all fine. + const accepted = [ + 'ops+T-42@example.com', + `${'a'.repeat(64)}@example.com`, + 'first.last@sub.example.com', + 'a@xn--bcher-kva.example', + 'a@b.c1', + ]; + const { message, getActions } = createMessageProxy(makeEnvelope()); + for (const to of accepted) { + await message.forward(to); + } + expect(getActions()).toEqual(accepted.map((to) => ({ type: 'forward', to }))); + }); + + it('setReject still works with a full queue (it shrinks the queue, so it is exempt)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const handler = defineEmail(async (message) => { + for (let i = 0; i < 100; i++) { + await message.forward(`box${i}@example.com`); + } + // The queue is at MAX_ACTIONS; setReject must not throw here — it + // purges the 100 forwards and leaves one reject behind. + message.setReject('not allowed'); + }); + const actions = await dispatchToHandler(makeEnvelope(), handler); + expect(actions).toEqual([{ type: 'reject', reason: 'not allowed' }]); + } finally { + warn.mockRestore(); + } + }); + + it('parseEmail can be called multiple times (cache replays)', async () => { + // parseEmail caches the buffered raw bytes in a WeakMap keyed by the + // proxy, so handlers can call it repeatedly without the underlying + // ReadableStream being "already consumed". After parseEmail, accessing + // message.raw still yields a usable ReadableStream (a fresh one per + // access, backed by the cached bytes). + let firstSubject: string | undefined; + let secondSubject: string | undefined; + let rawAfterParse: ReadableStream | undefined; + const handler = defineEmail(async (message) => { + const first = await parseEmail(message); + firstSubject = first.subject; + const second = await parseEmail(message); + secondSubject = second.subject; + rawAfterParse = message.raw; + }); + await dispatchToHandler(makeEnvelope(), handler); + expect(firstSubject).toBe('Help me'); + expect(secondSubject).toBe('Help me'); + // message.raw is a fresh ReadableStream each access, replayed from cache. + expect(rawAfterParse).toBeInstanceOf(ReadableStream); + const reader = rawAfterParse?.getReader(); + const chunks: Array = []; + while (reader) { + const { value, done } = await reader.read(); + if (done) { + break; + } + if (value) { + chunks.push(value); + } + } + const total = chunks.reduce((n, c) => n + c.byteLength, 0); + expect(total).toBeGreaterThan(0); + const decoded = new TextDecoder().decode( + chunks.reduce((acc, c) => { + const merged = new Uint8Array(acc.byteLength + c.byteLength); + merged.set(acc, 0); + merged.set(c, acc.byteLength); + return merged; + }, new Uint8Array(0)), + ); + expect(decoded).toContain('Subject: Help me'); + }); + + it('returns an empty action queue when the handler does nothing', async () => { + const handler = defineEmail(async () => {}); + const actions = await dispatchToHandler(makeEnvelope(), handler); + expect(actions).toEqual([]); + }); +}); diff --git a/packages/void/test/unit/email-mime.test.ts b/packages/void/test/unit/email-mime.test.ts new file mode 100644 index 000000000..d44d411d2 --- /dev/null +++ b/packages/void/test/unit/email-mime.test.ts @@ -0,0 +1,2874 @@ +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { join } from 'pathslash'; +import { describe, expect, it } from 'vite-plus/test'; +import { + buildMime, + MAX_ADDRESS_LENGTH, + MAX_ATTACHMENTS, + MAX_MESSAGE_BYTES, + MAX_RECIPIENTS, + MimeError, + normalizeEmail, + parseAddress, +} from '../../src/runtime/email/mime.ts'; +import type { Attachment } from '../../src/runtime/email/types.ts'; + +function decode(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +/** + * The one shape message `validateAddressParts` throws: the rule is + * `email-validator`, and the message names the rule rather than echoing the + * address (which may hold a control character). + */ +function invalidAddress(field: string): string { + return ( + `${field}: address is not a valid email address (ASCII local part and dotted domain, ` + + `RFC 5322 dot-atom; at most 64 characters before the '@' and 254 in total).` + ); +} + +async function build(opts: Parameters[0]) { + const email = await normalizeEmail(opts); + const { raw, messageId } = buildMime(email); + return { raw, messageId, text: decode(raw) }; +} + +function b64ToText(b64: string): string { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new TextDecoder().decode(bytes); +} + +/** + * Collect one header's physical lines: the `Name:` line plus every + * continuation (a line starting with WSP — RFC 5322 §2.2.3 folding). + */ +function headerLines(text: string, name: string): Array { + const lines = text.split('\r\n'); + const start = lines.findIndex((l) => l.startsWith(`${name}:`)); + if (start < 0) { + throw new Error(`header '${name}' not found`); + } + const collected = [lines[start]]; + for (let i = start + 1; i < lines.length; i++) { + if (lines[i].startsWith(' ') || lines[i].startsWith('\t')) { + collected.push(lines[i]); + } else { + break; + } + } + return collected; +} + +/** RFC 5322 §2.2.3 unfolding: drop the CRLF, keep the leading WSP. */ +function unfoldHeader(text: string, name: string): string { + return headerLines(text, name).join('').slice(`${name}:`.length).trimStart(); +} + +/** Every encoded-word in a value, as raw `=?UTF-8?B?...?=` strings. */ +function encodedWords(value: string): Array { + return value.match(/=\?UTF-8\?B\?[A-Za-z0-9+/=]*\?=/g) ?? []; +} + +/** + * Independent RFC 2047 decoder used as an in-test oracle. Per §6.2 the + * linear whitespace separating two *adjacent* encoded-words is dropped + * while whitespace next to ordinary text is kept. Each word is + * base64-decoded on its own, which is only correct because §5 forbids + * splitting a character across adjacent encoded-words — so this also + * proves the chunker never cut a multi-octet character in half. + */ +function decodeEncodedWords(value: string): string { + let out = ''; + let last = 0; + let previousWasWord = false; + for (const match of value.matchAll(/=\?UTF-8\?B\?([A-Za-z0-9+/=]*)\?=/g)) { + const gap = value.slice(last, match.index); + if (!(previousWasWord && /^[ \t]*$/.test(gap))) { + out += gap; + } + out += b64ToText(match[1]); + last = match.index + match[0].length; + previousWasWord = true; + } + return out + value.slice(last); +} + +describe('normalizeEmail', () => { + it('parses string addresses with display names', async () => { + const email = await normalizeEmail({ + from: 'Acme ', + to: 'user@example.com', + subject: 'Welcome', + text: 'Hi', + }); + expect(email.from).toEqual({ email: 'hello@acme.dev', name: 'Acme' }); + expect(email.to).toEqual([{ email: 'user@example.com' }]); + }); + + it('parses object addresses', async () => { + const email = await normalizeEmail({ + from: { email: 'a@x.dev', name: 'A' }, + to: { email: 'b@y.dev' }, + subject: 's', + text: 't', + }); + expect(email.from).toEqual({ email: 'a@x.dev', name: 'A' }); + }); + + it('accepts arrays for to/cc/bcc', async () => { + const email = await normalizeEmail({ + from: 'a@x.dev', + to: ['b@y.dev', { email: 'c@y.dev', name: 'C' }], + cc: 'd@y.dev', + bcc: ['e@y.dev'], + subject: 's', + text: 't', + }); + expect(email.to).toHaveLength(2); + expect(email.cc).toHaveLength(1); + expect(email.bcc).toHaveLength(1); + }); + + it('rejects missing text and html', async () => { + await expect( + normalizeEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 's' }), + ).rejects.toBeInstanceOf(MimeError); + }); + + it('rejects empty to list', async () => { + await expect( + normalizeEmail({ from: 'a@x.dev', to: [], subject: 's', text: 't' }), + ).rejects.toThrow(/'to'/); + }); + + it('rejects malformed addresses', async () => { + await expect( + normalizeEmail({ from: 'no-at-sign', to: 'b@y.dev', subject: 's', text: 't' }), + ).rejects.toThrow(/from/); + }); + + // The shape check only required an '@' to be present anywhere, so a + // trailing, leading or doubled separator passed the preflight and was + // reported as a successful capture in the dev inbox — then failed only + // after reaching the platform. Every code below is the ADVERTISED one. + it.each(['user@', '@example.com', 'a@b@c'])( + 'rejects %s, which used to pass the preflight', + async (bad) => { + await expect( + normalizeEmail({ from: bad, to: 'b@y.dev', subject: 's', text: 't' }), + ).rejects.toThrow(/from/); + await expect( + normalizeEmail({ from: 'a@x.dev', to: bad, subject: 's', text: 't' }), + ).rejects.toThrow(/to/); + }, + ); + + // Inverted: these used to be accepted as legal RFC 5321 mailboxes while + // the platform router refused them for the missing dot, so a handler's + // `forward('user@localhost')` was recorded here and dropped there. The + // rule is now `email-validator` on both sides, which wants a dotted + // domain with a letter-first TLD of at least two characters. + it.each(['user@localhost', 'x@y', 'a@example'])( + 'now refuses %s (no dot in the domain), as the router always did', + async (bad) => { + await expect( + normalizeEmail({ from: bad, to: 'b@y.dev', subject: 's', text: 't' }), + ).rejects.toThrow(invalidAddress('from')); + await expect( + normalizeEmail({ from: 'a@x.dev', to: bad, subject: 's', text: 't' }), + ).rejects.toThrow(invalidAddress('to')); + }, + ); + + // RFC 5322 §2.1.1 makes 998 a MUST and §3.2.2 allows folding ONLY at + // whitespace, so a long token with no spaces cannot be folded at all — + // `Subject:\r\n ` shortens nothing. The RFC 2047 chunking added + // earlier only covers NON-ASCII values; a pure-ASCII token bypasses + // `encodeWord` entirely and leaks through two separate paths in + // `foldHeader`: the single-chunk early return, and the fold loop, which + // still gives an over-long chunk its own line. Nothing downstream + // repairs it — the proxy streams our bytes verbatim. + const TOKEN = 'x'.repeat(1100); + + /** Longest physical line of the rendered message, in octets. */ + async function longestLine(opts: Record): Promise { + const email = await normalizeEmail(opts as never); + const raw = new TextDecoder().decode(buildMime(email).raw); + return Math.max(...raw.split('\r\n').map((l) => new TextEncoder().encode(l).length)); + } + + it.each([ + ['subject', { from: 'a@x.dev', to: 'b@y.dev', subject: TOKEN, text: 't' }], + [ + 'a custom header value', + { from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't', headers: { 'X-Trace': TOKEN } }, + ], + [ + 'a display name', + { from: { email: 'a@x.dev', name: TOKEN }, to: 'b@y.dev', subject: 's', text: 't' }, + ], + [ + 'an attachment filename', + { + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + attachments: [{ filename: `${TOKEN}.pdf`, content: new Uint8Array([1, 2, 3]) }], + }, + ], + ])('rejects an unbreakable token in %s rather than emitting a >998 octet line', async (_w, o) => { + await expect(normalizeEmail(o as never).then((e) => buildMime(e))).rejects.toThrow( + /998|line length/i, + ); + }); + + it('still accepts a token that lands exactly on the limit', async () => { + // `Subject: ` costs 9 octets, so 989 is the largest token that fits. + expect( + await longestLine({ from: 'a@x.dev', to: 'b@y.dev', subject: 'x'.repeat(989), text: 't' }), + ).toBe(998); + }); + + it('leaves foldable values alone', async () => { + const spaced = Array.from({ length: 200 }, (_, i) => `word${i}`).join(' '); + expect( + await longestLine({ from: 'a@x.dev', to: 'b@y.dev', subject: spaced, text: 't' }), + ).toBeLessThanOrEqual(998); + }); + + it('measures a line in UTF-8 octets, not UTF-16 units, and reports its number', async () => { + // A CJK filename is rendered verbatim into ` filename="…"`: 3 octets per + // character inside 12 of framing, so 328 characters land exactly on the + // 998-octet limit and 329 are one octet over. The pre-render guard sees + // only `length` (341) and lets both through; the post-render scan must + // count octets. The line number pins the CRLF-delimited index into the + // multipart body, where the part's own headers live. + const fits = '漢'.repeat(328); + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + attachments: [{ filename: fits, content: 'x' }], + }); + expect(text).toContain(`\r\n filename="${fits}"\r\n`); + await expect( + build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + attachments: [{ filename: `${fits}漢`, content: 'x' }], + }), + ).rejects.toThrow(/header line 19 is 999 octets, over the 998-octet limit/); + }); + + // A quoted-string parameter carries `\\` and `"` only as quoted-pairs + // (RFC 5322 §3.2.4 — qtext excludes both). Deleting quotes and leaving + // backslashes raw makes a trailing backslash escape the CLOSING quote, + // and silently eats embedded ones: a Windows path `C:\\Users\\bob\\r.pdf` + // reached the recipient as `C:Usersbobr.pdf`. This file's own folder + // already tracks quoted-pairs, so the emitter was contradicting it. + it.each([ + ['a trailing backslash', 'bad\\'], + ['an embedded backslash', 'a\\b'], + ['a Windows path', 'C:\\Users\\bob\\report.pdf'], + ['an embedded quote', 'he said "hi".pdf'], + ['a plain name', 'normal.pdf'], + ])('round-trips %s in the attachment filename', async (_what, filename) => { + const email = await normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + attachments: [{ filename, content: new Uint8Array([1, 2, 3]) }], + }); + const text = new TextDecoder().decode(buildMime(email).raw); + + // Recover it the way a receiver does: read the quoted-string back, + // undoing quoted-pairs. A raw backslash before the closing quote would + // leave the parameter unterminated and this would not match. + const m = text.match(/Content-Disposition: attachment; filename="((?:[^"\\]|\\.)*)"/); + expect(m, `no terminated filename parameter in:\n${text.slice(0, 400)}`).not.toBeNull(); + expect(m![1].replace(/\\(.)/g, '$1')).toBe(filename); + }); + + it('rejects CRLF in subject', async () => { + await expect( + normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 'X\r\nBcc: attacker@evil.dev', + text: 't', + }), + ).rejects.toThrow(/subject/); + }); + + it('rejects CRLF in custom header values', async () => { + await expect( + normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + headers: { 'X-Campaign': 'welcome\r\nBcc: attacker@evil.dev' }, + }), + ).rejects.toThrow(/X-Campaign/); + }); + + it('rejects invalid header names', async () => { + await expect( + normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + headers: { 'X-Bad\r\nInjected': 'v' }, + }), + ).rejects.toThrow(/header name/); + }); + + it('rejects CRLF in address email', async () => { + await expect( + normalizeEmail({ + from: { email: 'a@x.dev\r\nBcc: attacker@evil.dev', name: 'A' }, + to: 'b@y.dev', + subject: 's', + text: 't', + }), + ).rejects.toThrow(/from/); + }); + + it('rejects CRLF in display name', async () => { + await expect( + normalizeEmail({ + from: { email: 'a@x.dev', name: 'A\r\nBcc: attacker@evil.dev' }, + to: 'b@y.dev', + subject: 's', + text: 't', + }), + ).rejects.toThrow(/from/); + }); + + it('rejects CRLF in attachment filename', async () => { + await expect( + normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + attachments: [{ filename: 'evil.txt\r\nX-Injected: 1', content: 'x' }], + }), + ).rejects.toThrow(/filename/); + }); + + it("rejects an attachment disposition other than 'attachment' or 'inline'", async () => { + // `Attachment.disposition` is a two-literal union at compile time only. + // A JS caller, or a JSON-parsed options object, can pass any string, + // and `buildAttachmentPart` interpolates it verbatim into + // `Content-Disposition`. Unlike its neighbours it never went through + // `rejectCRLF`, so a CRLF in it rendered as a second header line inside + // the attachment part (`X-Injected: 1; filename="f.txt"`), and a blank + // line in it ended the part's header block early. + const base = { from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't' }; + const message = "attachment disposition must be 'attachment' or 'inline'."; + for (const disposition of [ + 'attachment\r\nX-Injected: 1', + 'attachment\r\n\r\nbody', + 'form-data', + ]) { + const attempt = normalizeEmail({ + ...base, + attachments: [{ filename: 'f.txt', content: 'x', disposition: disposition as never }], + }); + await expect(attempt).rejects.toThrow(message); + await expect(attempt).rejects.toMatchObject({ code: 'MIME_ERROR' }); + } + // Both literals still render as given, whatever the body shape. + for (const disposition of ['attachment', 'inline'] as const) { + const { text } = await build({ + ...base, + attachments: [{ filename: 'f.txt', content: 'x', disposition }], + }); + expect(text).toContain(`Content-Disposition: ${disposition}; filename="f.txt"`); + } + // And an omitted disposition still defaults on `contentId`. + const defaulted = await normalizeEmail({ + ...base, + attachments: [ + { filename: 'f.txt', content: 'x' }, + { filename: 'logo.png', content: 'x', contentId: 'logo' }, + ], + }); + expect(defaulted.attachments.map((a) => a.disposition)).toEqual(['attachment', 'inline']); + }); + + it('rejects whitespace inside an address', async () => { + await expect( + normalizeEmail({ from: 'a b@x.dev', to: 'b@y.dev', subject: 's', text: 't' }), + ).rejects.toThrow(/from/); + }); + + // `MAX_MESSAGE_BYTES` used to be enforced ONLY at the end of `buildMime` — + // after every attachment had been materialized in parallel by a + // `Promise.all`, base64'd, sliced at 76 columns, joined and re-encoded. + // Peak live memory measured about 10x the attachment bytes, so a set over + // roughly 12 MiB terminated the isolate instead of returning the + // `MIME_ERROR` the docs promise. Base64 expands by `ceil(n / 3) * 4`, so + // nothing above three quarters of the message cap can ever fit anyway. + const MAX_ATTACHMENT_BYTES_TOTAL = Math.floor((MAX_MESSAGE_BYTES * 3) / 4); + + /** + * A one-byte Blob wearing a declared `size`, recording whether anything + * ever read it. Shadowing beats allocating: the whole point is that the + * second attachment's bytes are never touched. + */ + function fakeBlob(name: string, size: number, reads: Array): Blob { + const blob = new Blob([new Uint8Array(1)]); + Object.defineProperty(blob, 'size', { value: size }); + Object.defineProperty(blob, 'arrayBuffer', { + value: async () => { + reads.push(name); + return new ArrayBuffer(size); + }, + }); + return blob; + } + + it('rejects an oversize attachment set without materializing past the first', async () => { + const reads: Array = []; + const half = Math.ceil(MAX_ATTACHMENT_BYTES_TOTAL / 2) + 1; + await expect( + normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + attachments: [ + { filename: 'first.bin', content: fakeBlob('first', half, reads) }, + { filename: 'second.bin', content: fakeBlob('second', half, reads) }, + ], + }), + ).rejects.toThrow( + new RegExp(`exceeds Cloudflare limit of ${MAX_ATTACHMENT_BYTES_TOTAL} bytes`), + ); + // The fix, stated as an assertion: the second attachment is charged from + // its DECLARED size and rejected before a single byte of it is copied. + expect(reads).toEqual(['first']); + }); + + it('still builds a legal attachment under the bound', async () => { + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + attachments: [{ filename: 'ok.bin', content: new Uint8Array(1024) }], + }); + expect(text).toContain('Content-Disposition: attachment; filename="ok.bin"'); + }); + + describe('attachment count', () => { + // `MAX_ATTACHMENT_BYTES_TOTAL` never advances on a zero-byte attachment, + // and the size projection in `buildMime` runs only after every entry + // has been normalized — about 275 bytes of live heap each. Measured, 2M + // empty attachments terminated a 128 MB isolate in 0.3 s instead of + // returning `MIME_ERROR`. The count is now refused before the loop, at + // the largest count the projection could admit: 119 octets per part — + // `ATTACHMENT_PART_MIN_OCTETS` plus a one-character filename rendered + // twice — under `MAX_MESSAGE_BYTES`. + const PART_MIN_OCTETS = 117 + 2; + + /** An attachment that records any read of the fields the loop touches first. */ + function watchedAttachment(reads: Array, i: number): Attachment { + return { + get filename() { + reads.push(i); + return 'a'; + }, + get content() { + reads.push(i); + return ''; + }, + }; + } + + it('is derived from the per-part floor and the message cap', () => { + expect(MAX_ATTACHMENTS).toBe(Math.floor(MAX_MESSAGE_BYTES / PART_MIN_OCTETS)); + expect(MAX_ATTACHMENTS).toBe(88_115); + }); + + it('refuses one over MAX_ATTACHMENTS before normalizing any of them', async () => { + const reads: Array = []; + const attachments = Array.from({ length: MAX_ATTACHMENTS + 1 }, (_, i) => + watchedAttachment(reads, i), + ); + let error: unknown; + try { + await normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + attachments, + }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(MimeError); + expect((error as MimeError).code).toBe('MIME_ERROR'); + expect((error as MimeError).message).toBe( + `email: too many attachments (max ${MAX_ATTACHMENTS}, got ${MAX_ATTACHMENTS + 1}).`, + ); + expect(reads).toEqual([]); + }); + + it('accepts exactly MAX_ATTACHMENTS', async () => { + const one = { filename: 'a', content: '' }; + const email = await normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + attachments: Array.from({ length: MAX_ATTACHMENTS }, () => one), + }); + expect(email.attachments.length).toBe(MAX_ATTACHMENTS); + }); + + it('never rejects a count the size projection would have admitted', () => { + // The smallest part there is: a one-character filename, an empty + // media type and no content. One over the cap is refused by the + // projection on framing alone, so the count bound only ever refuses + // what `buildMime` was going to; exactly at the cap the projection + // admits it and the post-render check is the one to refuse the + // 12.9 MB it renders to. + const part = { + filename: 'a', + contentType: '', + bytes: new Uint8Array(0), + contentId: undefined, + disposition: 'inline' as const, + }; + const email = (n: number) => ({ + from: { email: 'a@x.dev' }, + to: [{ email: 'b@y.dev' }], + cc: [], + bcc: [], + subject: 's', + text: 't', + headers: {}, + attachments: Array.from({ length: n }, () => part), + }); + expect(() => buildMime(email(MAX_ATTACHMENTS + 1))).toThrow( + /message is at least \d+ bytes once encoded, which exceeds Cloudflare limit/, + ); + expect(() => buildMime(email(MAX_ATTACHMENTS))).toThrow( + /message size \d+ bytes exceeds Cloudflare limit/, + ); + }); + + it('refuses a non-array attachments value instead of walking it unbounded', async () => { + // A `Set` iterates but has no `length`, so a count read off the + // options would bound nothing; the type admits only an array. + await expect( + normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + attachments: new Set([{ filename: 'a', content: '' }]) as unknown as Array, + }), + ).rejects.toThrow(/attachments must be an array/); + }); + }); + + /** + * An object address whose `email` records every read. `parseAddress` has + * to read it to do anything at all, so an untouched entry proves the list + * was refused before it was parsed. + */ + function watchedAddress(reads: Array, i: number): { email: string } { + return { + get email() { + reads.push(i); + return `u${i}@example.com`; + }, + }; + } + + it('refuses more than MAX_RECIPIENTS across to, cc and bcc before parsing any of them', async () => { + // The platform proxy refuses the same count on the deduplicated list, + // but only after the tenant has parsed every entry and rendered the + // `To:` header — measured, 230k short valid addresses (a legal 4.8 MiB + // message) terminated a 128 MB isolate on the way there. + const reads: Array = []; + const to = Array.from({ length: MAX_RECIPIENTS - 1 }, (_, i) => watchedAddress(reads, i)); + let error: unknown; + try { + await normalizeEmail({ + from: 'a@x.dev', + to, + cc: watchedAddress(reads, MAX_RECIPIENTS - 1), + bcc: [watchedAddress(reads, MAX_RECIPIENTS)], + subject: 's', + text: 't', + }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(MimeError); + expect((error as MimeError).code).toBe('INVALID_TO'); + expect((error as MimeError).message).toMatch( + new RegExp(`too many recipients \\(max ${MAX_RECIPIENTS}, got ${MAX_RECIPIENTS + 1}\\)`), + ); + expect(reads).toEqual([]); + }); + + it('accepts exactly MAX_RECIPIENTS across the three fields', async () => { + const to = Array.from({ length: MAX_RECIPIENTS - 2 }, (_, i) => `u${i}@example.com`); + const email = await normalizeEmail({ + from: 'a@x.dev', + to, + cc: 'cc@example.com', + bcc: ['bcc@example.com'], + subject: 's', + text: 't', + }); + expect(email.to.length + email.cc.length + email.bcc.length).toBe(MAX_RECIPIENTS); + }); + + it('refuses a quoted filename that could never fold onto a legal line, before rendering it', async () => { + // ` filename="…"` is the shortest line a quoted parameter can land on: + // 12 octets plus the name. The post-render line check refuses the same + // input, but only after `quoteParam` has escaped it — measured, a + // filename of ~1.7M quote characters killed a 128 MB isolate there. + const longest = 'a'.repeat(998 - 12); + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + attachments: [{ filename: longest, content: 'x' }], + }); + expect(text).toContain(`filename="${longest}"`); + await expect( + build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + attachments: [{ filename: `${longest}b`, content: 'x' }], + }), + ).rejects.toThrow( + /attachment filename is 987 characters; a quoted `filename=` parameter cannot be folded/, + ); + // The guard is cheap on the input that used to be fatal. + const started = performance.now(); + await expect( + build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + attachments: [{ filename: '"'.repeat(2_000_000), content: 'x' }], + }), + ).rejects.toThrow(/cannot be folded/); + expect(performance.now() - started).toBeLessThan(1000); + }); + + it('refuses a quoted display name that could never fold onto a legal line', async () => { + // Only a name that must be QUOTED (ASCII with specials) is one + // unbreakable token; a plain name folds at its spaces and a non-ASCII + // name becomes encoded-words, so neither is bounded here. + const plain = 'word '.repeat(300).trim(); + await expect( + build({ from: 'a@x.dev', to: { email: 'b@y.dev', name: plain }, subject: 's', text: 't' }), + ).resolves.toBeTruthy(); + const quotedFits = `x,${'a'.repeat(998 - 3 - 2)}`; + await expect( + build({ + from: 'a@x.dev', + to: { email: 'b@y.dev', name: quotedFits }, + subject: 's', + text: 't', + }), + ).resolves.toBeTruthy(); + await expect( + build({ + from: 'a@x.dev', + to: { email: 'b@y.dev', name: `${quotedFits}b` }, + subject: 's', + text: 't', + }), + ).rejects.toThrow( + /to: display name is 996 characters and must be quoted, which cannot be folded/, + ); + await expect( + build({ + from: 'a@x.dev', + to: { email: 'b@y.dev', name: '"'.repeat(2_000_000) }, + subject: 's', + text: 't', + }), + ).rejects.toThrow(/cannot be folded/); + }); + + it('refuses a header name that could never sit on a legal line, before rendering it', async () => { + // A field name never folds. `Name:` alone is the shortest line it can + // sit on — what `foldHeader` emits when the value's first token does + // not fit beside a long name — so 997 characters is the longest name + // with ANY legal rendering, and only with a value that folds: `Name:` + // is 998 octets, then ` a b`. The same name over an empty value is one + // 999-octet line, which is the post-render line check's to refuse, not + // this guard's: a bound one character tighter would reject a message + // that fits. + const longest = `X${'a'.repeat(998 - 1 - 1)}`; + expect(longest).toHaveLength(997); + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + headers: { [longest]: 'a b' }, + }); + expect(headerLines(text, longest)).toEqual([`${longest}:`, ' a b']); + await expect( + build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + headers: { [longest]: '' }, + }), + ).rejects.toThrow(/header line \d+ is 999 octets, over the 998-octet limit/); + // One character more has no legal rendering whatever the value. + await expect( + normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + headers: { [`${longest}b`]: 'a b' }, + }), + ).rejects.toThrow(/headers: a header name is 998 characters; a field name cannot be folded/); + // The guard is cheap on the input that used to be rendered four times + // over before the line check saw it: a 5 MiB name took 23 ms and + // 5.6 MB of heap to refuse post-render (measured); a 50 MiB one killed + // a 128 MB isolate. Refused here in 2 ms with no allocation. + const started = performance.now(); + await expect( + normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + headers: { ['X'.repeat(5 * 1024 * 1024)]: '' }, + }), + ).rejects.toThrow(/a field name cannot be folded/); + expect(performance.now() - started).toBeLessThan(1000); + }); + + it('refuses an address over MAX_ADDRESS_LENGTH, with the code for its field', async () => { + // RFC 5321 §4.5.3.1: 64-octet local part + `@` + 255-octet domain is the + // sum of the component maxima — the pre-parse bound, not a deliverable + // path (the package's 254 total is that). At exactly 320 the package + // answers; one over, this one answers first, before anything is scanned. + const longest = `${'a'.repeat(64)}@${'b'.repeat(251)}.dev`; + expect(longest).toHaveLength(MAX_ADDRESS_LENGTH); + const over = `${'a'.repeat(65)}@${'b'.repeat(251)}.dev`; + await expect( + normalizeEmail({ from: 'a@x.dev', to: longest, subject: 's', text: 't' }), + ).rejects.toThrow(invalidAddress('to')); + // 254 characters of 63-character-or-shorter labels: the longest address + // the package accepts. + const deliverable = `${'a'.repeat(64)}@${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(57)}.dev`; + expect(deliverable).toHaveLength(254); + const email = await normalizeEmail({ + from: deliverable, + to: deliverable, + subject: 's', + text: 't', + }); + expect(email.to[0].email).toBe(deliverable); + for (const [field, code] of [ + ['to', 'INVALID_TO'], + ['from', 'INVALID_FROM'], + ['cc', 'MIME_ERROR'], + ] as const) { + let error: unknown; + try { + await normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + [field]: over, + subject: 's', + text: 't', + }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(MimeError); + expect((error as MimeError).code).toBe(code); + expect((error as MimeError).message).toMatch( + new RegExp( + `^${field}: address is ${over.length} characters, over the ${MAX_ADDRESS_LENGTH}-character limit`, + ), + ); + } + // The bound is on the parsed address, as at the proxy — a long display + // name around a legal address is not an oversize address. + const named = await normalizeEmail({ + from: 'a@x.dev', + to: `"${'n'.repeat(2 * MAX_ADDRESS_LENGTH)}" <${deliverable}>`, + subject: 's', + text: 't', + }); + expect(named.to[0].email).toBe(deliverable); + }); + + it('refuses an address over 254 characters (RFC 5321 §4.5.3.1.3), by the package rule', async () => { + // RFC 5321 §4.5.3.1.3: a 256-octet forward-path `` is the + // longest every receiver MUST accept, so the address is at most 254. + // The 320 total is the sum of the local-part and domain maxima, not a + // path — `a@` + a 255-octet domain is 257 characters, under the + // total-length bound, and `void dev` and the test harness once captured + // it as a success. `email-validator` checks 254 before its regex, and + // the platform router applies the same package, so both sides refuse it. + const label = 'b'.repeat(63); + const domain255 = [label, label, label, label].join('.'); + expect(domain255).toHaveLength(255); + const over = `a@${domain255}`; + expect(over).toHaveLength(257); + expect(over.length).toBeLessThan(MAX_ADDRESS_LENGTH); + const longest = `a@${domain255.slice(0, 252)}`; + expect(longest).toHaveLength(254); + const email = await normalizeEmail({ from: longest, to: longest, subject: 's', text: 't' }); + expect(email.to[0].email).toBe(longest); + for (const [field, code] of [ + ['to', 'INVALID_TO'], + ['from', 'INVALID_FROM'], + ['cc', 'MIME_ERROR'], + ] as const) { + let error: unknown; + try { + await normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + [field]: over, + subject: 's', + text: 't', + }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(MimeError); + expect((error as MimeError).code).toBe(code); + expect((error as MimeError).message).toBe(invalidAddress(field)); + } + // Inverted: a raw IDN domain used to be accepted and counted in UTF-8 + // octets (SMTPUTF8). The package is ASCII-only, so octets and characters + // are the same thing and a raw `ü` label is refused however short; the + // same domain is accepted as punycode, which is what DNS carries. + const idnRaw = `a@${'ü'.repeat(31)}.${'b'.repeat(63)}`; + await expect( + normalizeEmail({ from: 'a@x.dev', to: idnRaw, subject: 's', text: 't' }), + ).rejects.toThrow(invalidAddress('to')); + const punycode = 'a@xn--bcher-kva.example'; + const idn = await normalizeEmail({ from: 'a@x.dev', to: punycode, subject: 's', text: 't' }); + expect(idn.to[0].email).toBe(punycode); + // A label over 63 characters is refused on its own, inside the 254 total. + const longLabel = `a@${'b'.repeat(64)}.dev`; + await expect( + normalizeEmail({ from: 'a@x.dev', to: longLabel, subject: 's', text: 't' }), + ).rejects.toThrow(invalidAddress('to')); + // The display-name form is bounded on the same parsed address, and the + // total-length bound still answers first for one over both. + expect(() => parseAddress(`Name <${over}>`, 'to')).toThrow(invalidAddress('to')); + expect(() => parseAddress(`${'a'.repeat(65)}@${domain255.slice(0, 200)}`, 'to')).toThrow( + invalidAddress('to'), + ); + expect(() => parseAddress(`${'a'.repeat(65)}@${domain255}`, 'to')).toThrow( + /over the 320-character limit/, + ); + }); + + it('refuses a local part over 64 characters (RFC 5321 §4.5.3.1.1), by the package rule', async () => { + // RFC 5321 §4.5.3.1.1: 64 octets is the longest local part every + // receiver MUST accept. The total-length bound alone let this through + // — `'a'.repeat(65)}@x.dev` is 71 characters — so `void dev` and the + // test harness once captured it as a success. `email-validator` + // enforces the 64, and the platform router applies the same package. + const longest = `${'a'.repeat(64)}@x.dev`; + const over = `${'a'.repeat(65)}@x.dev`; + expect(over.length).toBeLessThan(MAX_ADDRESS_LENGTH); + const email = await normalizeEmail({ from: longest, to: longest, subject: 's', text: 't' }); + expect(email.to[0].email).toBe(longest); + for (const [field, code] of [ + ['to', 'INVALID_TO'], + ['from', 'INVALID_FROM'], + ['cc', 'MIME_ERROR'], + ] as const) { + let error: unknown; + try { + await normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + [field]: over, + subject: 's', + text: 't', + }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(MimeError); + expect((error as MimeError).code).toBe(code); + expect((error as MimeError).message).toBe(invalidAddress(field)); + } + // Inverted: a non-ASCII local part (SMTPUTF8) used to be accepted and + // counted in UTF-8 octets. The package is ASCII-only, so `ünicode@` and + // a CJK local part are refused however short, on both sides. + for (const bad of ['ünicode@example.com', 'jöhn@example.com', `${'漢'.repeat(21)}@x.dev`]) { + await expect( + normalizeEmail({ from: 'a@x.dev', to: bad, subject: 's', text: 't' }), + ).rejects.toThrow(invalidAddress('to')); + } + // The display-name form is bounded on the same parsed address, and the + // total-length bound still answers first for an address over both. + expect(() => parseAddress(`Name <${over}>`, 'to')).toThrow(invalidAddress('to')); + expect(() => parseAddress(`${'a'.repeat(65)}@${'b'.repeat(251)}.dev`, 'to')).toThrow( + /over the 320-character limit/, + ); + }); + + describe('control characters in an address', () => { + // Every C0 control and DEL once passed the hand-written rule (which + // refused only CR, LF, NUL and what `\s` matches): `a\x01b@example.com` + // was accepted and `formatAddress` wrote the byte raw into the `To:` + // line, captured as a success under `void dev` and by the test harness, + // and dropped by the platform router after earlier actions had fired. + // `email-validator` admits only `atext`, so every control is refused, + // and the message never echoes the address. + const controls = [ + ['SOH', '\x01'], + ['ESC', '\x1b'], + ['US', '\x1f'], + ['DEL', '\x7f'], + ['TAB', '\t'], + ] as const; + + it('refuses a bare address carrying one, with the code for its field', async () => { + for (const [, ch] of controls) { + const bad = `a${ch}b@example.com`; + for (const [field, code] of [ + ['to', 'INVALID_TO'], + ['from', 'INVALID_FROM'], + ['cc', 'MIME_ERROR'], + ['bcc', 'MIME_ERROR'], + ['replyTo', 'MIME_ERROR'], + ] as const) { + let error: unknown; + try { + await normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + [field]: bad, + subject: 's', + text: 't', + }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(MimeError); + expect((error as MimeError).code).toBe(code); + expect((error as MimeError).message).toBe(invalidAddress(field)); + expect((error as MimeError).message).not.toContain(ch); + } + } + }); + + it('refuses it in the object and display-name forms on the same parsed address', async () => { + for (const [, ch] of controls) { + const bad = `a${ch}b@example.com`; + await expect( + normalizeEmail({ + from: 'a@x.dev', + to: { name: 'Bob', email: bad }, + subject: 's', + text: 't', + }), + ).rejects.toThrow(invalidAddress('to')); + expect(() => parseAddress(`Bob <${bad}>`, 'to')).toThrow(invalidAddress('to')); + } + // CR, LF and NUL in the object form reach the address rule directly + // (the string form is refused before it is parsed, by `rejectCRLF`): + // the package refuses them with the field's own code, so dropping the + // separate CRLF scan from the bare address lost nothing. + for (const ch of ['\r', '\n', '\0']) { + let error: unknown; + try { + await normalizeEmail({ + from: 'a@x.dev', + to: { email: `a${ch}b@example.com` }, + subject: 's', + text: 't', + }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(MimeError); + expect((error as MimeError).code).toBe('INVALID_TO'); + expect((error as MimeError).message).toBe(invalidAddress('to')); + expect(() => parseAddress(`Bob `, 'to')).toThrow( + /must not contain CR, LF, or NUL/, + ); + } + // The length bound still answers first: the scan is over at most 320 + // characters, so an over-long address is refused before it is read. + expect(() => parseAddress(`a\x01${'b'.repeat(320)}@x.dev`, 'to')).toThrow( + /over the 320-character limit/, + ); + }); + + it('never renders one raw from a display name, which is encoded instead', async () => { + // A name is not an address: a control in it is a character like any + // other and `formatAddress` RFC 2047-encodes the non-printable name, + // so the byte reaches the wire only inside base64. Pinned so the + // address rule is never mistaken for the reason the header is clean. + // Every C0 control and DEL except the CR and LF that delimit lines. + // eslint-disable-next-line no-control-regex -- C0 controls and DEL are the point + const rawControl = /[\x00-\x09\x0b\x0c\x0e-\x1f\x7f]/; + for (const [, ch] of controls) { + const { text } = await build({ + from: 'a@x.dev', + to: { name: `Bo${ch}b`, email: 'b@y.dev' }, + subject: 's', + text: 't', + }); + const toLine = unfoldHeader(text, 'To'); + expect(toLine).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?= $/); + expect(decodeEncodedWords(toLine)).toBe(`Bo${ch}b `); + expect(text.slice(0, text.indexOf('\r\n\r\n'))).not.toMatch(rawControl); + } + }); + }); + + describe('control characters in an attachment filename', () => { + // `rejectCRLF` refused only CR, LF and NUL, and a filename is rendered + // through `quoteParam` — never `encodeWord` — so `invoice\x01.pdf` was + // written raw into both `name="…"` and `filename="…"` (measured on the + // wire: `… 69 6e 76 6f 69 63 65 01 2e 70 64 66 22`), captured as a + // success under `void dev` and by the harness, and left for the + // receiving MTA. RFC 5322 §3.2.4 `qtext` is printable US-ASCII less `"` + // and `\`; a control in a quoted-string is `obs-qtext`, which §4 says + // MUST NOT be generated. The message names the code point and never + // echoes the value. + const base = { from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't' }; + const controls = [ + ['SOH', '\x01', 'U+0001'], + ['ESC', '\x1b', 'U+001B'], + ['US', '\x1f', 'U+001F'], + ['DEL', '\x7f', 'U+007F'], + ] as const; + + it('refuses one, naming its code point rather than echoing it', async () => { + for (const [, ch, point] of controls) { + let error: unknown; + try { + await normalizeEmail({ + ...base, + attachments: [{ filename: `invoice${ch}.pdf`, content: 'x' }], + }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(MimeError); + expect((error as MimeError).code).toBe('MIME_ERROR'); + expect((error as MimeError).message).toBe( + `attachment filename contains a control character (${point}).`, + ); + expect((error as MimeError).message).not.toContain(ch); + } + // CR, LF and NUL still answer first, with the message they always had. + for (const ch of ['\r', '\n', '\0']) { + await expect( + normalizeEmail({ ...base, attachments: [{ filename: `a${ch}b.pdf`, content: 'x' }] }), + ).rejects.toThrow('attachment filename: must not contain CR, LF, or NUL.'); + } + }); + + it('accepts HTAB, which is folding white space inside a quoted-string (RFC 5322 §3.2.4), unchanged', async () => { + // `quoted-string = DQUOTE *([FWS] qcontent) [FWS] DQUOTE` and `FWS` is + // made of `WSP` (SP / HTAB), so a tab between characters is legal + // exactly as a space is; `obs-NO-WS-CTL`, the RFC's own list of the + // controls that used to be `qtext`, leaves %d9 out. Rendered as it + // was: raw, inside the quotes, in both parameters. + const { text } = await build({ + ...base, + attachments: [{ filename: 'a\tb.pdf', content: 'x' }], + }); + expect(text).toContain('; name="a\tb.pdf"\r\n'); + expect(text).toContain('; filename="a\tb.pdf"\r\n'); + }); + + it('still renders a non-ASCII filename as raw UTF-8 in the quoted parameter', async () => { + // The existing path, pinned: no RFC 2231 `filename*=` and no + // encoded-word — the UTF-8 octets go inside the quotes as they did. + const { raw, text } = await build({ + ...base, + attachments: [{ filename: 'résumé.pdf', content: 'x' }], + }); + expect(text).toContain('; name="résumé.pdf"\r\n'); + expect(text).toContain('; filename="résumé.pdf"\r\n'); + expect(text).not.toContain('filename*='); + const hex = [...raw].map((b) => b.toString(16).padStart(2, '0')).join(' '); + expect(hex).toContain('66 69 6c 65 6e 61 6d 65 3d 22 72 c3 a9 73 75 6d c3 a9 2e 70 64 66 22'); + }); + + it('keeps the length bound in front of the scan', async () => { + // "Bound before you scan": an over-long filename is refused by the + // `QUOTED_FILENAME_LINE_OVERHEAD` guard before its characters are read. + const filename = `a\x01${'b'.repeat(2000)}.pdf`; + await expect( + normalizeEmail({ ...base, attachments: [{ filename, content: 'x' }] }), + ).rejects.toThrow( + new RegExp( + `attachment filename is ${filename.length} characters; a quoted \`filename=\` parameter cannot be folded`, + ), + ); + }); + + it('refuses one in the media type by the same rule', async () => { + // `Content-Type: ; name="…"` is rendered verbatim, and an + // RFC 2045 §5.1 `token` excludes CTLs. + await expect( + normalizeEmail({ + ...base, + attachments: [{ filename: 'a.pdf', content: 'x', contentType: 'application/pdf\x01' }], + }), + ).rejects.toThrow('attachment contentType contains a control character (U+0001).'); + }); + + it('refuses one in the Content-ID by the same rule', async () => { + // `Content-ID: ` is rendered verbatim, and an RFC 5322 §3.6.4 + // `msg-id` is `dot-atom-text` — no control at all. + await expect( + normalizeEmail({ + ...base, + attachments: [{ filename: 'a.png', content: 'x', contentId: 'lo\x7fgo' }], + }), + ).rejects.toThrow('attachment contentId contains a control character (U+007F).'); + }); + }); + + describe('media type syntax of an attachment', () => { + // `rejectControls` answered only for CTLs, and `buildAttachmentPart` + // writes the media type verbatim ahead of its own `; name="…"`, so + // `text/plain; x="` rendered `Content-Type: text/plain; x="; name="a.txt"` + // — captured as a success under `void dev` and by the harness, while + // Python's `email` reports `end of header inside quoted string` on that + // line and reads the builder's `name` as the VALUE of `x`. The grammar + // is RFC 2045 §5.1 `type "/" subtype *(";" parameter)`, read as a + // generator; the message names the rule and never echoes the value. + const base = { from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't' }; + const SYNTAX = + 'attachment contentType is not a valid media type (RFC 2045 §5.1: type/subtype, then ; attribute=value parameters).'; + + async function refused(contentType: string): Promise { + let error: unknown; + try { + await normalizeEmail({ + ...base, + attachments: [{ filename: 'a.txt', content: 'x', contentType }], + }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(MimeError); + expect((error as MimeError).code).toBe('MIME_ERROR'); + return error as MimeError; + } + + it('refuses one that does not parse, naming the rule rather than echoing it', async () => { + const invalid = [ + 'text/plain; x="', // unterminated quoted-string + 'text/plain; x="a\\"', // the `\` escapes the closing quote + 'text/plain; charset', // attribute without `=` value + 'text/plain; charset=', // empty token value (`""` is the empty value) + 'text/plain; x=a/b', // `/` is a tspecial; must be quoted + 'text', // subtype is MANDATORY (§5.1) + 'text/', // + '/plain', // + 'text/plain/x', // + 'text/ plain', // WSP inside the type + 'text plain', // + ' text/plain', // leading WSP + 'text/plain ', // trailing WSP + 'text/plain;', // `;` owes a parameter + 'text/plain;;', // + 'text/plain; ; a=b', // + 'text/plain; a=b,c', // `,` is a tspecial + 'text/plain (plain text)', // an RFC 822 comment: refused, not stripped + 'tëxt/plain', // token is US-ASCII + 'text/plain; ä=b', // + 'text/plain; a=ö', // + '', // + ]; + for (const contentType of invalid) { + const error = await refused(contentType); + expect(error.message, contentType).toBe(SYNTAX); + if (contentType.length > 0) { + expect(error.message).not.toContain(contentType); + } + } + }); + + it('accepts every valid form and renders it as it did, byte for byte', async () => { + const valid = [ + 'TEXT/PLAIN', // case is kept as given; matching is case-insensitive + 'application/vnd.ms-excel', + 'application/x-foo.bar-baz_1', // `.`, `-`, `_` are token characters + 'image/svg+xml', + 'text/plain; charset=utf-8', + 'text/plain; charset="utf-8"', + 'text/plain;charset=utf-8', // no WSP after `;` + 'text/plain ; charset=utf-8', // WSP before `;` (RFC 822 implicit LWSP) + 'text/plain;\tcharset=utf-8', // HTAB is WSP + 'text/plain; charset=""', // the empty quoted-string is a value + 'text/plain; title="a \\"b\\" \\\\ c; d=e"', // quoted-pairs, and `;` inside quotes + 'text/plain; title="résumé"', // what `name="résumé.pdf"` already carries + 'multipart/x-thing; a=1; B=2', // distinct attributes + ]; + for (const contentType of valid) { + const { text } = await build({ + ...base, + attachments: [{ filename: 'a.txt', content: 'x', contentType }], + }); + // Verbatim, then the builder's own parameter — exactly the line the + // unchecked path rendered. + expect(text, contentType).toContain(`Content-Type: ${contentType}; name="a.txt"\r\n`); + } + }); + + /** + * The attachment part's `Content-Type` header, every physical line of + * it: the lines between `Content-Type:` and the part's + * `Content-Transfer-Encoding: base64`, which only an attachment carries. + */ + async function attachmentContentTypeLines(contentType: string): Promise> { + const { text } = await build({ + ...base, + attachments: [{ filename: 'a.txt', content: 'x', contentType }], + }); + const lines = text.split('\r\n'); + const end = lines.indexOf('Content-Transfer-Encoding: base64'); + let start = end - 1; + while (start > 0 && !lines[start].startsWith('Content-Type:')) { + start--; + } + return lines.slice(start, end); + } + + it('pins the rendered line for three of them', async () => { + expect(await attachmentContentTypeLines('TEXT/PLAIN')).toEqual([ + 'Content-Type: TEXT/PLAIN; name="a.txt"', + ]); + expect(await attachmentContentTypeLines('text/plain; charset="utf-8"')).toEqual([ + 'Content-Type: text/plain; charset="utf-8"; name="a.txt"', + ]); + expect(await attachmentContentTypeLines('application/x-foo.bar-baz_1')).toEqual([ + 'Content-Type: application/x-foo.bar-baz_1; name="a.txt"', + ]); + }); + + it('keeps the length bound in front of the scan', async () => { + // "Bound before you scan": the `CONTENT_TYPE_LINE_OVERHEAD` guard + // answers for an over-long value before the grammar reads it — the + // stray `"` in here is never reached. ` ;` is the shortest line + // the media type can fold onto, so 996 characters render exactly at + // the 998-octet limit and 997 cannot. + const error = await refused(`a/b"${'c'.repeat(2000)}`); + expect(error.message).toBe( + 'attachment contentType is 2004 characters; at most 996 fit on the Content-Type line ' + + 'under the 998-octet limit in RFC 5322 §2.1.1. Shorten it.', + ); + expect((await refused(`a/${'b'.repeat(995)}`)).message).toMatch( + /^attachment contentType is 997 characters; at most 996 fit/, + ); + const lines = await attachmentContentTypeLines(`a/${'b'.repeat(994)}`); + expect(lines).toEqual(['Content-Type:', ` a/${'b'.repeat(994)};`, ' name="a.txt"']); + expect(lines[1].length).toBe(998); + }); + + it("refuses a caller's name= parameter, which the builder sets from the filename", async () => { + // `buildAttachmentPart` appends `name=""`, so a second one + // would be the same parameter twice — RFC 6838 §4.3: "It is an error + // for a specific parameter to be specified more than once." + for (const contentType of ['text/plain; name=other', 'text/plain; NAME="other"']) { + expect((await refused(contentType)).message).toBe( + 'attachment contentType must not carry a name= parameter; the builder adds name="" itself.', + ); + } + }); + + it('refuses a parameter given twice, whatever its case (RFC 6838 §4.3)', async () => { + // §5.1: "Matching of attributes is ALWAYS case-insensitive", and + // §4.3 of RFC 6838 makes a repeat an error; a parser that keeps the + // first and one that keeps the last would disagree on the message. + for (const contentType of [ + 'text/plain; a=b; a=c', + 'text/plain; charset=utf-8; CHARSET=ascii', + ]) { + expect((await refused(contentType)).message).toBe( + 'attachment contentType gives the same parameter twice; attribute names are case-insensitive and each may appear once (RFC 6838 §4.3).', + ); + } + }); + + it('reports a syntax error ahead of a semantic one on the same parameter', async () => { + // `name` without a value is a syntax error first. + expect((await refused('text/plain; name')).message).toBe(SYNTAX); + }); + }); + + describe('Content-ID syntax of an attachment', () => { + // `rejectControls` answered only for CTLs, and `buildAttachmentPart` + // writes the Content-ID verbatim, adding `<` `>` unless the value + // already starts with `<`: `logo other` rendered ``, + // `` rendered `>` — captured as + // a success under `void dev` and by the harness, and left for the + // receiver to refuse or to fail the html's `cid:` reference against. + // The grammar is RFC 5322 §3.6.4 `msg-id`, read as a generator, with + // the `@` side and the brackets optional (RFC 2392 §2 resolves `cid:x` + // against ``, and the builder has always bracketed a bare id); the + // message names the rule and never echoes the value. + const base = { from: 'a@x.dev', to: 'b@y.dev', subject: 's', html: '' }; + const SYNTAX = + 'attachment contentId is not a valid Content-ID (RFC 5322 §3.6.4 msg-id: an identifier of atext, "." and "@", optionally in one pair of angle brackets).'; + + async function refused(contentId: string): Promise { + let error: unknown; + try { + await normalizeEmail({ + ...base, + attachments: [{ filename: 'logo.png', content: 'x', contentId }], + }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(MimeError); + expect((error as MimeError).code).toBe('MIME_ERROR'); + return error as MimeError; + } + + /** The attachment part's rendered `Content-ID` line. */ + async function contentIdLine(contentId: string): Promise { + const { text } = await build({ + ...base, + attachments: [{ filename: 'logo.png', content: 'x', contentId }], + }); + return text.split('\r\n').find((line) => line.startsWith('Content-ID:')); + } + + it('refuses one that does not parse, naming the rule rather than echoing it', async () => { + const invalid = [ + 'logo other', // WSP: rendered `` + '', // rendered `>` + '>', // + '<', // + 'x', // text after the closing bracket + '<>', // brackets around nothing + '<', // + '>', // + ' logo', // leading WSP + 'logo ', // trailing WSP + 'lo\tgo', // HTAB is WSP too (`rejectControls` lets it through) + 'lo"go', // `"` is not atext + 'lo(go)', // nor `(` `)`: an RFC 822 comment is refused, not stripped + 'lo,go', // + 'lo;go', // + 'lo:go', // + 'lo\\go', // + 'a@[127.0.0.1]', // `no-fold-literal` is left out + 'a..b', // `dot-atom-text` is 1*atext *("." 1*atext): no doubled dot + '.a', // no leading dot + 'a.', // no trailing dot + 'a.@b', // nor on either side of the `@` + 'a@.b', // + 'a@b@c', // one `@` at most + '@b', // an `@` owes both sides + 'a@', // + 'lögo', // atext is US-ASCII + '', // used to be validated, then silently not rendered + ]; + for (const contentId of invalid) { + const error = await refused(contentId); + expect(error.message, JSON.stringify(contentId)).toBe(SYNTAX); + if (contentId.length > 0) { + expect(error.message).not.toContain(contentId); + } + } + }); + + it('accepts every valid form and renders it as it did, byte for byte', async () => { + // Bare or bracketed, the wire form is ``; a bracketed id renders + // as given, a bare one is wrapped — exactly what the unchecked path did. + const valid: Array<[contentId: string, rendered: string]> = [ + ['logo', ''], // the documented example: no `@` (RFC 2392 §2) + ['', ''], + ['logo@example.com', ''], // a proper RFC 5322 msg-id + ['', ''], + ['a.b', ''], + ['a.b.c@d.e', ''], + ["!#$%&'*+-/=?^_`{|}~", ""], // every atext symbol + ['20240101.1234@mail.void.cloud', '<20240101.1234@mail.void.cloud>'], + ['Logo-1_2', ''], // case is kept as given + ]; + for (const [contentId, rendered] of valid) { + expect(await contentIdLine(contentId), contentId).toBe(`Content-ID: ${rendered}`); + } + }); + + it('keeps the documented inline-image example working verbatim', async () => { + const { text } = await build({ + from: 'Acme ', + to: 'user@example.com', + subject: 'Hello', + html: 'Acme', + attachments: [ + { + filename: 'logo.png', + content: new Uint8Array([1, 2, 3]), + contentType: 'image/png', + contentId: 'logo', + disposition: 'inline', + }, + ], + }); + expect(text).toContain('Content-ID: \r\n'); + expect(text).toContain('Acme'); + }); + + it('still infers disposition from a bracketed id', async () => { + // `att.contentId ? 'inline' : 'attachment'`: any id the grammar + // admits is non-empty, so the default is `inline` for both forms. + const email = await normalizeEmail({ + ...base, + attachments: [ + { filename: 'a.png', content: 'x', contentId: 'a' }, + { filename: 'b.png', content: 'x', contentId: '' }, + ], + }); + expect(email.attachments.map((a) => a.disposition)).toEqual(['inline', 'inline']); + }); + + it('keeps the length bound in front of the scan', async () => { + // "Bound before you scan": the `CONTENT_ID_LINE_OVERHEAD` guard + // answers for an over-long value before the grammar reads it — the + // space in here is never reached. `Content-ID: ` is one token, + // so it never folds: a bare id of 984 characters renders exactly at + // the 998-octet limit and 985 cannot; an id that brings its own + // brackets is allowed those two characters more. + const error = await refused(`x ${'x'.repeat(1998)}`); + expect(error.message).toBe( + 'attachment contentId is 2000 characters; at most 984 fit on the Content-ID line ' + + 'under the 998-octet limit in RFC 5322 §2.1.1. Shorten it.', + ); + expect((await refused('x'.repeat(985))).message).toMatch( + /^attachment contentId is 985 characters; at most 984 fit/, + ); + const bare = await contentIdLine('x'.repeat(984)); + expect(bare).toBe(`Content-ID: <${'x'.repeat(984)}>`); + expect(bare!.length).toBe(998); + expect((await refused(`<${'x'.repeat(985)}>`)).message).toMatch( + /^attachment contentId is 987 characters; at most 986 fit/, + ); + const bracketed = await contentIdLine(`<${'x'.repeat(984)}>`); + expect(bracketed).toBe(`Content-ID: <${'x'.repeat(984)}>`); + expect(bracketed!.length).toBe(998); + }); + }); + + describe('a colon in an address', () => { + // `:` is an RFC 5322 §3.2.3 special, not `atext`, so a bare + // `a:b@example.com` is no addr-spec without quoting — which is refused + // too. It was the one special missing from the hand-written address + // class: the address passed here, a handler's `forward()` recorded it, + // and the platform router's `isValidEmailAddress` dropped that one + // forward after earlier actions had fired. `email-validator` admits only + // `atext`, so it is refused like `,` and `;`. + const colons = ['a:b@example.com', 'a@b:c.example.com', ':a@example.com', 'a@example.com:']; + + it('refuses a bare address carrying one, with the code for its field', async () => { + for (const bad of colons) { + for (const [field, code] of [ + ['to', 'INVALID_TO'], + ['from', 'INVALID_FROM'], + ['cc', 'MIME_ERROR'], + ['bcc', 'MIME_ERROR'], + ['replyTo', 'MIME_ERROR'], + ] as const) { + let error: unknown; + try { + await normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + [field]: bad, + subject: 's', + text: 't', + }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(MimeError); + expect((error as MimeError).code).toBe(code); + expect((error as MimeError).message).toBe(invalidAddress(field)); + } + } + }); + + it('refuses it in the object and display-name forms on the same parsed address', async () => { + for (const bad of colons) { + await expect( + normalizeEmail({ + from: 'a@x.dev', + to: { name: 'Bob', email: bad }, + subject: 's', + text: 't', + }), + ).rejects.toThrow(invalidAddress('to')); + expect(() => parseAddress(`Bob <${bad}>`, 'to')).toThrow(invalidAddress('to')); + } + }); + + it('still accepts one in a display name, where it is quoted', async () => { + // A name is a phrase, and a phrase with a special is rendered as one + // quoted-string — the address rule is not the name rule. + const { text } = await build({ + from: 'a@x.dev', + to: { name: 'Ops: Tier 2', email: 'b@y.dev' }, + subject: 's', + text: 't', + }); + expect(unfoldHeader(text, 'To')).toBe('"Ops: Tier 2" '); + }); + }); + + describe('dot-atom shape of an address', () => { + // An RFC 5322 §3.2.3 dot-atom cannot start or end with a dot or carry + // two in a row, on either side of the `@`. The hand-written rule and + // the platform router's regex BOTH accepted every one of these — no + // divergence, just a shared hole that Cloudflare or the next MTA would + // refuse after the handler's test had reported a success. One package + // on both sides closes it. + const dots = [ + 'a..b@example.com', + '.a@example.com', + 'a.@example.com', + 'a@example..com', + 'a@.example.com', + 'a@example.com.', + ]; + + it('refuses leading, trailing and doubled dots, with the code for its field', async () => { + for (const bad of dots) { + for (const [field, code] of [ + ['to', 'INVALID_TO'], + ['from', 'INVALID_FROM'], + ['cc', 'MIME_ERROR'], + ['bcc', 'MIME_ERROR'], + ['replyTo', 'MIME_ERROR'], + ] as const) { + let error: unknown; + try { + await normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + [field]: bad, + subject: 's', + text: 't', + }); + } catch (e) { + error = e; + } + expect(error, bad).toBeInstanceOf(MimeError); + expect((error as MimeError).code).toBe(code); + expect((error as MimeError).message).toBe(invalidAddress(field)); + } + expect(() => parseAddress(`Bob <${bad}>`, 'to')).toThrow(invalidAddress('to')); + } + }); + + it('still accepts dots between atoms, on both sides', async () => { + for (const ok of [ + 'first.last@example.com', + 'a.b.c@sub.example.co.uk', + 'a@sub.sub.example.com', + ]) { + const email = await normalizeEmail({ from: ok, to: ok, subject: 's', text: 't' }); + expect(email.to[0].email).toBe(ok); + } + }); + }); + + describe('domain and local-part characters of an address', () => { + // The package's domain rule: ASCII labels, and a TLD that starts with a + // letter and is at least two characters. A quoted local part and a + // display name in the bare string stay refused, as they always were. + it('applies the TLD rule', async () => { + for (const bad of ['a@b.c', 'a@b.1c', 'a@b.c-', 'a@b.-c']) { + await expect( + normalizeEmail({ from: 'a@x.dev', to: bad, subject: 's', text: 't' }), + ).rejects.toThrow(invalidAddress('to')); + } + for (const ok of [ + 'a@b.c1', + 'a@b.co', + 'a@b.c-d', + 'a@xn--bcher-kva.example', + 'a@b-c.example', + ]) { + const email = await normalizeEmail({ from: 'a@x.dev', to: ok, subject: 's', text: 't' }); + expect(email.to[0].email).toBe(ok); + } + }); + + it('refuses a raw IDN domain and a quoted local part', async () => { + for (const bad of [ + 'a@exämple.com', + '"a b"@example.com', + '"a@b"@example.com', + '"weird"@x.dev', + ]) { + await expect( + normalizeEmail({ from: 'a@x.dev', to: bad, subject: 's', text: 't' }), + ).rejects.toThrow(invalidAddress('to')); + } + }); + + it('pins the package quirk: a backtick may not be the FIRST local-part character', async () => { + // The first character class of `email-validator`'s regex lacks the + // backtick that every later class has. Pinned, not worked around: the + // point of one package on both sides is that the rule is whatever the + // package says, and a wrapper that patched this would be the start of + // the next hand-written rule. + await expect( + normalizeEmail({ from: 'a@x.dev', to: '`a@x.com', subject: 's', text: 't' }), + ).rejects.toThrow(invalidAddress('to')); + const email = await normalizeEmail({ + from: 'a@x.dev', + to: 'a`b@x.com', + subject: 's', + text: 't', + }); + expect(email.to[0].email).toBe('a`b@x.com'); + }); + + it('accepts every RFC 5322 atext punctuation after the first character', async () => { + const atext = "!#$%&'*+-/=?^_`{|}~"; + const ok = `a${atext}@example.com`; + const email = await normalizeEmail({ from: 'a@x.dev', to: ok, subject: 's', text: 't' }); + expect(email.to[0].email).toBe(ok); + }); + }); + + it('parses the display-name form in linear time', () => { + // `^\s*([^"<]+?)?\s*<` let three quantifiers share one run of + // whitespace; the backtracker tried every split, cubic in the run. + // Measured on that form: 2,000 spaces took 2 s, 4,000 took 10 s (16 s + // when the `>` is missing). A regex cannot be interrupted, so keep the + // run where a regression costs seconds, not the test's whole timeout. + // (Two-letter TLDs: the address rule wants at least two.) + const run = ' '.repeat(4000); + const started = performance.now(); + expect(parseAddress(`${run}a@b.cd`, 'to')).toEqual({ email: 'a@b.cd' }); + expect(() => parseAddress(`${run}${run}`, 'to')).toEqual({ + email: 'a@b.cd', + name: 'Name', + }); + expect(performance.now() - started).toBeLessThan(1000); + }); + + it('keeps the display-name form’s language after the regex change', () => { + // Shapes the old and new patterns were checked to agree on + // (exhaustively to length 7 over a 9-symbol alphabet); pinned here. + expect(parseAddress('a>b ', 'to')).toEqual({ email: 'c@d.ef', name: 'a>b' }); + expect(parseAddress('x', 'to')).toEqual({ email: 'a@b.cd', name: 'x' }); + expect(parseAddress('\tJohn\tSmith\t', 'to')).toEqual({ + email: 'j@s.ef', + name: 'John\tSmith', + }); + expect(parseAddress(' ', 'to')).toEqual({ email: 'a@b.cd' }); + expect(() => parseAddress('a"b ', 'to')).toThrow(MimeError); + expect(() => parseAddress('a e', 'to')).toThrow(MimeError); + expect(() => parseAddress('
', 'to')).toThrow(MimeError); + }); + + it('undoes quoted-pairs in a quoted display name, and only there', () => { + // RFC 5322 §3.2.4: inside a quoted-string `"` and `\` appear only as + // `\"` and `\\`, and `\x` is `x` for any other `x`. The parsed name is + // what `formatAddress` quotes and escapes again, so it has to be the + // raw text — what the object form carries — or the escapes render + // twice and the recipient sees the backslashes. + expect(parseAddress('"Smith \\"Bob\\"" ', 'to')).toEqual({ + email: 'a@x.dev', + name: 'Smith "Bob"', + }); + expect(parseAddress('"a\\\\b" ', 'to')).toEqual({ email: 'a@x.dev', name: 'a\\b' }); + expect(parseAddress('"ab\\\\" ', 'to')).toEqual({ email: 'a@x.dev', name: 'ab\\' }); + expect(parseAddress('"a\\xb" ', 'to')).toEqual({ email: 'a@x.dev', name: 'axb' }); + // UTF-16 code units, surrogate pairs included. + expect(parseAddress('"\\"😀 \\\\ ï" ', 'to')).toEqual({ + email: 'a@x.dev', + name: '"😀 \\ ï', + }); + // A quoted name with no backslash is untouched, and a backslash in an + // UNQUOTED name is a character like any other — as in the object form. + expect(parseAddress('"Smith, John" ', 'to')).toEqual({ + email: 'a@x.dev', + name: 'Smith, John', + }); + expect(parseAddress('a\\b ', 'to')).toEqual({ email: 'c@d.ef', name: 'a\\b' }); + // `\@` is still an `@` in a display name, which is still refused. + expect(() => parseAddress('"a\\@x" ', 'to')).toThrow(/must not contain '@'/); + // The quoted-string line guard sees the DECODED name, the one + // `formatAddress` renders: 500 pairs are 1,000 raw characters (over + // the guard) but a 500-character name (under it), and the count the + // error reports is the decoded one. + expect(parseAddress(`"${'\\"'.repeat(500)}" `, 'to').name).toBe('"'.repeat(500)); + expect(() => parseAddress(`"${'\\"'.repeat(996)}" `, 'to')).toThrow( + /display name is 996 characters and must be quoted/, + ); + }); + + it('undoes quoted-pairs in linear time and without a per-pair allocation', () => { + // A `replace(/\\(.)/g, '$1')` holds about 60 bytes of heap per match + // before it builds its result — measured 307 MB for 10 MiB of `\"` + // pairs, past a 128 MB isolate — and nothing bounds the name before + // the decode runs: an ASCII name is bounded only on its DECODED form, + // and a non-ASCII one never is. The buffer routine peaked at 49 MB on + // the same input. Heap is not something a test can pin; time is, and + // both shapes are exercised so the output of each path is built. + const started = performance.now(); + expect(() => parseAddress(`"${'\\"'.repeat(1_000_000)}" `, 'to')).toThrow( + /cannot be folded/, + ); + const nonAscii = parseAddress(`"${'\\"é'.repeat(500_000)}" `, 'to'); + expect(nonAscii.name).toHaveLength(1_000_000); + expect(nonAscii.name!.startsWith('"é"é')).toBe(true); + expect(performance.now() - started).toBeLessThan(1000); + }); +}); + +describe('buildMime', () => { + it('builds a plain text/plain message', async () => { + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 'Hello', + text: 'Hi there', + }); + expect(text).toContain('From: a@x.dev'); + expect(text).toContain('To: b@y.dev'); + expect(text).toContain('Subject: Hello'); + expect(text).toContain('Content-Type: text/plain; charset=UTF-8'); + expect(text).toContain('Hi there'); + expect(text).toMatch(/Message-ID: <[^@]+@x\.dev>/); + }); + + it('emits multipart/alternative when both text and html are set', async () => { + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 'Hi', + text: 'plain', + html: '

html

', + }); + expect(text).toContain('Content-Type: multipart/alternative;'); + expect(text).toContain('Content-Type: text/plain; charset=UTF-8'); + expect(text).toContain('Content-Type: text/html; charset=UTF-8'); + expect(text).toContain('plain'); + expect(text).toContain('

html

'); + }); + + it('encodes non-ASCII display names as RFC 2047 encoded-words', async () => { + const { text } = await build({ + from: { email: 'j@x.dev', name: 'Jürgen Müller' }, + to: 'b@y.dev', + subject: '日本語', + text: 'hi', + }); + expect(text).toMatch(/From: =\?UTF-8\?B\?[^?]+\?= /); + expect(text).toMatch(/Subject: =\?UTF-8\?B\?[^?]+\?=/); + }); + + it('quoted-prints non-ASCII bodies', async () => { + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 'café', + }); + expect(text).toContain('Content-Transfer-Encoding: quoted-printable'); + // 'é' = 0xC3 0xA9 in UTF-8 → =C3=A9 + expect(text).toContain('caf=C3=A9'); + }); + + // The encoding decision, pinned: only LF and CRLF end a line, a bare CR + // (or any other control character) forces quoted-printable, and a line of + // 990 printable characters is the longest that ships as 7bit. Captured + // from the regex-and-split implementation the one-pass scanner replaced. + it.each<[string, string, string, string]>([ + ['a bare CR', 'a\rb', 'quoted-printable', 'a\r\nb'], + ['a tab', 'a\tb', 'quoted-printable', 'a\tb'], + ['DEL', 'a\x7fb', 'quoted-printable', 'a=7Fb'], + ['a 990-character line', 'a'.repeat(990), '7bit', 'a'.repeat(990)], + [ + 'a 991-character line', + 'a'.repeat(991), + 'quoted-printable', + `${'a'.repeat(75)}=\r\n`.repeat(13) + 'a'.repeat(16), + ], + [ + 'two 990-character lines split by a bare LF', + `${'a'.repeat(990)}\n${'b'.repeat(990)}`, + '7bit', + `${'a'.repeat(990)}\r\n${'b'.repeat(990)}`, + ], + ['a mix of LF and CRLF', 'l1\nl2\r\nl3\n', '7bit', 'l1\r\nl2\r\nl3\r\n'], + ])('encodes %s as %s', async (_what, input, encoding, expected) => { + const { text } = await build({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: input }); + expect(text).toContain(`Content-Transfer-Encoding: ${encoding}`); + expect(bodyOf(text)).toBe(expected); + }); + + it('attaches files as base64 in multipart/mixed', async () => { + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 'hi', + attachments: [ + { + filename: 'hello.txt', + content: 'hello world', + contentType: 'text/plain', + }, + ], + }); + expect(text).toContain('Content-Type: multipart/mixed;'); + expect(text).toContain('Content-Disposition: attachment; filename="hello.txt"'); + expect(text).toContain('Content-Transfer-Encoding: base64'); + expect(text).toContain(btoa('hello world')); + }); + + it('renders cc, replyTo, and custom headers', async () => { + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + cc: 'c@y.dev', + bcc: 'd@y.dev', + replyTo: 'r@x.dev', + headers: { 'X-Campaign': 'welcome' }, + subject: 's', + text: 'hi', + }); + expect(text).toContain('Cc: c@y.dev'); + expect(text).toContain('Reply-To: r@x.dev'); + expect(text).toContain('X-Campaign: welcome'); + // Bcc must NOT appear in the rendered MIME — every recipient receives the + // same raw body, so a Bcc header would expose the BCC list. + expect(text).not.toContain('Bcc:'); + expect(text).not.toContain('d@y.dev'); + }); + + it('drops user-supplied reserved headers', async () => { + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 'hi', + headers: { 'Content-Type': 'text/evil', From: 'spoof@x.dev' }, + }); + expect(text).not.toContain('text/evil'); + expect(text).not.toContain('spoof@x.dev'); + }); + + it('uses multipart/related for inline attachments alongside html', async () => { + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + html: '', + attachments: [ + { + filename: 'logo.png', + content: new Uint8Array([1, 2, 3]), + contentType: 'image/png', + contentId: 'logo', + disposition: 'inline', + }, + ], + }); + expect(text).toContain('Content-Type: multipart/related;'); + expect(text).toContain('Content-Disposition: inline; filename="logo.png"'); + expect(text).toContain('Content-ID: '); + }); + + it('rejects oversized messages', async () => { + const big = 'x'.repeat(11 * 1024 * 1024); + const email = await normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: big, + }); + expect(() => buildMime(email)).toThrow(/exceeds Cloudflare limit/); + }); + + it('quotes display names containing RFC 5322 specials (comma is the worst)', async () => { + // Comma in a name MUST be quoted — without quotes it splits the + // address-list field. Pre-fix, the formatAddress fallback emitted + // unquoted ASCII names, letting `Smith, John` corrupt the To header. + const { text } = await build({ + from: { email: 'a@x.dev', name: 'Smith, John' }, + to: { email: 'b@y.dev', name: 'Doe; Jane' }, + subject: 's', + text: 'hi', + }); + expect(text).toContain('From: "Smith, John" '); + expect(text).toContain('To: "Doe; Jane" '); + }); + + it('quotes display names containing parens, angles, colons', async () => { + const cases: Array<[string, string]> = [ + ['Acme (US)', '"Acme (US)"'], + ['', '""'], + ['phone: x', '"phone: x"'], + ]; + for (const [name, expected] of cases) { + const { text } = await build({ + from: { email: 'a@x.dev', name }, + to: 'b@y.dev', + subject: 's', + text: 'hi', + }); + expect(text).toContain(`From: ${expected} `); + } + }); + + it('escapes embedded quotes and backslashes in quoted display names', async () => { + const { text } = await build({ + from: { email: 'a@x.dev', name: 'Smith, "Bob" \\Bobby' }, + to: 'b@y.dev', + subject: 's', + text: 'hi', + }); + // Backslash is escaped first (`\` → `\\`), then quotes are escaped + // (`"` → `\"`). Order matters; reverse order would double-escape the + // backslash from the quote-escape into `\\\\\"`. + expect(text).toContain('From: "Smith, \\"Bob\\" \\\\Bobby" '); + }); + + it('renders a quoted display name with its quoted-pairs escaped exactly once', async () => { + // `"Smith \"Bob\"" ` used to keep the raw `\"` pairs in the + // parsed name, and `formatAddress` escaped them again: the header read + // `"Smith \\\"Bob\\\""` and the recipient saw `Smith \"Bob\"`. The + // object form of the same name was right, so the two forms disagreed. + const cases: Array<[string, string, string]> = [ + ['"Smith \\"Bob\\"" ', 'Smith "Bob"', '"Smith \\"Bob\\""'], + ['"a\\\\b" ', 'a\\b', '"a\\\\b"'], + ['"ab\\\\" ', 'ab\\', '"ab\\\\"'], + ['"Smith, John" ', 'Smith, John', '"Smith, John"'], + ['"a\\xb" ', 'axb', 'axb'], + ]; + for (const [from, name, rendered] of cases) { + const viaString = await build({ from, to: 'b@y.dev', subject: 's', text: 't' }); + const viaObject = await build({ + from: { email: 'a@x.dev', name }, + to: 'b@y.dev', + subject: 's', + text: 't', + }); + expect(unfoldHeader(viaString.text, 'From')).toBe(`${rendered} `); + expect(unfoldHeader(viaObject.text, 'From')).toBe(`${rendered} `); + // Read the quoted-string back the way a receiver does. + const m = unfoldHeader(viaString.text, 'From').match(/^"((?:[^"\\]|\\.)*)" ', + to: 'b@y.dev', + subject: 's', + text: 't', + }); + expect(decodeEncodedWords(unfoldHeader(text, 'From'))).toBe('"😀\\ ï '); + }); + + describe('a period in a display name', () => { + // `.` is one of RFC 5322 §3.2.3's `specials`, so it is not `atext` and a + // name containing one is not a `phrase` of atoms: `Dr. Smith ` + // parses only as `obs-phrase` (§4.1), which §4 says MUST NOT be + // generated. `RFC5322_SPECIALS` used to omit it, so every dotted name + // went out unquoted; a strict receiver (Python's `email` parser flags + // "period in 'phrase'", and `.` alone as an invalid phrase) sees + // obsolete syntax on an otherwise valid message. + const dotted: Array<[string, string]> = [ + ['Dr. Smith', '"Dr. Smith"'], + ['J. R. R. Tolkien', '"J. R. R. Tolkien"'], + ['Smith.', '"Smith."'], + ['.', '"."'], + ['Acme Inc.', '"Acme Inc."'], + ]; + + it('renders the name as one quoted-string, in From and in an address list', async () => { + for (const [name, rendered] of dotted) { + const { text } = await build({ + from: { email: 'a@x.dev', name }, + to: [{ email: 'b@y.dev', name }, 'c@z.dev'], + subject: 's', + text: 'hi', + }); + expect(unfoldHeader(text, 'From')).toBe(`${rendered} `); + expect(unfoldHeader(text, 'To')).toBe(`${rendered} , c@z.dev`); + } + }); + + it('renders the string form the same way, whether or not the caller quoted it', async () => { + for (const [name, rendered] of dotted) { + const bare = await build({ + from: `${name} `, + to: 'b@y.dev', + subject: 's', + text: 't', + }); + const quoted = await build({ + from: `${rendered} `, + to: 'b@y.dev', + subject: 's', + text: 't', + }); + expect(unfoldHeader(bare.text, 'From')).toBe(`${rendered} `); + expect(unfoldHeader(quoted.text, 'From')).toBe(`${rendered} `); + } + }); + + it('round-trips through postal-mime as the original name', async () => { + const { default: PostalMime } = await import('postal-mime'); + for (const [name] of dotted) { + const { raw } = await build({ + from: { email: 'a@x.dev', name }, + to: { email: 'b@y.dev', name }, + subject: 's', + text: 'hi', + }); + const parsed = await PostalMime.parse(raw); + expect(parsed.from).toEqual({ address: 'a@x.dev', name }); + expect(parsed.to).toEqual([{ address: 'b@y.dev', name }]); + } + }); + + it('still escapes a quote or backslash beside the period', async () => { + // The period only selects the quoted-string path; inside it `"` and + // `\` are quoted-pairs as before, and the period itself is `qtext`. + const { default: PostalMime } = await import('postal-mime'); + const name = 'Dr. "Doc" Smith \\ Jr.'; + const { raw, text } = await build({ + from: { email: 'a@x.dev', name }, + to: 'b@y.dev', + subject: 's', + text: 'hi', + }); + expect(unfoldHeader(text, 'From')).toBe('"Dr. \\"Doc\\" Smith \\\\ Jr." '); + expect((await PostalMime.parse(raw)).from).toEqual({ address: 'a@x.dev', name }); + }); + + it('leaves a non-ASCII dotted name to the encoded-word path', async () => { + // `ASCII_PRINTABLE` is tested first: an encoded-word carries the + // period as base64, so quoting it as well would put the quotes inside + // the encoded text, not around it. + const { text } = await build({ + from: { email: 'a@x.dev', name: 'Dr. Müller' }, + to: 'b@y.dev', + subject: 's', + text: 'hi', + }); + const from = unfoldHeader(text, 'From'); + expect(from).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?= $/); + expect(decodeEncodedWords(from)).toBe('Dr. Müller '); + }); + + it('is now bounded by the quoted-string line guard, at the same edge as any other special', async () => { + // A quoted-string is one token `foldHeader` never splits, so a + // dotted name is now subject to the ` "name"` line bound in + // `validateAddressParts`: 995 characters fit a 998-octet line exactly + // (the period is `qtext`, so nothing is escaped and the bound is + // exact), 996 do not. Before the fix the same 996-character name + // rendered unquoted and folded at its spaces. + const fits = `Dr. ${'a'.repeat(998 - 3 - 4)}`; + expect(fits.length).toBe(995); + const { text } = await build({ + from: 'a@x.dev', + to: { email: 'b@y.dev', name: fits }, + subject: 's', + text: 't', + }); + const lines = headerLines(text, 'To'); + expect(lines).toEqual(['To:', ` "${fits}"`, ' ']); + expect(Math.max(...lines.map((l) => l.length))).toBe(998); + await expect( + build({ + from: 'a@x.dev', + to: { email: 'b@y.dev', name: `${fits}b` }, + subject: 's', + text: 't', + }), + ).rejects.toThrow( + /to: display name is 996 characters and must be quoted, which cannot be folded/, + ); + }); + }); + + it('rejects bare addr-spec values containing a comma (header-injection guard)', async () => { + // Without this guard, `to: "victim@a.com,attacker@b.com"` slips + // through the unquoted addr-spec path and renders as two recipients. + await expect( + normalizeEmail({ + from: 'a@x.dev', + to: 'victim@example.com,attacker@evil.dev', + subject: 's', + text: 't', + }), + ).rejects.toThrow(/to/); + }); + + it('rejects bare addr-spec containing semicolon, angle, or quotes', async () => { + for (const bad of ['a@x.dev;b@evil.dev', 'a@x.dev', '"weird"@x.dev']) { + await expect( + normalizeEmail({ from: bad, to: 'b@y.dev', subject: 's', text: 't' }), + ).rejects.toThrow(/from/); + } + }); + + it('rejects unbalanced quotes around display name', async () => { + // Pre-fix the parser had two independent optional quotes, so a + // single stray `"` slipped through. + await expect( + normalizeEmail({ + from: '"Acme ', + to: 'b@y.dev', + subject: 's', + text: 't', + }), + ).rejects.toThrow(/from/); + }); + + it('drops user-supplied In-Reply-To and References (reserved for replyEmail)', async () => { + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + headers: { + 'In-Reply-To': '', + References: '', + }, + }); + expect(text).not.toContain('spoof-thread'); + expect(text).not.toContain('spoof-references'); + }); + + it('folds long whitespace-separated headers (e.g. References) below 78 columns', async () => { + // 30 message IDs without commas — historical bug: foldHeader only + // split at address-list separators, so the entire References line + // emitted as one ~1KB string and breached RFC 5322's 998-octet hard + // limit. With whitespace-folding, every physical line stays well + // below 78 chars and CRLF + WSP starts each continuation. + const ids: Array = []; + for (let i = 0; i < 30; i++) { + ids.push(``); + } + const refs = ids.join(' '); + const { raw } = buildMime( + await normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + threading: { references: refs, inReplyTo: ids[ids.length - 1] }, + }), + ); + const text = new TextDecoder().decode(raw); + const lines = text.split('\r\n'); + // Find the References line + any continuations (lines starting with + // a single SP — RFC 5322 folding marker). + const refIdx = lines.findIndex((l) => l.startsWith('References: ')); + expect(refIdx).toBeGreaterThanOrEqual(0); + const collected: Array = [lines[refIdx]]; + for (let i = refIdx + 1; i < lines.length; i++) { + if (lines[i].startsWith(' ') || lines[i].startsWith('\t')) { + collected.push(lines[i]); + } else { + break; + } + } + // Every physical line stays under RFC 5322's 998-octet limit and + // also under our soft 78-octet target (we cap at ~76). + for (const l of collected) { + expect(l.length).toBeLessThanOrEqual(78); + } + // All 30 message IDs survive intact across the fold. + const merged = collected.map((l, i) => (i === 0 ? l : l.trimStart())).join(' '); + for (const id of ids) { + expect(merged).toContain(id); + } + expect(collected.length).toBeGreaterThan(1); // actually folded + }); + + it('header-folds long address lists at unquoted comma boundaries only', async () => { + // Construct a To list with quoted names containing commas, so that a + // naive split-on-`, ` fold would corrupt the rendered headers. Verify + // that all four addresses still render intact. + const { text } = await build({ + from: 'a@x.dev', + to: [ + { email: 'one@y.dev', name: 'Smith, John' }, + { email: 'two@y.dev', name: 'Doe, Jane' }, + { email: 'three@y.dev', name: 'Bob' }, + { email: 'four@y.dev', name: 'Roe, Mary' }, + ], + subject: 's', + text: 't', + }); + expect(text).toContain('"Smith, John" '); + expect(text).toContain('"Doe, Jane" '); + expect(text).toContain('Bob '); + expect(text).toContain('"Roe, Mary" '); + }); + + it('caps every RFC 2047 encoded-word at 75 characters', async () => { + // 16 CJK characters are 48 UTF-8 bytes. Encoded as one unbounded + // word that is `=?UTF-8?B?` (10) + base64(48) (64) + `?=` (2) = 76 + // characters — one over RFC 2047 §2's 75-character ceiling. The + // ceiling leaves 63 base64 characters, so 45 source bytes is the + // largest payload that fits. + const subject = '日本語のメールを送信します。テス'; + expect([...subject]).toHaveLength(16); + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject, + text: 't', + }); + const value = unfoldHeader(text, 'Subject'); + const words = encodedWords(value); + expect(words.length).toBeGreaterThan(0); + for (const word of words) { + expect(word.length).toBeLessThanOrEqual(75); + } + expect(decodeEncodedWords(value)).toBe(subject); + }); + + it('folds a long non-ASCII subject under RFC 5322 §2.1.1 (998 octets)', async () => { + // 246 CJK characters used to render as a single 1005-octet physical + // line: past RFC 5322 §2.1.1's 998-octet MUST and RFC 5321 + // §4.5.3.1.6's 1000-octet SMTP text-line limit. Nothing downstream + // repairs it — the proxy forwards these bytes verbatim. + const subject = '日本語'.repeat(82); + expect([...subject]).toHaveLength(246); + const { raw, text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject, + text: 't', + }); + // Measure octets, not UTF-16 units — the limit is on the wire form. + const encoder = new TextEncoder(); + for (const line of text.split('\r\n')) { + expect(encoder.encode(line).byteLength).toBeLessThanOrEqual(998); + } + const lines = headerLines(text, 'Subject'); + expect(lines.length).toBeGreaterThan(1); // actually folded + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(78); + } + const value = unfoldHeader(text, 'Subject'); + for (const word of encodedWords(value)) { + expect(word.length).toBeLessThanOrEqual(75); + } + expect(decodeEncodedWords(value)).toBe(subject); + expect(raw.byteLength).toBeGreaterThan(0); + }); + + it('folds a very long non-ASCII subject (500 CJK characters)', async () => { + const subject = '確認'.repeat(250); + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject, + text: 't', + }); + const encoder = new TextEncoder(); + for (const line of text.split('\r\n')) { + expect(encoder.encode(line).byteLength).toBeLessThanOrEqual(998); + } + expect(decodeEncodedWords(unfoldHeader(text, 'Subject'))).toBe(subject); + }); + + it('folds long non-ASCII display names in From and in address lists', async () => { + // Display names run through the same encoder, so `From:` and every + // recipient list inherits the same unbounded-line bug. The + // multi-recipient case additionally exercises the address-list fold + // branch, which never re-folded inside a single part. + const name = '株式会社ボイドゼロの営業部'.repeat(20); + const { text } = await build({ + from: { email: 'j@x.dev', name }, + to: [ + // A quoted display name whose own comma must survive the fold. + { email: 'one@y.dev', name: 'Smith, John' }, + { email: 'two@y.dev', name }, + { email: 'three@y.dev', name: 'Bob' }, + ], + subject: 's', + text: 't', + }); + const encoder = new TextEncoder(); + for (const line of text.split('\r\n')) { + expect(encoder.encode(line).byteLength).toBeLessThanOrEqual(998); + } + for (const header of ['From', 'To']) { + const value = unfoldHeader(text, header); + for (const word of encodedWords(value)) { + expect(word.length).toBeLessThanOrEqual(75); + } + } + expect(decodeEncodedWords(unfoldHeader(text, 'From'))).toBe(`${name} `); + expect(decodeEncodedWords(unfoldHeader(text, 'To'))).toBe( + `"Smith, John" , ${name} , Bob `, + ); + }); + + it('never splits an astral character across encoded-words', async () => { + // RFC 2047 §5: "A multi-octet character may not be split across + // adjacent encoded-words." A chunker that counted UTF-16 units — or + // that base64'd once and sliced the output — would cut a 4-byte + // emoji in half, and each half would decode to U+FFFD. Sweep the + // leading padding so the astral run crosses the chunk boundary at + // every possible byte offset. + for (let pad = 0; pad < 8; pad++) { + const subject = `${'x'.repeat(pad)}é🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉𠮷𠮷ok`; + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject, + text: 't', + }); + const value = unfoldHeader(text, 'Subject'); + const decoded = decodeEncodedWords(value); + expect(decoded).not.toContain('�'); + expect(decoded).toBe(subject); + } + }); + + it('keeps folding long ASCII header values at whitespace (control)', async () => { + // Pure-ASCII values never reach the encoder, so this pins the + // pre-existing whitespace-folding behaviour against regressions from + // the encoded-word change. + const words: Array = []; + for (let i = 0; i < 40; i++) { + words.push(`word${i.toString().padStart(3, '0')}`); + } + const subject = words.join(' '); + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject, + text: 't', + }); + const lines = headerLines(text, 'Subject'); + expect(lines.length).toBeGreaterThan(1); + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(78); + } + expect(encodedWords(unfoldHeader(text, 'Subject'))).toHaveLength(0); + expect(unfoldHeader(text, 'Subject')).toBe(subject); + }); +}); + +function bodyOf(text: string): string { + return text.split('\r\n\r\n').slice(1).join('\r\n\r\n'); +} + +describe('buildMime body size projection', () => { + // A 4 MiB non-ASCII body is 12.6 MiB once quoted-printable encoded, so it + // can never fit. Before the projection, `buildMime` found that out only + // after encoding it — measured at ~11x the UTF-8 bytes live — and a body a + // few times over the limit terminated a 128 MB isolate instead of + // returning `MIME_ERROR`. The projection is a single allocation-free pass + // over the string, so this test is fast and the rejected string is small. + it('rejects a body that cannot fit before encoding it', async () => { + const cjk = '漢'.repeat(Math.ceil((4 * 1024 * 1024) / 3)); + const email = await normalizeEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: cjk }); + let error: unknown; + try { + buildMime(email); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(MimeError); + expect((error as MimeError).code).toBe('MIME_ERROR'); + // The projection's message, not the post-render one: the body was never encoded. + expect((error as MimeError).message).toMatch( + new RegExp( + `at least \\d+ bytes once encoded, which exceeds Cloudflare limit of ${MAX_MESSAGE_BYTES} bytes`, + ), + ); + }); + + it('counts text and html together', async () => { + // Each alone projects under the limit; together they cannot fit. + const half = '漢'.repeat(Math.ceil((1.8 * 1024 * 1024) / 3)); + await expect( + build({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: half }), + ).resolves.toBeTruthy(); + await expect( + build({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: half, html: half }), + ).rejects.toThrow(/at least \d+ bytes once encoded/); + }); + + it('never rejects a message that fits, and leaves the final check as the authority', async () => { + // 3.3 MiB of CJK projects to ~10.38 MiB (under the cap, since the + // projection ignores soft breaks and headers) but renders to ~10.8 MiB. + // The projection must let it through; the post-render check must reject + // it. One full ~10 MB render, so it carries its own timeout (0.4 s here, + // several times that on a loaded CI runner). + const nearly = '漢'.repeat(Math.ceil((3.3 * 1024 * 1024) / 3)); + const email = await normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: nearly, + }); + expect(() => buildMime(email)).toThrow(/message size \d+ bytes exceeds Cloudflare limit/); + // And a body the projection accepts builds; the size that fits is not + // at issue, so keep this one small. + const fits = '漢'.repeat(20_000); + const { raw } = await build({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: fits }); + expect(raw.byteLength).toBeLessThanOrEqual(MAX_MESSAGE_BYTES); + }, 30_000); +}); + +describe('buildMime header size projection', () => { + const PROJECTION = new RegExp( + `message is at least \\d+ bytes once encoded, which exceeds Cloudflare limit of ${MAX_MESSAGE_BYTES} bytes`, + ); + + async function expectProjected(opts: Parameters[0]) { + const email = await normalizeEmail(opts); + let error: unknown; + try { + buildMime(email); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(MimeError); + expect((error as MimeError).code).toBe('MIME_ERROR'); + // The projection's message, not the post-render one: nothing was encoded. + expect((error as MimeError).message).toMatch(PROJECTION); + } + + // A non-ASCII header value is B-encoded into 45-byte words of 72 octets + // each and folded one per line: 5 octets for every CJK character, so 3M + // characters (9 MB of UTF-8) is a 15 MB header. Before the header bound, + // `buildMime` encoded, folded, joined and re-encoded all of that before + // it measured anything, and a 7M-character subject terminated a 128 MB + // isolate instead of returning `MIME_ERROR`. + it('rejects a subject that cannot fit before encoding it', async () => { + await expectProjected({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: '漢'.repeat(3_000_000), + text: 'hi', + }); + }); + + it('counts every display name across the recipient list', async () => { + await expectProjected({ + from: 'a@x.dev', + to: Array.from({ length: MAX_RECIPIENTS }, (_, i) => ({ + email: `u${i}@y.dev`, + name: '漢'.repeat(30_000), + })), + subject: 's', + text: 'hi', + }); + }); + + it('counts a custom header, and not a reserved one that is never rendered', async () => { + const big = 'x'.repeat(11 * 1024 * 1024); + await expectProjected({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 'hi', + headers: { 'X-Note': big }, + }); + // `Subject` in the custom map is dropped by `buildMime`, so charging it + // would reject a message that fits. + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 'hi', + headers: { Subject: big }, + }); + expect(text).toContain('Subject: s\r\n'); + }); + + // A custom header's NAME is rendered verbatim too, and `normalizeEmail` + // bounds one name at under a kilobyte — not the sum, nor the count. With + // empty values the names and the `: ` CRLF around each are the whole + // message, and none of it was charged: 60k empty headers with + // 500-character names — a 30 MB message — projected to zero, rendered in + // full and peaked at 121 MB of a 128 MB isolate (measured, 240 ms); 100k + // of them went to 169 MB. Every name and its framing is charged now, so + // the same set is refused before a line is built (98 ms, 67 MB peak). + it('counts every custom header name and the framing around it', async () => { + const names = (count: number, len: number) => + Object.fromEntries(Array.from({ length: count }, (_, i) => [`X${i}`.padEnd(len, 'a'), ''])); + const started = performance.now(); + // 22k names of 500 characters: 11 MB of names, nothing else. + await expectProjected({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + headers: names(22_000, 500), + }); + // 31 ms measured; the bound leaves room for a loaded CI runner. + expect(performance.now() - started).toBeLessThan(2000); + // The bound is exact for a header with an empty value: 10,485 names of + // 996 characters project 745 octets under the cap and render 548 under + // it, so they build. One more 600-character name projects 141 under + // and renders 57 over, so the post-render check — not the projection — + // must be the one to refuse it. One more 996-character name is over + // by projection alone. + const fits = names(10_485, 996); + const { raw } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + headers: fits, + }); + expect(raw.byteLength).toBeLessThanOrEqual(MAX_MESSAGE_BYTES); + await expect( + build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + headers: { ...fits, ['Y'.padEnd(600, 'a')]: '' }, + }), + ).rejects.toThrow(/message size \d+ bytes exceeds Cloudflare limit/); + await expectProjected({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + headers: names(10_486, 996), + }); + }, 30_000); + + // The runner's own heap is not capped, so the pin runs the projection in + // a child with the isolate's cap. 60k empty headers with 996-character + // names are 60 MB of names the caller already holds; rendering them held + // the folded lines, their join and `rawText` beside the names — three + // copies, 180 MB live — and died with `FATAL ERROR: Reached heap limit` + // before the size check (measured). The projection refuses them at an + // 88 MB peak, in 173 ms. + it('refuses 60 MB of header names inside a 128 MB heap', () => { + const mimeModule = pathToFileURL( + join(import.meta.dirname, '../../src/runtime/email/mime.ts'), + ).href; + const child = ` + const { normalizeEmail, buildMime } = await import(process.argv[1]); + const headers = {}; + for (let i = 0; i < 60_000; i++) headers['X' + i + 'a'.repeat(996 - 1 - String(i).length)] = ''; + const t0 = performance.now(); + const email = await normalizeEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't', headers }); + let error; + try { buildMime(email); } catch (e) { error = e; } + console.log(JSON.stringify({ code: error?.code, message: error?.message, ms: performance.now() - t0 })); + `; + const stdout = execFileSync( + process.execPath, + ['--max-old-space-size=128', '--input-type=module', '-e', child, mimeModule], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }, + ); + const result = JSON.parse(stdout.trim().split('\n').at(-1) ?? '{}') as { + code?: string; + message?: string; + ms: number; + }; + expect(result.code).toBe('MIME_ERROR'); + expect(result.message).toMatch(PROJECTION); + // 173 ms measured locally; the bound leaves room for CI's slower runner + // (about 6x), where the old shape did not finish at all. + expect(result.ms).toBeLessThan(5000); + }, 30_000); + + it('never charges the whitespace that folding collapses', async () => { + // `foldHeader` re-joins a long value from its non-WSP tokens, so 11 MiB + // of spaces renders as one. A bound that counted `length` would reject + // this message; it fits. + const { text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: `a${' '.repeat(11 * 1024 * 1024)}b`, + text: 'hi', + }); + expect(text).toContain('Subject: a b\r\n'); + }); + + // `replyEmail` threads `In-Reply-To` and `References` straight off the + // INBOUND message, so a remote sender controls them up to the whole raw + // message. They are rendered verbatim — never `encodeWord`ed — but folded + // and joined like any other header, so an oversized chain was rendered in + // full before the post-render check saw it. + it('counts the threading headers', async () => { + // 900k twelve-character ids: 10.8 MB of non-WSP before a single space. + await expectProjected({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 'hi', + threading: { references: ' '.repeat(900_000).trimEnd() }, + }); + await expectProjected({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 'hi', + threading: { inReplyTo: `<${'x'.repeat(11 * 1024 * 1024)}@x>` }, + }); + }); + + // An attachment part's own headers are built outside `foldHeader`'s + // encoded path: `Content-Type: ; name=""`, + // `Content-Disposition: ; filename=""` and + // `Content-ID: `. Each of the three values is charged, and + // each is now also bounded per attachment in `normalizeAttachment` (see + // 'media type syntax of an attachment' and 'Content-ID syntax of an + // attachment'), so none reaches the projection over-long through + // `normalizeEmail`. The charge stays — `buildMime` is the authority on + // the final size, whatever handed it the email — so it is pinned on a + // hand-built `NormalizedEmail`, as the attachment-count test is. + it('counts an attachment Content-ID, even though normalizeAttachment now bounds it', () => { + const over = 'x'.repeat(11 * 1024 * 1024); + expect(() => + buildMime({ + from: { email: 'a@x.dev' }, + to: [{ email: 'b@y.dev' }], + cc: [], + bcc: [], + subject: 's', + text: 'hi', + headers: {}, + attachments: [ + { + filename: 'f.txt', + contentType: 'text/plain', + bytes: new Uint8Array(1), + contentId: over, + disposition: 'inline', + }, + ], + }), + ).toThrow(PROJECTION); + }); + + // `normalizeAttachment` bounds ONE filename at 986 characters, not the + // sum, and nothing bounds the part count: 50k zero-byte attachments with + // legal filenames are a 106 MB message — each filename rendered twice, + // inside 117 octets of boundary and framing — that was held three to + // four times over between the parts, their join, `rawText` and `raw`, + // and terminated a 128 MB isolate before the final size check. Every + // part is charged its filename twice plus that framing. + it('counts every attachment part: its filename twice, and the framing around it', async () => { + const filename = 'a'.repeat(986); + const empty = new Uint8Array(0); + const parts = (n: number) => + Array.from({ length: n }, () => ({ filename, contentType: 'a/b', content: empty })); + await expectProjected({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + attachments: parts(50_000), + }); + // The bound is exact for a part with no content: 5,012 parts project to + // 640 octets under the cap and render to 10.66 MB, so the post-render + // check — not the projection — must be the one to refuse them. + await expect( + build({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't', attachments: parts(5_012) }), + ).rejects.toThrow(/message size \d+ bytes exceeds Cloudflare limit/); + // And 4,900 parts, a 10.42 MB message, build. + const { raw } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + attachments: parts(4_900), + }); + expect(raw.byteLength).toBeLessThanOrEqual(MAX_MESSAGE_BYTES); + }); + + it('never rejects a References chain that fits, and never charges the whitespace it folds away', async () => { + // A 5 MB chain of short ids is a legal message: it folds at its spaces + // into 76-column lines and renders under the cap. + const id = ''; + const chain = Array.from({ length: Math.floor((5 * 1024 * 1024) / (id.length + 1)) }, () => id); + const { raw, text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 'hi', + threading: { references: chain.join(' ') }, + }); + expect(raw.byteLength).toBeLessThanOrEqual(MAX_MESSAGE_BYTES); + expect(unfoldHeader(text, 'References')).toBe(chain.join(' ')); + // A long chain is re-joined from its ids by single spaces, so 11 MiB of + // spaces between two ids renders as one; a bound that charged them + // would reject a message that fits. + const spaced = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 'hi', + threading: { references: `${' '.repeat(11 * 1024 * 1024)}` }, + }); + expect(spaced.text).toContain('References: \r\n'); + }); + + it('never rejects a header-heavy message that fits, and leaves the final check as the authority', async () => { + // 2.15M CJK characters project to ~10.32 MiB (4.8 octets each: the bound + // omits the fold's CRLF per word) but render to ~10.75 MB at 5 octets + // each. The projection must let it through; the post-render check must + // reject it. This is the one case that needs a full ~10 MB render (the + // property is AT the boundary), so it carries its own timeout: 0.8 s + // here, 5.4 s on a loaded 4-vCPU CI runner. + const nearly = '漢'.repeat(2_150_000); + const email = await normalizeEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: nearly, + text: 'hi', + }); + expect(() => buildMime(email)).toThrow(/message size \d+ bytes exceeds Cloudflare limit/); + // And a subject the projection accepts builds; the size that fits is + // not at issue, so keep this one small. + const fits = '漢'.repeat(20_000); + const { raw } = await build({ from: 'a@x.dev', to: 'b@y.dev', subject: fits, text: 'hi' }); + expect(raw.byteLength).toBeLessThanOrEqual(MAX_MESSAGE_BYTES); + }, 30_000); +}); + +describe('foldHeader memory', () => { + // A legal 3 MB subject of one-letter words is 1.5M fold points. The + // earlier fold kept a slice, a `{ text, comma }` object and a cons-string + // node per token — about 65 bytes of heap per input character — and + // terminated a 128 MB isolate on a message that fit. The fold now keeps + // only its finished lines (~1 byte per character). This pins the + // rendered shape; the memory bound is measured, not asserted, because + // the test runner's heap is not the isolate's. + it('folds a legal subject of millions of one-letter words', async () => { + const words = 1_500_000; + const { raw, text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 'a '.repeat(words).trimEnd(), + text: 'hi', + }); + expect(raw.byteLength).toBeLessThanOrEqual(MAX_MESSAGE_BYTES); + const lines = headerLines(text, 'Subject'); + expect(lines[0]).toBe(`Subject:${' a'.repeat(34)}`); + expect(lines.every((l) => l.length <= 76)).toBe(true); + expect(unfoldHeader(text, 'Subject')).toBe('a '.repeat(words).trimEnd()); + }); +}); + +describe('body line scan memory', () => { + // A legal 6 MB body of 3M two-character lines — pasted logs, say — renders + // to 9 MB. The encoding decision used to strip every line break with a + // global `replace` and `split` the body into an array of lines, the 7bit + // path re-joined it with another global `replace` (V8 keeps two or three + // parts-array slots per match), and the post-render line check `split` + // the whole message again: 240 MB of heap, all of it per LINE and + // invisible to any size projection, on a message that fit a 128 MB + // isolate four times over. Every pass is now one `charCodeAt` scan and + // the 7bit body is written into a buffer of its own size (measured, 32 + // MB). The memory bound is measured, not asserted, because the test + // runner's heap is not the isolate's; this pins the rendered shape and + // that the scans stay linear. + it('builds a legal message of millions of short lines in text and html', async () => { + const lines = 1_500_000; + const started = performance.now(); + const { raw, text } = await build({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 'a\n'.repeat(lines), + html: '\n'.repeat(lines), + }); + // A gross-regression guard only: the previous code died here, it did not + // slow down. 0.2 s locally; a loaded CI runner is several times slower. + expect(performance.now() - started).toBeLessThan(20_000); + expect(raw.byteLength).toBeLessThanOrEqual(MAX_MESSAGE_BYTES); + expect(raw.byteLength).toBeGreaterThan(lines * 5); + expect(text).toContain(`Content-Transfer-Encoding: 7bit\r\n\r\n${'a\r\n'.repeat(lines)}`); + expect(text).toContain(`Content-Transfer-Encoding: 7bit\r\n\r\n${'\r\n'.repeat(lines)}`); + // Every LF in the message is the LF of a CRLF. + expect(text).not.toMatch(/(^|[^\r])\n/); + }); +}); + +describe('quotedPrintable encoder', () => { + // Fixtures captured from the string-concatenating encoder these replace, + // so the byte-buffer encoder is pinned to the exact previous output. + const cases: Array<[string, string, string]> = [ + [ + 'soft-breaks at 76 columns', + 'é'.repeat(30), + '=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=\r\n=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9=\r\n=C3=A9=C3=A9=C3=A9=C3=A9=C3=A9', + ], + ['quotes trailing whitespace', 'a \nb\t\nc é', 'a=20\r\nb=09\r\nc =C3=A9'], + ['quotes control bytes and =', 'a\u0001b=é', 'a=01b=3D=C3=A9'], + ['normalizes CR, LF and CRLF', 'l1\r\nl2\rl3\nl4é', 'l1\r\nl2\r\nl3\r\nl4=C3=A9'], + [ + 'does not split a quoted byte across a soft break', + 'a'.repeat(100) + 'é', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=\r\naaaaaaaaaaaaaaaaaaaaaaaaa=C3=A9', + ], + ]; + for (const [name, input, expected] of cases) { + it(name, async () => { + const { text } = await build({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: input }); + expect(text).toContain('Content-Transfer-Encoding: quoted-printable'); + expect(bodyOf(text)).toBe(expected); + }); + } +}); diff --git a/packages/void/test/unit/email-own-account.test.ts b/packages/void/test/unit/email-own-account.test.ts new file mode 100644 index 000000000..8d87cdf20 --- /dev/null +++ b/packages/void/test/unit/email-own-account.test.ts @@ -0,0 +1,688 @@ +/** + * Email on the developer's OWN Cloudflare account (`void deploy --platform + * cloudflare`, design-doc 0078 "Mode B"). + * + * Two things differ from the managed platform and both are exercised here + * with `cloudflare:email` faked, since the real module exists only in workerd: + * + * outbound sendEmail() -> env.SEND_EMAIL.send(new EmailMessage(...)), + * one envelope per recipient, no proxy hop + * inbound Cloudflare calls the worker's `email()` with a REAL + * `ForwardableEmailMessage` and ignores the return value, so the + * generated entry must ENACT the recorded actions on it + */ +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { compileWorkerEntry } from '../../src/router/compile.ts'; +import { sendEmail } from '../../src/runtime/email.ts'; +import { + createMessageProxy, + enactEmailActions, + isForwardableEmailMessage, + parseEmail, + replyEmail, +} from '../../src/runtime/email/inbound.ts'; +import { MimeError } from '../../src/runtime/email/mime.ts'; +import { createEmailTestHarness } from '../../src/runtime/email/testing.ts'; +import { envContext } from '../../src/runtime/env-raw.ts'; +import { withRuntimeEnv } from '../../src/runtime/env.ts'; + +// Stand-in for workerd's `EmailMessage`. It only needs to be a recognisable +// class that keeps its constructor arguments, so a test can assert which +// envelope reached the binding and that a genuine instance (not a duck-typed +// object) was handed to `reply()`. +const fake = vi.hoisted(() => { + class FakeEmailMessage { + constructor( + readonly from: string, + readonly to: string, + readonly raw: ReadableStream | string, + ) {} + } + return { FakeEmailMessage }; +}); + +vi.mock('cloudflare:email', () => ({ EmailMessage: fake.FakeEmailMessage })); + +async function readAll(raw: ReadableStream | string): Promise { + if (typeof raw === 'string') { + return raw; + } + const reader = raw.getReader(); + let out = ''; + const decoder = new TextDecoder(); + for (;;) { + const { value, done } = await reader.read(); + if (done) { + break; + } + out += decoder.decode(value, { stream: true }); + } + return out + decoder.decode(); +} + +/** An error shaped the way Email Service throws them: `Error` + `code`. */ +function bindingError(code: string, message: string): Error & { code: string } { + return Object.assign(new Error(message), { code }); +} + +function fakeSendBinding( + respond: (msg: InstanceType) => { messageId?: string } | undefined, +): { + binding: { send(msg: unknown): Promise<{ messageId?: string } | undefined> }; + sent: Array>; +} { + const sent: Array> = []; + return { + sent, + binding: { + async send(msg: unknown) { + expect(msg).toBeInstanceOf(fake.FakeEmailMessage); + const m = msg as InstanceType; + sent.push(m); + return respond(m); + }, + }, + }; +} + +describe('sendEmail (SEND_EMAIL binding)', () => { + it('sends one EmailMessage per unique recipient and surfaces the binding messageIds', async () => { + const { binding, sent } = fakeSendBinding((m) => ({ messageId: `cf-${m.to}` })); + const result = await withRuntimeEnv({ SEND_EMAIL: binding }, () => + sendEmail({ + from: 'Acme ', + to: ['alice@example.com', 'Bob '], + cc: 'ALICE@example.com', + subject: 'Welcome', + text: 'Hi there', + }), + ); + + expect(result).toEqual({ + ok: true, + ids: [ + { recipient: 'alice@example.com', messageId: 'cf-alice@example.com' }, + { recipient: 'bob@example.com', messageId: 'cf-bob@example.com' }, + ], + }); + expect(sent.map((m) => [m.from, m.to])).toEqual([ + ['support@mail.acme.com', 'alice@example.com'], + ['support@mail.acme.com', 'bob@example.com'], + ]); + // Each envelope carries the shared MIME, on a fresh stream. + const bodies = await Promise.all(sent.map((m) => readAll(m.raw))); + for (const body of bodies) { + expect(body).toContain('Subject: Welcome'); + expect(body).toContain('Hi there'); + } + }); + + it('falls back to the locally generated messageId when the binding returns none', async () => { + const { binding } = fakeSendBinding(() => undefined); + const result = await withRuntimeEnv({ SEND_EMAIL: binding }, () => + sendEmail({ from: 'a@mail.acme.com', to: 'b@example.com', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.ids[0].messageId).toMatch(/@/); + } + }); + + it('is tried before __VOID_PROXY', async () => { + const { binding, sent } = fakeSendBinding(() => ({ messageId: 'cf-1' })); + const proxyCalls: Array = []; + const proxy = { + async fetch(req: Request | string) { + proxyCalls.push(typeof req === 'string' ? req : req.url); + return new Response('{"ok":true,"ids":[]}'); + }, + }; + const result = await withRuntimeEnv( + { SEND_EMAIL: binding, __VOID_PROXY: proxy, __VOID_PROXY_TOKEN: 't', __PROJECT_ID: 'p' }, + () => sendEmail({ from: 'a@mail.acme.com', to: 'b@example.com', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(true); + expect(sent).toHaveLength(1); + expect(proxyCalls).toEqual([]); + }); + + it('yields to the dev inbox / test harness sink', async () => { + const { binding, sent } = fakeSendBinding(() => ({ messageId: 'cf-1' })); + const harness = createEmailTestHarness(); + try { + const result = await withRuntimeEnv({ SEND_EMAIL: binding }, () => + sendEmail({ from: 'a@mail.acme.com', to: 'b@example.com', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(true); + expect(harness.messages).toHaveLength(1); + expect(sent).toHaveLength(0); + } finally { + harness.dispose(); + } + }); + + it('defaults from to __VOID_EMAIL_FROM', async () => { + const { binding, sent } = fakeSendBinding(() => ({ messageId: 'cf-1' })); + const result = await withRuntimeEnv( + { SEND_EMAIL: binding, __VOID_EMAIL_FROM: 'noreply@mail.acme.com' }, + () => sendEmail({ to: 'b@example.com', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(true); + expect(sent[0].from).toBe('noreply@mail.acme.com'); + expect(await readAll(sent[0].raw)).toContain('From: noreply@mail.acme.com'); + }); + + it.each([ + ['E_RECIPIENT_NOT_ALLOWED', 'UNVERIFIED_DESTINATION'], + ['E_SENDER_NOT_VERIFIED', 'INVALID_FROM'], + ['E_SENDER_DOMAIN_NOT_AVAILABLE', 'INVALID_FROM'], + ['E_RATE_LIMIT_EXCEEDED', 'QUOTA_EXCEEDED'], + ['E_DAILY_LIMIT_EXCEEDED', 'QUOTA_EXCEEDED'], + ['E_DELIVERY_FAILED', 'UPSTREAM_ERROR'], + ['E_INTERNAL_SERVER_ERROR', 'UPSTREAM_ERROR'], + ])( + 'maps a thrown %s to %s per recipient, keeping the Cloudflare message', + async (cfCode, code) => { + const thrown = bindingError(cfCode, `cloudflare says: ${cfCode}`); + const { binding } = fakeSendBinding(() => { + throw thrown; + }); + const result = await withRuntimeEnv({ SEND_EMAIL: binding }, () => + sendEmail({ from: 'a@mail.acme.com', to: 'b@example.com', subject: 's', text: 't' }), + ); + expect(result).toEqual({ + ok: false, + deliveries: [ + { + recipient: 'b@example.com', + ok: false, + error: { code, message: `cloudflare says: ${cfCode}`, cause: thrown }, + }, + ], + }); + }, + ); + + it('recognises the older message-only "destination address not verified" error', async () => { + const { binding } = fakeSendBinding(() => { + throw new Error('destination address b@example.com not verified'); + }); + const result = await withRuntimeEnv({ SEND_EMAIL: binding }, () => + sendEmail({ from: 'a@mail.acme.com', to: 'b@example.com', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'deliveries' in result) { + expect(result.deliveries[0]).toMatchObject({ + ok: false, + error: { code: 'UNVERIFIED_DESTINATION' }, + }); + } else { + throw new Error('expected a per-recipient failure'); + } + }); + + it('attempts every recipient and reports a mid-batch failure in the proxy shape', async () => { + const { binding, sent } = fakeSendBinding((m) => { + if (m.to === 'blocked@example.com') { + throw bindingError('E_RECIPIENT_NOT_ALLOWED', 'not allowed'); + } + return { messageId: `cf-${m.to}` }; + }); + const result = await withRuntimeEnv({ SEND_EMAIL: binding }, () => + sendEmail({ + from: 'a@mail.acme.com', + to: ['blocked@example.com', 'ok@example.com'], + subject: 's', + text: 't', + }), + ); + expect(sent.map((m) => m.to)).toEqual(['blocked@example.com', 'ok@example.com']); + expect(result).toMatchObject({ + ok: false, + deliveries: [ + { + recipient: 'blocked@example.com', + ok: false, + error: { code: 'UNVERIFIED_DESTINATION', message: 'not allowed' }, + }, + { recipient: 'ok@example.com', ok: true, messageId: 'cf-ok@example.com' }, + ], + }); + }); +}); + +/** + * A `ForwardableEmailMessage` as workerd hands it to `email()`: a one-shot + * raw stream, a `Headers` instance, and the three native methods, each + * recording its call. + * + * `verifiedDestinations` models the one refusal Cloudflare documents for + * `forward(rcptTo)` — "Forward this email message to a verified destination + * address of the account" — so a fake with the list set throws, as the real + * method does, for anything that is not on it. Left unset, every address is + * accepted, which is what the fake did before the option existed. + */ +function nativeMessage(opts: { + from: string; + to: string; + mime: string; + verifiedDestinations?: ReadonlyArray; +}): { + message: ForwardableEmailMessage; + calls: { + rejects: Array; + forwards: Array<{ to: string; headers: Headers | undefined }>; + replies: Array; + }; +} { + const bytes = new TextEncoder().encode(opts.mime); + const headers = new Headers(); + for (const line of opts.mime.split('\r\n\r\n')[0].split('\r\n')) { + const idx = line.indexOf(':'); + headers.set(line.slice(0, idx), line.slice(idx + 1).trim()); + } + const calls = { + rejects: [] as Array, + forwards: [] as Array<{ to: string; headers: Headers | undefined }>, + replies: [] as Array, + }; + const message = { + from: opts.from, + to: opts.to, + headers, + raw: new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }), + rawSize: bytes.byteLength, + setReject(reason: string) { + calls.rejects.push(reason); + }, + async forward(to: string, fwdHeaders?: Headers) { + if (opts.verifiedDestinations && !opts.verifiedDestinations.includes(to)) { + throw bindingError('E_RECIPIENT_NOT_ALLOWED', `destination address ${to} not verified`); + } + calls.forwards.push({ to, headers: fwdHeaders }); + return { messageId: 'fwd' }; + }, + async reply(reply: unknown) { + calls.replies.push(reply); + return { messageId: 'rep' }; + }, + } as unknown as ForwardableEmailMessage; + return { message, calls }; +} + +const INBOUND_MIME = + 'From: sender@example.com\r\n' + + 'To: support@mail.acme.com\r\n' + + 'Subject: Help\r\n' + + 'Message-ID: \r\n' + + '\r\n' + + 'my order is missing\r\n'; + +/** + * Runs the GENERATED `handleEmail` from `compileWorkerEntry` with the real + * runtime wired in and the user handler injected — the seam between "record + * the actions" and "enact them on the native message" is what is under test. + */ +function buildHandleEmail( + userHandler: (message: ForwardableEmailMessage, env: unknown, ctx: unknown) => Promise, +): (envelope: unknown, env: unknown, ctx: unknown) => Promise { + const code = compileWorkerEntry({ hasEmail: true }); + const start = code.indexOf('async function handleEmail'); + expect(start).toBeGreaterThan(-1); + const end = code.indexOf('\n}\n', start); + const body = code.slice(start, end + 3); + // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func + return new Function( + '__filterInternalBindings', + '__createMessageProxy', + '__enactEmailActions', + '__isForwardableEmailMessage', + '__withRawEnv', + 'withRuntimeEnv', + '__emailHandler', + `${body}\nreturn handleEmail;`, + )( + (env: unknown) => env, + createMessageProxy, + enactEmailActions, + isForwardableEmailMessage, + (env: unknown, fn: () => unknown) => envContext.run(env as never, fn), + withRuntimeEnv, + userHandler, + ) as never; +} + +describe('generated handleEmail on a native ForwardableEmailMessage', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('enacts setReject on the real message', async () => { + const handleEmail = buildHandleEmail(async (message) => { + message.setReject('no thanks'); + }); + const { message, calls } = nativeMessage({ + from: 'sender@example.com', + to: 'support@mail.acme.com', + mime: INBOUND_MIME, + }); + + const actions = await handleEmail(message, {}, {}); + + expect(calls.rejects).toEqual(['no thanks']); + expect(actions).toEqual([{ type: 'reject', reason: 'no thanks' }]); + }); + + it('enacts forward with its headers as a Headers instance', async () => { + const handleEmail = buildHandleEmail(async (message) => { + await message.forward('ops@acme.com', new Headers({ 'X-Void-Trace': 'abc' })); + }); + const { message, calls } = nativeMessage({ + from: 'sender@example.com', + to: 'support@mail.acme.com', + mime: INBOUND_MIME, + }); + + await handleEmail(message, {}, {}); + + expect(calls.forwards).toHaveLength(1); + expect(calls.forwards[0].to).toBe('ops@acme.com'); + expect(calls.forwards[0].headers).toBeInstanceOf(Headers); + expect(calls.forwards[0].headers?.get('X-Void-Trace')).toBe('abc'); + }); + + it('enacts a forward recorded after parseEmail drained the native raw stream', async () => { + // The documented handler shape — `parseEmail(message)`, then + // `message.forward(...)` — reads the one-shot native `raw` to completion + // before the forward is replayed, exactly as Cloudflare's own "Email + // Storage" example does on a real message. The replay must not depend + // on `raw`: `forward(rcptTo, headers?)` carries no bytes, the runtime + // forwards the message it holds (miniflare's local `forward` never reads + // `raw` either), so a disturbed stream at replay time is the ordinary + // case, not a fault. + const handleEmail = buildHandleEmail(async (message) => { + const parsed = await parseEmail(message); + await message.forward('ops@acme.com', new Headers({ 'X-Subject': parsed.subject ?? '' })); + }); + const { message, calls } = nativeMessage({ + from: 'sender@example.com', + to: 'support@mail.acme.com', + mime: INBOUND_MIME, + }); + const nativeForward = message.forward.bind(message); + let rawLockedAtForward: boolean | undefined; + vi.spyOn(message, 'forward').mockImplementation(async (to, fwdHeaders) => { + rawLockedAtForward = message.raw.locked; + return nativeForward(to, fwdHeaders); + }); + + const actions = await handleEmail(message, {}, {}); + + // The parse consumed the native stream and it is still held by that + // reader when the native forward fires — and the forward fires anyway. + expect(rawLockedAtForward).toBe(true); + expect(calls.forwards).toHaveLength(1); + expect(calls.forwards[0].to).toBe('ops@acme.com'); + expect(calls.forwards[0].headers?.get('X-Subject')).toBe('Help'); + expect(actions).toEqual([ + { type: 'forward', to: 'ops@acme.com', headers: { 'x-subject': 'Help' } }, + ]); + }); + + it('fires nothing on the native message when a later forward carries an invalid header record', async () => { + // The plain-record form is stored verbatim, so a value `new Headers()` + // rejects used to surface only during native replay — after the first + // forward had already fired. Cloudflare then sees a failed invocation + // for a message that was partly delivered. The bad header must fail in + // the handler's own call, before anything is enacted. + const handleEmail = buildHandleEmail(async (message) => { + await message.forward('ops@acme.com'); + // The record form is a runtime affordance (JS handlers, or a cast): + // `ForwardableEmailMessage.forward` is typed against `Headers`. + const record = { 'X-Void-Trace': 'abc\r\nBcc: leak@evil.test' } as unknown as Headers; + await message.forward('archive@acme.com', record); + }); + const { message, calls } = nativeMessage({ + from: 'sender@example.com', + to: 'support@mail.acme.com', + mime: INBOUND_MIME, + }); + + const outcome = await handleEmail(message, {}, {}).then( + () => 'resolved', + (e: unknown) => e, + ); + + // The partial delivery is the bug: nothing may have reached the native + // message. Asserted before the error shape so a regression names it. + expect(calls.forwards).toEqual([]); + expect(calls.replies).toEqual([]); + expect(calls.rejects).toEqual([]); + expect(outcome).toBeInstanceOf(MimeError); + expect((outcome as MimeError).message).toMatch(/^forward: invalid header/); + }); + + it('fires nothing on the native message when a later forward carries a malformed address', async () => { + // The address half of the case above. `forward()` used to check only the + // length, so `not-an-address` was recorded and surfaced during native + // replay — Cloudflare refuses a destination that is not a verified + // address — after the first forward had already fired. The fake models + // that refusal; the proxy must fail in the handler's own call instead. + const handleEmail = buildHandleEmail(async (message) => { + await message.forward('ops@acme.com'); + await message.forward('not-an-address'); + }); + const { message, calls } = nativeMessage({ + from: 'sender@example.com', + to: 'support@mail.acme.com', + mime: INBOUND_MIME, + verifiedDestinations: ['ops@acme.com'], + }); + + const outcome = await handleEmail(message, {}, {}).then( + () => 'resolved', + (e: unknown) => e, + ); + + expect(calls.forwards).toEqual([]); + expect(calls.replies).toEqual([]); + expect(calls.rejects).toEqual([]); + expect(outcome).toBeInstanceOf(MimeError); + expect((outcome as MimeError).code).toBe('INVALID_TO'); + expect((outcome as MimeError).message).toBe( + 'to: address is not a valid email address (ASCII local part and dotted domain, ' + + "RFC 5322 dot-atom; at most 64 characters before the '@' and 254 in total).", + ); + }); + + it('fires nothing on the native message when a later hand-built reply carries a malformed address', async () => { + // The `{ from, to, raw }` reply form bypasses `normalizeEmail`; it used to + // check only the length, so the malformed `to` reached Cloudflare's + // native `reply()` after the forward had fired. Same record-time rule + // as `forward(to)` now: the handler's own call fails, nothing replays. + const handleEmail = buildHandleEmail(async (message) => { + await message.forward('ops@acme.com'); + await message.reply({ + from: 'support@mail.acme.com', + to: 'not-an-address', + raw: new TextEncoder().encode('Subject: re\r\n\r\nhi\r\n'), + } as unknown as Parameters[0]); + }); + const { message, calls } = nativeMessage({ + from: 'sender@example.com', + to: 'support@mail.acme.com', + mime: INBOUND_MIME, + verifiedDestinations: ['ops@acme.com'], + }); + + const outcome = await handleEmail(message, {}, {}).then( + () => 'resolved', + (e: unknown) => e, + ); + + expect(calls.forwards).toEqual([]); + expect(calls.replies).toEqual([]); + expect(calls.rejects).toEqual([]); + expect(outcome).toBeInstanceOf(MimeError); + expect((outcome as MimeError).code).toBe('INVALID_TO'); + expect((outcome as MimeError).message).toBe( + 'to: address is not a valid email address (ASCII local part and dotted domain, ' + + "RFC 5322 dot-atom; at most 64 characters before the '@' and 254 in total).", + ); + }); + + it('enacts replyEmail as a real EmailMessage sent from the delivered address', async () => { + const handleEmail = buildHandleEmail(async (message) => { + // Both reads of the one-shot native stream must work: the proxy + // buffers it so a parse followed by a reply is the ordinary case. + const parsed = await parseEmail(message); + await replyEmail(message, { text: `re: ${parsed.text?.trim()}` }); + }); + const { message, calls } = nativeMessage({ + from: 'sender@example.com', + to: 'support@mail.acme.com', + mime: INBOUND_MIME, + }); + + // No __VOID_PROJECT_SLUG: the reply sender is message.to itself. + const actions = await handleEmail(message, {}, {}); + + expect(calls.replies).toHaveLength(1); + const reply = calls.replies[0] as InstanceType; + expect(reply).toBeInstanceOf(fake.FakeEmailMessage); + expect(reply.from).toBe('support@mail.acme.com'); + expect(reply.to).toBe('sender@example.com'); + const raw = await readAll(reply.raw); + expect(raw).toContain('Subject: Re: Help'); + expect(raw).toContain('In-Reply-To: '); + expect(raw).toContain('re: my order is missing'); + expect(actions).toMatchObject([ + { type: 'reply', from: 'support@mail.acme.com', to: 'sender@example.com' }, + ]); + }); + + it('keeps the platform reply sender when a slug is injected', async () => { + const handleEmail = buildHandleEmail(async (message) => { + await replyEmail(message, { text: 'ack' }); + }); + const { message, calls } = nativeMessage({ + from: 'sender@example.com', + to: 'acme+support@mail.void.cloud', + mime: INBOUND_MIME, + }); + + await handleEmail( + message, + { __VOID_PROJECT_SLUG: 'acme', __VOID_EMAIL_DOMAIN: 'mail.void.cloud' }, + {}, + ); + + const reply = calls.replies[0] as InstanceType; + expect(reply.from).toBe('acme+noreply@mail.void.cloud'); + }); + + it('enacts a reject that followed a forward as the reject alone', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const handleEmail = buildHandleEmail(async (message) => { + await message.forward('ops@acme.com'); + message.setReject('changed my mind'); + }); + const { message, calls } = nativeMessage({ + from: 'sender@example.com', + to: 'support@mail.acme.com', + mime: INBOUND_MIME, + }); + + await handleEmail(message, {}, {}); + + expect(calls.forwards).toEqual([]); + expect(calls.rejects).toEqual(['changed my mind']); + }); + + it('hands the native setReject() the reason the platform router would send', async () => { + // A reason built from the parsed message carries what the remote sender + // put there — a CRLF and a NUL smuggled through an RFC 2047 encoded-word + // here. The platform router slices the reason to 1000 characters and + // strips controls before it reaches the SMTP reply; this replay used to + // pass the recorded string to the native `setReject()` verbatim, so the + // same handler injected a line into the SMTP reply on your own account + // and was clean on the platform. The proxy now records the normalized + // reason, so the two deployments send the same string. + const handleEmail = buildHandleEmail(async (message) => { + const parsed = await parseEmail(message); + message.setReject(`unwanted: ${parsed.subject}${'!'.repeat(2000)}`); + }); + const { message, calls } = nativeMessage({ + from: 'sender@example.com', + to: 'support@mail.acme.com', + mime: + 'From: sender@example.com\r\n' + + 'To: support@mail.acme.com\r\n' + + 'Subject: =?UTF-8?Q?spam=0D=0ABcc:_x=00?=\r\n' + + '\r\n' + + 'body\r\n', + }); + + const actions = await handleEmail(message, {}, {}); + + // 23 characters of prefix plus 977 "!" fill the 1000-character window; + // the CRLF pair then collapses to one space and the NUL to another. + const expected = `unwanted: spam Bcc: x ${'!'.repeat(977)}`; + expect(calls.rejects).toEqual([expected]); + expect(actions).toEqual([{ type: 'reject', reason: expected }]); + }); + + it('fires nothing on the native message when a later forward carries too many headers', async () => { + // Cloudflare's native `forward()` accepts any bag, so on your own account + // a 33-header forward used to go out — while the platform router drops + // the WHOLE action list for the same handler. The router's bounds now + // fail the handler's own call, before anything is enacted, in both. + const handleEmail = buildHandleEmail(async (message) => { + await message.forward('ops@acme.com'); + const bag = new Headers(); + for (let i = 0; i < 33; i++) { + bag.set(`X-Void-${i}`, 'v'); + } + await message.forward('archive@acme.com', bag); + }); + const { message, calls } = nativeMessage({ + from: 'sender@example.com', + to: 'support@mail.acme.com', + mime: INBOUND_MIME, + }); + + const outcome = await handleEmail(message, {}, {}).then( + () => 'resolved', + (e: unknown) => e, + ); + + expect(calls.forwards).toEqual([]); + expect(calls.replies).toEqual([]); + expect(calls.rejects).toEqual([]); + expect(outcome).toBeInstanceOf(MimeError); + expect((outcome as MimeError).message).toBe('forward: 33 headers, over the 32-header limit.'); + }); + + it('still returns the actions as data for a platform envelope, enacting nothing', async () => { + const handleEmail = buildHandleEmail(async (message) => { + message.setReject('spam'); + }); + const envelope = { + from: 'sender@example.com', + to: 'acme+support@mail.void.cloud', + headers: {}, + raw: new TextEncoder().encode(INBOUND_MIME), + rawSize: INBOUND_MIME.length, + }; + + const actions = await handleEmail(envelope, { __VOID_PROJECT_SLUG: 'acme' }, {}); + + expect(actions).toEqual([{ type: 'reject', reason: 'spam' }]); + expect(isForwardableEmailMessage(envelope)).toBe(false); + }); +}); diff --git a/packages/void/test/unit/email-send.test.ts b/packages/void/test/unit/email-send.test.ts new file mode 100644 index 000000000..d184956ea --- /dev/null +++ b/packages/void/test/unit/email-send.test.ts @@ -0,0 +1,1270 @@ +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { sendEmail } from '../../src/runtime/email.ts'; +import { + clearDevBuffer, + DEV_INBOX_ENV_VAR, + listDevMessages, +} from '../../src/runtime/email/dev-inbox.ts'; +import { createEmailTestHarness } from '../../src/runtime/email/testing.ts'; +import { withRuntimeEnv } from '../../src/runtime/env.ts'; + +afterEach(() => { + clearDevBuffer(); +}); + +// Stand-in for workerd's `EmailMessage`, so the own-account `SEND_EMAIL` +// transport can run in the plain-node runner and its result be compared +// with a captured one. +const fake = vi.hoisted(() => { + class FakeEmailMessage { + constructor( + readonly from: string, + readonly to: string, + readonly raw: ReadableStream | string, + ) {} + } + return { FakeEmailMessage }; +}); + +vi.mock('cloudflare:email', () => ({ EmailMessage: fake.FakeEmailMessage })); + +/** + * Build a fake `__VOID_PROXY` service binding that responds to + * `/email/send` with a canned response. Captures every request body for + * assertion. + */ +function fakeProxy(handler: (body: unknown) => { status: number; body: unknown }): { + proxy: { fetch(req: Request | string, init?: RequestInit): Promise }; + calls: Array<{ url: string; body: unknown }>; +} { + const calls: Array<{ url: string; body: unknown }> = []; + return { + calls, + proxy: { + async fetch(req, init) { + const url = typeof req === 'string' ? req : req.url; + const bodyText = + typeof init?.body === 'string' + ? init.body + : init?.body instanceof ArrayBuffer + ? new TextDecoder().decode(init.body) + : ''; + let parsed: unknown = null; + try { + parsed = JSON.parse(bodyText); + } catch { + /* ignore */ + } + calls.push({ url, body: parsed }); + const result = handler(parsed); + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { 'content-type': 'application/json' }, + }); + }, + }, + }; +} + +const PROXY_ENV_KEYS = { + __VOID_PROXY_TOKEN: 'tok', + __PROJECT_ID: 'p_test', +}; + +describe('sendEmail (test harness)', () => { + it('captures sends and returns ok with a dev id', async () => { + const harness = createEmailTestHarness(); + try { + const result = await sendEmail({ + from: 'Acme ', + to: 'user@example.com', + subject: 'Welcome', + text: 'Hi there', + html: '

Hi there

', + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.ids).toHaveLength(1); + expect(result.ids[0].recipient).toBe('user@example.com'); + expect(result.ids[0].messageId).toMatch(/^dev_/); + } + expect(harness.messages).toHaveLength(1); + const m = harness.messages[0]; + expect(m.subject).toBe('Welcome'); + expect(m.from.email).toBe('hello@acme.dev'); + expect(m.to[0].email).toBe('user@example.com'); + expect(m.text).toBe('Hi there'); + expect(m.html).toBe('

Hi there

'); + } finally { + harness.dispose(); + } + }); + + it('does not populate the dev buffer when only the harness sink is active', async () => { + const harness = createEmailTestHarness(); + try { + await sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 'harness-only', + text: 'hi', + }); + expect(listDevMessages().some((m) => m.subject === 'harness-only')).toBe(false); + expect(harness.messages.some((m) => m.subject === 'harness-only')).toBe(true); + } finally { + harness.dispose(); + } + }); + + // A captured result must be the result a deploy returns. Both production + // transports send to `uniqueRecipients()` — one envelope per address, + // matched case-insensitively and reported lowercased — while the capture + // path mapped every `to`/`cc`/`bcc` occurrence in its original case, so a + // test written against it expected four deliveries where production makes + // two. The captured MESSAGE still carries the fields as given: the inbox + // shows what was asked for, `ids` reports what would be sent. + it('reports one id per unique address, the way the production transports do', async () => { + const opts = { + from: 'a@x.dev', + to: ['A@x.dev'], + cc: ['a@x.dev'], + bcc: ['Bob ', 'b@x.dev'], + subject: 's', + text: 't', + }; + const harness = createEmailTestHarness(); + let captured: Awaited>; + try { + captured = await sendEmail(opts); + expect(harness.messages).toHaveLength(1); + const m = harness.messages[0]; + expect(m.to).toEqual([{ email: 'A@x.dev' }]); + expect(m.cc).toEqual([{ email: 'a@x.dev' }]); + expect(m.bcc).toEqual([{ email: 'B@x.dev', name: 'Bob' }, { email: 'b@x.dev' }]); + } finally { + harness.dispose(); + } + expect(captured.ok).toBe(true); + if (!captured.ok) { + return; + } + expect(captured.ids).toEqual([ + { recipient: 'a@x.dev', messageId: captured.ids[0].messageId }, + { recipient: 'b@x.dev', messageId: captured.ids[0].messageId }, + ]); + expect(captured.ids[0].messageId).toMatch(/^dev_/); + + // The same call through the own-account binding: the recipients the + // capture reported are exactly the envelopes production sends. + const sent: Array = []; + const binding = { + async send(msg: unknown) { + const m = msg as InstanceType; + sent.push(m.to); + return { messageId: `cf-${m.to}` }; + }, + }; + const deployed = await withRuntimeEnv({ SEND_EMAIL: binding }, () => sendEmail(opts)); + expect(deployed.ok).toBe(true); + if (deployed.ok) { + expect(deployed.ids.map((d) => d.recipient)).toEqual(captured.ids.map((d) => d.recipient)); + expect(sent).toEqual(captured.ids.map((d) => d.recipient)); + } + }); + + it('clear() empties the harness without disposing the sink', async () => { + const harness = createEmailTestHarness(); + try { + await sendEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 's1', text: 't' }); + harness.clear(); + expect(harness.messages).toHaveLength(0); + await sendEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 's2', text: 't' }); + expect(harness.messages).toHaveLength(1); + expect(harness.messages[0].subject).toBe('s2'); + } finally { + harness.dispose(); + } + }); +}); + +describe('sendEmail (error mapping)', () => { + it('returns INVALID_FROM for malformed sender', async () => { + const harness = createEmailTestHarness(); + try { + const result = await sendEmail({ + from: 'not-an-email', + to: 'b@y.dev', + subject: 's', + text: 't', + }); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('INVALID_FROM'); + } + } finally { + harness.dispose(); + } + }); + + it('returns MIME_ERROR when neither text nor html is set', async () => { + const harness = createEmailTestHarness(); + try { + const result = await sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + } as Parameters[0]); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('MIME_ERROR'); + } + } finally { + harness.dispose(); + } + }); + + it('returns BINDING_MISSING when no sink and no __VOID_PROXY', async () => { + const result = await withRuntimeEnv({}, () => + sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + sendInDev: true, + }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('BINDING_MISSING'); + } + }); + + it('returns INVALID_TO for an oversize recipient list before any transport is tried', async () => { + // Pre-flight: it must come back as a top-level `error`, from the + // harness (`void dev`) exactly as it would from a deployed worker — the + // proxy's own 100-recipient refusal used to be the first place a list + // like this was ever rejected, and only after the tenant had built it. + const harness = createEmailTestHarness(); + try { + const result = await sendEmail({ + from: 'a@x.dev', + to: Array.from({ length: 101 }, (_, i) => `u${i}@example.com`), + subject: 's', + text: 't', + }); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('INVALID_TO'); + expect(result.error.message).toMatch(/too many recipients \(max 100, got 101\)/); + } + expect(harness.messages).toHaveLength(0); + } finally { + harness.dispose(); + } + }); +}); + +describe('sendEmail (from defaulting)', () => { + // The platform mail domain is injected per environment (`mail.void.cloud` + // in production, its own zone in staging), so these tests deliberately use + // a non-production value: a passing assertion then proves the address was + // built from the binding rather than from a baked-in constant. + const MAIL_DOMAIN = 'mail.staging.void.cloud'; + + it('defaults from to +noreply@<__VOID_EMAIL_DOMAIN> when omitted and both bindings are injected', async () => { + const harness = createEmailTestHarness(); + try { + const result = await withRuntimeEnv( + { __VOID_PROJECT_SLUG: 'acme', __VOID_EMAIL_DOMAIN: MAIL_DOMAIN }, + () => sendEmail({ to: 'b@y.dev', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(true); + expect(harness.messages).toHaveLength(1); + expect(harness.messages[0].from.email).toBe(`acme+noreply@${MAIL_DOMAIN}`); + } finally { + harness.dispose(); + } + }); + + it('defaults from to __VOID_EMAIL_FROM when omitted (own-account deploys)', async () => { + const harness = createEmailTestHarness(); + try { + const result = await withRuntimeEnv( + { __VOID_EMAIL_FROM: 'Acme ' }, + () => sendEmail({ to: 'b@y.dev', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(true); + expect(harness.messages[0].from).toEqual({ email: 'noreply@mail.acme.com', name: 'Acme' }); + } finally { + harness.dispose(); + } + }); + + it('prefers __VOID_EMAIL_FROM over the platform slug default', async () => { + const harness = createEmailTestHarness(); + try { + await withRuntimeEnv( + { + __VOID_EMAIL_FROM: 'noreply@mail.acme.com', + __VOID_PROJECT_SLUG: 'acme', + __VOID_EMAIL_DOMAIN: MAIL_DOMAIN, + }, + () => sendEmail({ to: 'b@y.dev', subject: 's', text: 't' }), + ); + expect(harness.messages[0].from.email).toBe('noreply@mail.acme.com'); + } finally { + harness.dispose(); + } + }); + + it('returns INVALID_FROM for a malformed __VOID_EMAIL_FROM instead of guessing', async () => { + const harness = createEmailTestHarness(); + try { + const result = await withRuntimeEnv({ __VOID_EMAIL_FROM: 'not-an-address' }, () => + sendEmail({ to: 'b@y.dev', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('INVALID_FROM'); + } + expect(harness.messages).toHaveLength(0); + } finally { + harness.dispose(); + } + }); + + it('respects explicit from even when both bindings are set', async () => { + const harness = createEmailTestHarness(); + try { + await withRuntimeEnv({ __VOID_PROJECT_SLUG: 'acme', __VOID_EMAIL_DOMAIN: MAIL_DOMAIN }, () => + sendEmail({ + from: 'override@example.com', + to: 'b@y.dev', + subject: 's', + text: 't', + }), + ); + expect(harness.messages[0].from.email).toBe('override@example.com'); + } finally { + harness.dispose(); + } + }); + + it('respects explicit from when neither binding is present', async () => { + // The bindings only feed the default. A caller who supplies `from` + // must keep working outside a deployed worker. + const harness = createEmailTestHarness(); + try { + const result = await withRuntimeEnv({}, () => + sendEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(true); + expect(harness.messages[0].from.email).toBe('a@x.dev'); + } finally { + harness.dispose(); + } + }); + + it('returns INVALID_FROM when from is omitted and neither binding is available', async () => { + const harness = createEmailTestHarness(); + try { + const result = await withRuntimeEnv({}, () => + sendEmail({ to: 'b@y.dev', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('INVALID_FROM'); + } + } finally { + harness.dispose(); + } + }); + + it('returns INVALID_FROM (never a baked-in domain) when __VOID_EMAIL_DOMAIN is missing', async () => { + // Regression guard for the whole point of the binding: with a slug but + // no configured domain, the send must fail loudly rather than quietly + // addressing the production zone. + const harness = createEmailTestHarness(); + try { + const result = await withRuntimeEnv({ __VOID_PROJECT_SLUG: 'acme' }, () => + sendEmail({ to: 'b@y.dev', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('INVALID_FROM'); + expect(result.error.message).not.toContain('mail.void.cloud'); + } + expect(harness.messages).toHaveLength(0); + } finally { + harness.dispose(); + } + }); + + it('returns INVALID_FROM when __VOID_PROJECT_SLUG is missing but the domain is set', async () => { + const harness = createEmailTestHarness(); + try { + const result = await withRuntimeEnv({ __VOID_EMAIL_DOMAIN: MAIL_DOMAIN }, () => + sendEmail({ to: 'b@y.dev', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('INVALID_FROM'); + } + expect(harness.messages).toHaveLength(0); + } finally { + harness.dispose(); + } + }); + + it('refuses to substitute a malformed __VOID_PROJECT_SLUG', async () => { + const harness = createEmailTestHarness(); + try { + // Slug containing `@` would corrupt the resulting envelope address. + const result = await withRuntimeEnv( + { __VOID_PROJECT_SLUG: 'evil@x.com', __VOID_EMAIL_DOMAIN: MAIL_DOMAIN }, + () => sendEmail({ to: 'b@y.dev', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('INVALID_FROM'); + } + } finally { + harness.dispose(); + } + }); + + it('returns INVALID_FROM (not MIME_ERROR) when from is explicitly null', async () => { + // Regression: `null` previously slipped past the empty-from guard + // and was passed to parseAddress, surfacing as MIME_ERROR. + const harness = createEmailTestHarness(); + try { + const result = await sendEmail({ + from: null as unknown as string, + to: 'b@y.dev', + subject: 's', + text: 't', + }); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('INVALID_FROM'); + } + } finally { + harness.dispose(); + } + }); + + it('accepts a 56-char slug (RFC 5321 64-octet local-part limit minus +noreply)', async () => { + const harness = createEmailTestHarness(); + try { + const slug = 'a'.repeat(56); + const result = await withRuntimeEnv( + { __VOID_PROJECT_SLUG: slug, __VOID_EMAIL_DOMAIN: MAIL_DOMAIN }, + () => sendEmail({ to: 'b@y.dev', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(true); + expect(harness.messages[0].from.email).toBe(`${slug}+noreply@${MAIL_DOMAIN}`); + } finally { + harness.dispose(); + } + }); + + it('rejects a 57-char slug (would build a 65-octet local-part)', async () => { + const harness = createEmailTestHarness(); + try { + const slug = 'a'.repeat(57); + const result = await withRuntimeEnv( + { __VOID_PROJECT_SLUG: slug, __VOID_EMAIL_DOMAIN: MAIL_DOMAIN }, + () => sendEmail({ to: 'b@y.dev', subject: 's', text: 't' }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('INVALID_FROM'); + } + } finally { + harness.dispose(); + } + }); + + it('rejects an explicit 65-character local part before capture, like the slug default', async () => { + // The slug default above refuses to BUILD a 65-octet local part, but the + // same address passed explicitly used to be captured as a success — + // under `void dev` and here — and could only bounce at an MTA. + const harness = createEmailTestHarness(); + try { + const from = await sendEmail({ + from: `${'a'.repeat(57)}+noreply@${MAIL_DOMAIN}`, + to: 'b@y.dev', + subject: 's', + text: 't', + }); + expect(from.ok).toBe(false); + if (!from.ok && 'error' in from) { + expect(from.error.code).toBe('INVALID_FROM'); + expect(from.error.message).toMatch( + /^from: address is not a valid email address \(ASCII local part/, + ); + } + const to = await sendEmail({ + from: 'a@x.dev', + to: `${'a'.repeat(65)}@y.dev`, + subject: 's', + text: 't', + }); + expect(to.ok).toBe(false); + if (!to.ok && 'error' in to) { + expect(to.error.code).toBe('INVALID_TO'); + } + expect(harness.messages).toHaveLength(0); + } finally { + harness.dispose(); + } + }); + + it('rejects a 257-character address before capture, whatever its local part', async () => { + // `a@` + a 255-octet domain is under the 320 total and the 64-octet + // local part, and used to be captured as a success — under `void dev` + // and here — when RFC 5321 §4.5.3.1.3 caps the path at 256 octets. + const label = 'b'.repeat(63); + const over = `a@${[label, label, label, label].join('.')}`; + expect(over).toHaveLength(257); + const harness = createEmailTestHarness(); + try { + const from = await sendEmail({ from: over, to: 'b@y.dev', subject: 's', text: 't' }); + expect(from.ok).toBe(false); + if (!from.ok && 'error' in from) { + expect(from.error.code).toBe('INVALID_FROM'); + expect(from.error.message).toMatch( + /^from: address is not a valid email address \(ASCII local part/, + ); + } + const to = await sendEmail({ from: 'a@x.dev', to: over, subject: 's', text: 't' }); + expect(to.ok).toBe(false); + if (!to.ok && 'error' in to) { + expect(to.error.code).toBe('INVALID_TO'); + } + expect(harness.messages).toHaveLength(0); + } finally { + harness.dispose(); + } + }); +}); + +describe('sendEmail (proxy round trip)', () => { + it('returns ok and surfaces messageIds from the proxy', async () => { + const { proxy, calls } = fakeProxy(() => ({ + status: 200, + body: { ok: true, ids: [{ recipient: 'b@y.dev', messageId: 'cf-1' }] }, + })); + const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + sendInDev: true, + }), + ); + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain('/email/send'); + const body = calls[0].body as Record; + expect(body.projectId).toBe('p_test'); + // Legacy auth field name (proxy still reads it under the historical + // `aiToken` key). New `proxyToken` ships the same value so the proxy + // can migrate without a coordinated release. + expect(body.aiToken).toBe('tok'); + expect(body.proxyToken).toBe('tok'); + expect(body.from).toBe('a@x.dev'); + expect(Array.isArray(body.recipients)).toBe(true); + expect(body.recipients as Array).toEqual(['b@y.dev']); + expect(typeof body.mimeBase64).toBe('string'); + expect((body.mimeBase64 as string).length).toBeGreaterThan(0); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.ids).toEqual([{ recipient: 'b@y.dev', messageId: 'cf-1' }]); + } + }); + + it('falls back to the locally-generated messageId when the proxy returns an empty one', async () => { + const { proxy } = fakeProxy(() => ({ + status: 200, + body: { ok: true, ids: [{ recipient: 'b@y.dev', messageId: '' }] }, + })); + const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + sendInDev: true, + }), + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.ids[0].messageId).toMatch(/^<.+@x\.dev>$/); + } + }); + + it('surfaces QUOTA_EXCEEDED when the proxy denies the send', async () => { + const { proxy } = fakeProxy(() => ({ + status: 429, + body: { ok: false, error: { code: 'QUOTA_EXCEEDED', message: 'monthly cap reached' } }, + })); + const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + sendInDev: true, + }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('QUOTA_EXCEEDED'); + expect(result.error.message).toContain('monthly cap reached'); + } + }); + + it('returns UPSTREAM_ERROR on proxy fetch failure', async () => { + const proxy = { + async fetch() { + throw new Error('network unreachable'); + }, + }; + const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + sendInDev: true, + }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('UPSTREAM_ERROR'); + } + }); + + it('passes mid-batch deliveries through unchanged', async () => { + const { proxy } = fakeProxy(() => ({ + status: 200, + body: { + ok: false, + deliveries: [ + { recipient: 'a@y.dev', ok: true, messageId: 'cf-a' }, + { + recipient: 'b@y.dev', + ok: false, + error: { code: 'UNVERIFIED_DESTINATION', message: 'b@y.dev not verified' }, + }, + { recipient: 'c@y.dev', ok: true, messageId: 'cf-c' }, + ], + }, + })); + const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ + from: 'a@x.dev', + to: ['a@y.dev', 'b@y.dev', 'c@y.dev'], + subject: 's', + text: 't', + sendInDev: true, + }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'deliveries' in result) { + expect(result.deliveries).toHaveLength(3); + const b = result.deliveries.find((d) => d.recipient === 'b@y.dev'); + expect(b?.ok).toBe(false); + if (b && !b.ok) { + expect(b.error.code).toBe('UNVERIFIED_DESTINATION'); + } + } + }); + + it('coerces unknown error codes to UPSTREAM_ERROR', async () => { + const { proxy } = fakeProxy(() => ({ + status: 500, + body: { ok: false, error: { code: 'WEIRD_NEW_CODE', message: 'something broke' } }, + })); + const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + sendInDev: true, + }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('UPSTREAM_ERROR'); + expect(result.error.message).toContain('something broke'); + } + }); + + it.each([ + ['null body', null], + ['empty object', {}], + ['ok:true with no ids', { ok: true }], + ['ok:true with non-array ids', { ok: true, ids: 'string' }], + ['ok:false with neither error nor deliveries', { ok: false }], + ['primitive number body', 42], + ['primitive array body', []], + ])('returns UPSTREAM_ERROR (no throw) for malformed proxy response: %s', async (_, payload) => { + const { proxy } = fakeProxy(() => ({ status: 200, body: payload })); + const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + sendInDev: true, + }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('UPSTREAM_ERROR'); + } + }); + + // The proxy's middleware (capability gate, auth, platform lifecycle) + // refuses ahead of the email handler with a flat `{ error, message? }` + // and a non-2xx status — a well-formed refusal whose text must reach the + // caller, not a "malformed response". + it.each([ + [ + 'a 501 from the platform capability gate', + 501, + { + error: 'platform capability unavailable', + capability: 'email', + message: 'This Void platform does not enable email.', + }, + 'This Void platform does not enable email.', + ], + [ + 'a 403 from the auth middleware (no message, falls back to error)', + 403, + { error: 'invalid project token' }, + 'invalid project token', + ], + ])('surfaces the proxy refusal text for %s', async (_, status, payload, expected) => { + const { proxy } = fakeProxy(() => ({ status, body: payload })); + const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + sendInDev: true, + }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('UPSTREAM_ERROR'); + expect(result.error.message).toBe(`email: proxy returned ${status}: ${expected}`); + expect(result.error.cause).toEqual(payload); + } + }); + + it('still reports a flat error body on a 2xx as malformed', async () => { + const { proxy } = fakeProxy(() => ({ status: 200, body: { error: 'nope' } })); + const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + sendInDev: true, + }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('UPSTREAM_ERROR'); + expect(result.error.message).toBe('email: proxy returned malformed response (status 200)'); + } + }); + + it('returns UPSTREAM_ERROR when proxy responds with non-JSON', async () => { + const proxy = { + async fetch() { + return new Response('not json', { + status: 502, + headers: { 'content-type': 'text/plain' }, + }); + }, + }; + const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + sendInDev: true, + }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('UPSTREAM_ERROR'); + } + }); + + it('dedupes recipients across to/cc/bcc and sends one envelope per unique address', async () => { + const { calls, proxy } = fakeProxy((body) => { + const recipients = (body as { recipients: Array }).recipients; + return { + status: 200, + body: { + ok: true, + ids: recipients.map((r) => ({ recipient: r, messageId: `cf-${r}` })), + }, + }; + }); + const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + cc: 'B@Y.DEV', + bcc: ['b@y.dev', 'c@y.dev'], + subject: 's', + text: 't', + sendInDev: true, + }), + ); + const recipients = (calls[0].body as { recipients: Array }).recipients; + expect(recipients).toEqual(['b@y.dev', 'c@y.dev']); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.ids).toHaveLength(2); + } + }); +}); + +// The proxy answers `ok: true` only after every requested recipient was +// accepted by the allowlist AND confirmed by `SEND_EMAIL.send()`, and its +// `ids` is `deliveries.map(({ recipient, messageId }))` — one entry per +// requested address, `messageId` always a string (`''` when CF returned +// none). So on `ok: true` the list is the request's recipient set, and a +// list that does not account for every recipient is a schema-skewed or +// corrupt proxy: dropping the bad entry and still returning `ok: true` +// would tell the caller a delivery was confirmed when none was. +describe('sendEmail (proxy delivery list)', () => { + const send = (proxy: ReturnType['proxy'], to: string | Array) => + withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ from: 'a@x.dev', to, subject: 's', text: 't', sendInDev: true }), + ); + + it.each([ + [ + 'a null entry', + 'b@y.dev', + { ok: true, ids: [null] }, + 'entry 0 is not { recipient: string, messageId: string }', + ], + [ + 'a missing messageId', + 'b@y.dev', + { ok: true, ids: [{ recipient: 'b@y.dev' }] }, + 'entry 0 is not { recipient: string, messageId: string }', + ], + [ + 'a non-string recipient', + 'b@y.dev', + { ok: true, ids: [{ recipient: 42, messageId: 'cf-1' }] }, + 'entry 0 is not { recipient: string, messageId: string }', + ], + [ + 'an empty list for a two-recipient send', + ['b@y.dev', 'c@y.dev'], + { ok: true, ids: [] }, + 'expected 2 deliveries, got 0', + ], + [ + 'one entry short of the request', + ['b@y.dev', 'c@y.dev'], + { ok: true, ids: [{ recipient: 'b@y.dev', messageId: 'cf-1' }] }, + 'expected 2 deliveries, got 1', + ], + [ + 'a duplicated recipient', + ['b@y.dev', 'c@y.dev'], + { + ok: true, + ids: [ + { recipient: 'b@y.dev', messageId: 'cf-1' }, + { recipient: 'b@y.dev', messageId: 'cf-2' }, + ], + }, + 'entry 1 repeats a recipient', + ], + [ + 'a recipient that was not requested', + 'b@y.dev', + { ok: true, ids: [{ recipient: 'z@y.dev', messageId: 'cf-1' }] }, + 'entry 0 names a recipient that was not requested', + ], + [ + 'a recipient in the wrong case', + 'b@y.dev', + { ok: true, ids: [{ recipient: 'B@y.dev', messageId: 'cf-1' }] }, + 'entry 0 names a recipient that was not requested', + ], + ])( + 'returns UPSTREAM_ERROR for an ok:true response carrying %s', + async (_, to, payload, reason) => { + const { proxy } = fakeProxy(() => ({ status: 200, body: payload })); + const result = await send(proxy, to); + expect(result).toEqual({ + ok: false, + error: { + code: 'UPSTREAM_ERROR', + message: `email: proxy returned a malformed delivery list (${reason})`, + cause: payload, + }, + }); + }, + ); + + it('passes a complete list through verbatim, in the order the proxy sent it', async () => { + const { calls, proxy } = fakeProxy(() => ({ + status: 200, + body: { + ok: true, + ids: [ + { recipient: 'c@y.dev', messageId: 'cf-c' }, + { recipient: 'b@y.dev', messageId: '' }, + ], + }, + })); + const result = await send(proxy, ['b@y.dev', 'c@y.dev']); + expect((calls[0].body as { recipients: Array }).recipients).toEqual([ + 'b@y.dev', + 'c@y.dev', + ]); + expect(result.ok).toBe(true); + if (result.ok) { + // Order-insensitive: the proxy's order is kept, not re-sorted. The + // empty messageId is the proxy's "CF returned none" convention and + // still falls back to the locally generated Message-ID. + expect(result.ids.map((d) => d.recipient)).toEqual(['c@y.dev', 'b@y.dev']); + expect(result.ids[0].messageId).toBe('cf-c'); + expect(result.ids[1].messageId).toMatch(/^<.+@x\.dev>$/); + } + }); +}); + +// The mid-batch `deliveries` list is built from the same per-recipient list +// as `ids`: one entry per requested address, each either +// `{ recipient, ok: true, messageId }` or `{ recipient, ok: false, error }` +// (the proxy always attaches `error` to a failed entry — both its producer +// paths do). The documented retry pattern acts on exactly the `ok: false` +// entries, so a list that skips, repeats or invents a recipient, or an +// entry that does not state its outcome, cannot be acted on: skipping the +// bad entry would leave a recipient with no outcome and no retry, and +// treating an entry with no `ok` as failed would retry a recipient that may +// have been delivered. Such a response is a schema-skewed or corrupt proxy +// and is refused as a whole, under the same rules as the success path. +describe('sendEmail (proxy mid-batch delivery list)', () => { + const send = (proxy: ReturnType['proxy'], to: string | Array) => + withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ from: 'a@x.dev', to, subject: 's', text: 't', sendInDev: true }), + ); + const failed = (recipient: string) => ({ + recipient, + ok: false, + error: { code: 'UNVERIFIED_DESTINATION', message: `${recipient} not verified` }, + }); + const SHAPE = + '{ recipient: string, ok: true, messageId: string } | { recipient: string, ok: false, error: { message: string } }'; + + it.each([ + [ + 'a null entry standing in for one recipient', + ['a@y.dev', 'b@y.dev'], + { ok: false, deliveries: [null, failed('b@y.dev')] }, + `entry 0 is not ${SHAPE}`, + ], + [ + 'a string entry', + ['a@y.dev', 'b@y.dev'], + { ok: false, deliveries: [failed('a@y.dev'), 'string-not-object'] }, + `entry 1 is not ${SHAPE}`, + ], + [ + 'an empty list for a two-recipient send', + ['a@y.dev', 'b@y.dev'], + { ok: false, deliveries: [] }, + 'expected 2 deliveries, got 0', + ], + [ + 'one entry short of the request', + ['a@y.dev', 'b@y.dev'], + { ok: false, deliveries: [failed('b@y.dev')] }, + 'expected 2 deliveries, got 1', + ], + [ + 'more entries than the request', + ['a@y.dev', 'b@y.dev'], + { + ok: false, + deliveries: [ + { recipient: 'a@y.dev', ok: true, messageId: 'cf-a' }, + null, + { recipient: 'b@y.dev', ok: false, error: null }, + 'string-not-object', + { ok: true, messageId: 'cf-x' }, + ], + }, + 'expected 2 deliveries, got 5', + ], + [ + 'a duplicated recipient', + ['a@y.dev', 'b@y.dev'], + { + ok: false, + deliveries: [failed('b@y.dev'), { recipient: 'b@y.dev', ok: true, messageId: 'cf-b' }], + }, + 'entry 1 repeats a recipient', + ], + [ + 'a recipient that was not requested', + ['a@y.dev', 'b@y.dev'], + { + ok: false, + deliveries: [failed('z@y.dev'), { recipient: 'b@y.dev', ok: true, messageId: 'cf-b' }], + }, + 'entry 0 names a recipient that was not requested', + ], + [ + 'a recipient in the wrong case', + ['a@y.dev', 'b@y.dev'], + { + ok: false, + deliveries: [failed('A@y.dev'), { recipient: 'b@y.dev', ok: true, messageId: 'cf-b' }], + }, + 'entry 0 names a recipient that was not requested', + ], + [ + 'an entry with no recipient', + ['a@y.dev', 'b@y.dev'], + { ok: false, deliveries: [{ ok: true, messageId: 'cf-a' }, failed('b@y.dev')] }, + `entry 0 is not ${SHAPE}`, + ], + [ + 'an entry with no ok', + ['a@y.dev', 'b@y.dev'], + { ok: false, deliveries: [{ recipient: 'a@y.dev', messageId: 'cf-a' }, failed('b@y.dev')] }, + `entry 0 is not ${SHAPE}`, + ], + [ + 'an entry whose ok is the string "true"', + ['a@y.dev', 'b@y.dev'], + { + ok: false, + deliveries: [{ recipient: 'a@y.dev', ok: 'true', messageId: 'cf-a' }, failed('b@y.dev')], + }, + `entry 0 is not ${SHAPE}`, + ], + [ + 'an ok:true entry with no messageId', + ['a@y.dev', 'b@y.dev'], + { ok: false, deliveries: [{ recipient: 'a@y.dev', ok: true }, failed('b@y.dev')] }, + `entry 0 is not ${SHAPE}`, + ], + [ + 'an ok:true entry with a non-string messageId', + ['a@y.dev', 'b@y.dev'], + { + ok: false, + deliveries: [{ recipient: 'a@y.dev', ok: true, messageId: 42 }, failed('b@y.dev')], + }, + `entry 0 is not ${SHAPE}`, + ], + [ + 'an ok:false entry with no error', + ['a@y.dev', 'b@y.dev'], + { + ok: false, + deliveries: [ + { recipient: 'a@y.dev', ok: true, messageId: 'cf-a' }, + { recipient: 'b@y.dev', ok: false }, + ], + }, + `entry 1 is not ${SHAPE}`, + ], + [ + 'an ok:false entry with a null error', + ['a@y.dev', 'b@y.dev'], + { + ok: false, + deliveries: [ + { recipient: 'a@y.dev', ok: true, messageId: 'cf-a' }, + { recipient: 'b@y.dev', ok: false, error: null }, + ], + }, + `entry 1 is not ${SHAPE}`, + ], + [ + 'an ok:false entry whose error has no string message', + ['a@y.dev', 'b@y.dev'], + { + ok: false, + deliveries: [ + { recipient: 'a@y.dev', ok: true, messageId: 'cf-a' }, + { recipient: 'b@y.dev', ok: false, error: { code: 'UPSTREAM_ERROR', message: 7 } }, + ], + }, + `entry 1 is not ${SHAPE}`, + ], + ])( + 'returns a top-level UPSTREAM_ERROR for a mid-batch response carrying %s', + async (_, to, payload, reason) => { + const { proxy } = fakeProxy(() => ({ status: 200, body: payload })); + const result = await send(proxy, to); + expect(result).toEqual({ + ok: false, + error: { + code: 'UPSTREAM_ERROR', + message: `email: proxy returned a malformed delivery list (${reason})`, + cause: payload, + }, + }); + }, + ); + + it('passes a complete list through in the order the proxy sent it', async () => { + // The proxy lists allowlist rejections before the sends it attempted, + // so the order differs from the request's; it is kept, not re-sorted. + // An unknown error code is coerced; the empty messageId is the proxy's + // "CF returned none" convention and falls back to the locally generated + // Message-ID, as on the success path. + const { calls, proxy } = fakeProxy(() => ({ + status: 200, + body: { + ok: false, + deliveries: [ + { recipient: 'c@y.dev', ok: false, error: { code: 'WEIRD_NEW_CODE', message: 'nope' } }, + failed('b@y.dev'), + { recipient: 'a@y.dev', ok: true, messageId: '' }, + ], + }, + })); + const result = await send(proxy, ['a@y.dev', 'b@y.dev', 'c@y.dev']); + expect((calls[0].body as { recipients: Array }).recipients).toEqual([ + 'a@y.dev', + 'b@y.dev', + 'c@y.dev', + ]); + expect(result.ok).toBe(false); + if (!result.ok && 'deliveries' in result) { + expect(result.deliveries.map((d) => d.recipient)).toEqual(['c@y.dev', 'b@y.dev', 'a@y.dev']); + expect(result.deliveries[0]).toEqual({ + recipient: 'c@y.dev', + ok: false, + error: { code: 'UPSTREAM_ERROR', message: 'nope' }, + }); + expect(result.deliveries[1]).toEqual({ + recipient: 'b@y.dev', + ok: false, + error: { code: 'UNVERIFIED_DESTINATION', message: 'b@y.dev not verified' }, + }); + const a = result.deliveries[2]; + expect(a.ok).toBe(true); + if (a.ok) { + expect(a.messageId).toMatch(/^<.+@x\.dev>$/); + } + } else { + throw new Error('expected a mid-batch result'); + } + }); + + it('does not refuse a mid-batch list in which every entry succeeded', async () => { + // Contradicts `anyFailed`, but every recipient has a trustworthy + // outcome and the retry filter selects nothing. Refusing it would make + // the caller retry the whole call and deliver duplicates. + const { proxy } = fakeProxy(() => ({ + status: 200, + body: { + ok: false, + deliveries: [ + { recipient: 'a@y.dev', ok: true, messageId: 'cf-a' }, + { recipient: 'b@y.dev', ok: true, messageId: 'cf-b' }, + ], + }, + })); + const result = await send(proxy, ['a@y.dev', 'b@y.dev']); + expect(result).toEqual({ + ok: false, + deliveries: [ + { recipient: 'a@y.dev', ok: true, messageId: 'cf-a' }, + { recipient: 'b@y.dev', ok: true, messageId: 'cf-b' }, + ], + }); + }); +}); + +describe('sendEmail (sendInDev escape hatch)', () => { + it('bypasses the harness and calls the proxy', async () => { + const harness = createEmailTestHarness(); + try { + const { proxy, calls } = fakeProxy(() => ({ + status: 200, + body: { ok: true, ids: [{ recipient: 'b@y.dev', messageId: 'cf-id-123' }] }, + })); + const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + sendInDev: true, + }), + ); + expect(harness.messages).toHaveLength(0); + expect(calls).toHaveLength(1); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.ids[0].messageId).toBe('cf-id-123'); + } + } finally { + harness.dispose(); + } + }); + + // The test above hand-injects `__VOID_PROXY`, which `void dev` never + // produces: the platform adds that service binding only on a deployed + // worker, and the own-account `SEND_EMAIL` binding is deleted from the + // resolved worker config whenever `viteCommand === 'serve'`. So under a + // real dev server `sendInDev: true` bypasses the inbox and lands on + // nothing. Pin that, message text included, so re-adding a send binding + // to dev has to come past this test. + it('returns BINDING_MISSING under a real `void dev` env (no transport to fall through to)', async () => { + const assets = { + calls: 0, + async fetch() { + this.calls += 1; + return new Response('unreachable', { status: 500 }); + }, + }; + const result = await withRuntimeEnv({ [DEV_INBOX_ENV_VAR]: 'ASSETS', ASSETS: assets }, () => + sendEmail({ + from: 'a@x.dev', + to: 'b@y.dev', + subject: 's', + text: 't', + sendInDev: true, + }), + ); + // The inbox was bypassed: nothing was posted to the dev server, and + // nothing was buffered. + expect(assets.calls).toBe(0); + expect(listDevMessages()).toHaveLength(0); + expect(result.ok).toBe(false); + if (!result.ok && 'error' in result) { + expect(result.error.code).toBe('BINDING_MISSING'); + expect(result.error.message).toContain('`sendInDev: true`, which does not deliver'); + expect(result.error.message).toContain('`void dev` binds no send transport at all'); + } + }); +}); diff --git a/packages/void/test/unit/env-validation-selfhost.test.ts b/packages/void/test/unit/env-validation-selfhost.test.ts index 1dbd2e661..24507d127 100644 --- a/packages/void/test/unit/env-validation-selfhost.test.ts +++ b/packages/void/test/unit/env-validation-selfhost.test.ts @@ -753,7 +753,7 @@ describe('dischargeSelfHostEnvContext — ground-truth secret gate', () => { // --- first-deploy (worker-not-found) surfaces a structured, actionable error --- describe('first-deploy worker-not-found', () => { - // The EXACT UserError wrangler 4.107 emits from `secret list` when the worker + // The EXACT UserError wrangler 4.131 emits from `secret list` when the worker // does not exist yet — the full fixed block rethrown after isWorkerNotFoundError. const WORKER_NOT_FOUND_STDERR = 'Worker "app" not found.\n\n' + diff --git a/packages/void/test/unit/inference.test.ts b/packages/void/test/unit/inference.test.ts index 52c70a950..847aea570 100644 --- a/packages/void/test/unit/inference.test.ts +++ b/packages/void/test/unit/inference.test.ts @@ -1,3 +1,4 @@ +import { parse } from 'es-module-lexer/js'; import { describe, expect, it } from 'vite-plus/test'; import { inferBindingsFromSource, mergeBindings } from '../../src/inference/walk.ts'; @@ -312,6 +313,99 @@ describe('inferBindingsFromSource', () => { expect(result.needsAI).toBe(true); }); + it("detects import { sendEmail } from 'void/email'", () => { + const code = ` + import { sendEmail } from "void/email"; + export const POST = defineHandler(async () => { + await sendEmail({ from: "a@x.dev", to: "b@y.dev", subject: "hi", text: "hi" }); + }) + `; + const result = inferBindingsFromSource(code, 'test.ts'); + expect(result.needsEmail).toBe(true); + }); + + it('does NOT detect c.env.SEND_EMAIL access (binding lives on the proxy, not the user worker)', () => { + // SEND_EMAIL is intentionally absent from the env-binding regex — + // outbound email routes through __VOID_PROXY/email/send, so a user + // worker accessing `env.SEND_EMAIL` will get undefined regardless of + // whether we'd inferred the import. Inference is now solely driven + // by `void/email` import-source detection. + const code = ` + export const POST = defineHandler(async (c) => { + return c.env.SEND_EMAIL.send(message); + }) + `; + const result = inferBindingsFromSource(code, 'test.ts'); + expect(result.needsEmail).toBe(false); + }); + + // A type-only import arms nothing at runtime, but `needsEmail` is ORed with + // `hasEmailHandlers` in `cli/deploy.ts` to gate the email deploy step: an app + // that only imports `SendEmailOptions` would warn with no `email.from`, and + // exit 1 under `--require-email`, having never sent an email. + it("does not detect email from type-only 'void/email' imports", () => { + const code = ` + import type { SendEmailOptions } from "void/email"; + export type Options = SendEmailOptions; + `; + const result = inferBindingsFromSource(code, 'types.ts'); + expect(result.needsEmail).toBe(false); + }); + + it("does not detect email from inline type-only 'void/email' imports", () => { + const code = ` + import { type SendEmailOptions, type SendEmailResult } from "void/email"; + export type Options = SendEmailOptions; + export type Result = SendEmailResult; + `; + const result = inferBindingsFromSource(code, 'types.ts'); + expect(result.needsEmail).toBe(false); + }); + + it("detects email from mixed inline type and value 'void/email' imports", () => { + const code = ` + import { type SendEmailOptions, sendEmail } from "void/email"; + export const send = (o: SendEmailOptions) => sendEmail(o); + `; + const result = inferBindingsFromSource(code, 'send.ts'); + expect(result.needsEmail).toBe(true); + }); + + // TSX is exactly where a component importing a type lands, and it is served + // by the regex fallback rather than the lexer — so the guard has to exist on + // BOTH paths. The `parse` assertion pins that this fixture really does take + // the fallback; if es-module-lexer ever learns JSX, this test would silently + // stop covering it. + it("does not detect email from a type-only 'void/email' import in .tsx", () => { + const code = ` + import type { SendEmailOptions } from "void/email"; + export const Compose = (props: { options: SendEmailOptions }) => ( +
+ +
+ ); + `; + expect(() => parse(code)).toThrow(); + const result = inferBindingsFromSource(code, 'Compose.tsx'); + expect(result.needsEmail).toBe(false); + }); + + it("still detects a runtime 'void/email' import in .tsx", () => { + const code = ` + import { sendEmail } from "void/email"; + export const Compose = (props: { subject: string }) => ( +
+ +
+ ); + `; + expect(() => parse(code)).toThrow(); + const result = inferBindingsFromSource(code, 'Compose.tsx'); + expect(result.needsEmail).toBe(true); + }); + it('does not detect needsAI from unrelated identifiers', () => { const code = ` const AI = someLibrary; diff --git a/packages/void/test/unit/plugin-email-config.test.ts b/packages/void/test/unit/plugin-email-config.test.ts new file mode 100644 index 000000000..4d96db2d5 --- /dev/null +++ b/packages/void/test/unit/plugin-email-config.test.ts @@ -0,0 +1,355 @@ +import { + chmodSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathslash'; +import type { Plugin } from 'vite'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; + +// Runs the REAL `mergeBindings` closure: `voidPlugin()` hands it to the +// Cloudflare plugin as `config`, and the mock captures it. The Cloudflare +// plugin then `defu`-merges the returned object over the `resolved` config it +// passed in, so a key can only be REMOVED by mutating `resolved` — which is +// what the `send_email` assertions below check on both objects. +type MockCloudflarePluginConfig = { + config: (resolved: Record) => Record; +}; + +type MockCloudflarePlugin = Plugin & { + api: { + pluginConfig: MockCloudflarePluginConfig; + }; +}; + +vi.mock('@cloudflare/vite-plugin', () => ({ + cloudflare(pluginConfig: MockCloudflarePluginConfig): Array { + return [ + { + name: 'mock:cloudflare', + api: { pluginConfig }, + }, + ]; + }, +})); + +const tempDirs: Array = []; + +function createProjectRoot( + voidConfig: Record = {}, + { usesVoidEmail = true }: { usesVoidEmail?: boolean } = {}, +): string { + const root = mkdtempSync(join(tmpdir(), 'void-email-config-')); + tempDirs.push(root); + writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' })); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ worker: { compatibility_date: '2026-02-24' }, ...voidConfig }), + ); + mkdirSync(join(root, 'routes/api'), { recursive: true }); + if (usesVoidEmail) { + // An app that actually sends mail, so `needsEmail` is inferred the way it + // would be for a real project rather than forced through config. + writeFileSync( + join(root, 'routes/api/notify.ts'), + ` +import { sendEmail } from "void/email"; + +export async function POST() { + await sendEmail({ to: "a@example.com", subject: "hi", text: "hi" }); + return Response.json({}); +} +`, + ); + } else { + // An app that never imports `void/email` and drives Cloudflare's email + // API through its own binding, so `needsEmail` is inferred false. + writeFileSync( + join(root, 'routes/api/notify.ts'), + ` +import { env } from "cloudflare:workers"; + +export async function POST() { + await env.MAILER.send({}); + return Response.json({}); +} +`, + ); + } + return root; +} + +function getHookHandler(hook: T | { handler: T } | undefined): T { + if (typeof hook === 'function') { + return hook; + } + if (hook && typeof hook === 'object' && 'handler' in hook) { + return hook.handler; + } + throw new Error('Expected Vite hook handler.'); +} + +async function resolveCloudflareConfig( + root: string, + resolved: Record, + configEnv: import('vite').ConfigEnv, +): Promise> { + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const { voidPlugin } = await import('../../src/index.ts'); + const plugins = voidPlugin(); + + const envPlugin = plugins.find((plugin) => plugin.name === 'void:env'); + const config = getHookHandler(envPlugin?.config) as ( + config: import('vite').UserConfig, + env: import('vite').ConfigEnv, + ) => void; + config({}, configEnv); + + const cloudflarePlugin = plugins.find( + (plugin): plugin is MockCloudflarePlugin => plugin.name === 'mock:cloudflare', + ); + if (!cloudflarePlugin) { + throw new Error('Expected mocked Cloudflare plugin.'); + } + + return cloudflarePlugin.api.pluginConfig.config(resolved); +} + +const SERVE: import('vite').ConfigEnv = { command: 'serve', mode: 'development' }; +const BUILD: import('vite').ConfigEnv = { command: 'build', mode: 'production' }; + +/** A root wrangler.jsonc as `void deploy --platform cloudflare` leaves it. */ +function provisionedRootConfig(): Record { + return { + assets: { binding: 'ASSETS' }, + send_email: [{ name: 'SEND_EMAIL' }], + vars: { __VOID_EMAIL_FROM: 'Acme ' }, + }; +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe('mergeBindings email config (void dev)', () => { + it('strips send_email under serve so the dev inbox is the only sink', async () => { + const root = createProjectRoot({ email: { from: 'Acme ' } }); + const resolved = provisionedRootConfig(); + + const result = await resolveCloudflareConfig(root, resolved, SERVE); + + // Removed from the object the Cloudflare plugin merges over, and not + // re-added by the returned patch. + expect('send_email' in resolved).toBe(false); + expect('send_email' in result).toBe(false); + }); + + it('injects __VOID_EMAIL_FROM from void.json email.from under serve', async () => { + const root = createProjectRoot({ email: { from: 'Acme ' } }); + + const result = await resolveCloudflareConfig(root, { assets: { binding: 'ASSETS' } }, SERVE); + + expect((result.vars as Record).__VOID_EMAIL_FROM).toBe( + 'Acme ', + ); + }); + + it('does not invent a sender when email.from is unset', async () => { + const root = createProjectRoot(); + + const result = await resolveCloudflareConfig(root, { assets: { binding: 'ASSETS' } }, SERVE); + + expect((result.vars as Record | undefined)?.__VOID_EMAIL_FROM).toBeUndefined(); + }); + + it('strips send_email under serve even when email.from is unset', async () => { + const root = createProjectRoot(); + const resolved = { send_email: [{ name: 'SEND_EMAIL' }] }; + + const result = await resolveCloudflareConfig(root, resolved, SERVE); + + expect('send_email' in resolved).toBe(false); + expect('send_email' in result).toBe(false); + }); + + it('preserves a user send_email binding when the app does not import void/email', async () => { + // Nothing in this app goes through `sendEmail`, so the strip has nothing + // to guard: the user's own binding must reach miniflare exactly as + // wrangler.jsonc declares it, or `env.MAILER.send()` is a dev-only crash. + const root = createProjectRoot({}, { usesVoidEmail: false }); + const resolved = { + assets: { binding: 'ASSETS' }, + send_email: [{ name: 'MAILER', destination_address: 'ops@example.com' }], + }; + + const result = await resolveCloudflareConfig(root, resolved, SERVE); + + expect(resolved.send_email).toEqual([ + { name: 'MAILER', destination_address: 'ops@example.com' }, + ]); + // Not re-added by the patch either: defu concatenates arrays, so a patch + // entry would double the binding rather than replace it. + expect('send_email' in result).toBe(false); + }); + + it('leaves a SEND_EMAIL entry alone when the app does not import void/email', async () => { + // The same `needsEmail` that arms the dev inbox gates the strip. Without + // `void/email` in the app, `sendEmail` never runs, so a `SEND_EMAIL` entry + // is either a stale leftover nothing reads or the user's own choice of + // name — both belong to the root config, as `void deploy` also treats them. + const root = createProjectRoot({}, { usesVoidEmail: false }); + const resolved = { send_email: [{ name: 'SEND_EMAIL' }] }; + + const result = await resolveCloudflareConfig(root, resolved, SERVE); + + expect(resolved.send_email).toEqual([{ name: 'SEND_EMAIL' }]); + expect('send_email' in result).toBe(false); + }); + + it('strips only the SEND_EMAIL entry when the app also carries its own binding', async () => { + const root = createProjectRoot({ email: { from: 'Acme ' } }); + const resolved = { + ...provisionedRootConfig(), + send_email: [{ name: 'MAILER' }, { name: 'SEND_EMAIL' }, { name: 'ALERTS' }], + }; + + const result = await resolveCloudflareConfig(root, resolved, SERVE); + + expect(resolved.send_email).toEqual([{ name: 'MAILER' }, { name: 'ALERTS' }]); + expect('send_email' in result).toBe(false); + }); +}); + +describe('mergeBindings dev inbox vars (void dev)', () => { + it('injects the inbox binding var and the dev-trigger token together under serve', async () => { + const root = createProjectRoot(); + + const result = await resolveCloudflareConfig(root, { assets: { binding: 'ASSETS' } }, SERVE); + + const vars = result.vars as Record; + expect(vars.__VOID_EMAIL_DEV_INBOX).toBe('ASSETS'); + // The same per-project token the cron/queue dev routes check: the inbox + // middleware requires it on every route, including the worker's capture + // POST, so the worker must hold the value the plugin was handed. + const persisted = readFileSync(join(root, '.void', 'dev-trigger-token'), 'utf8').trim(); + expect(persisted.length).toBeGreaterThan(0); + expect(vars.__VOID_EMAIL_DEV_INBOX_TOKEN).toBe(persisted); + }); + + it('injects neither var when the app does not import void/email', async () => { + const root = createProjectRoot({}, { usesVoidEmail: false }); + + const result = await resolveCloudflareConfig(root, { assets: { binding: 'ASSETS' } }, SERVE); + + const vars = result.vars as Record | undefined; + expect(vars?.__VOID_EMAIL_DEV_INBOX).toBeUndefined(); + expect(vars?.__VOID_EMAIL_DEV_INBOX_TOKEN).toBeUndefined(); + }); + + it('injects neither var on build — a production wrangler.json carries no dev token', async () => { + const root = createProjectRoot(); + + const result = await resolveCloudflareConfig(root, { assets: { binding: 'ASSETS' } }, BUILD); + + const vars = result.vars as Record | undefined; + expect(vars?.__VOID_EMAIL_DEV_INBOX).toBeUndefined(); + expect(vars?.__VOID_EMAIL_DEV_INBOX_TOKEN).toBeUndefined(); + }); +}); + +// The token file is the one credential every inbox route accepts, so its mode +// is part of the inbox's authorization: under the usual 022 umask a default +// `openSync(.., 'wx')` leaves it 0644, readable by every other account on a +// shared host — and loopback needs no `--host`, so that account can read raw +// MIME, clear the buffer, or plant captures against the owner's dev server. +// Windows has no group/other bits (`mode` and `chmod` only toggle write +// there), so the mode assertions are POSIX-only. +describe('dev-trigger token file mode (inbox credential)', () => { + const tokenPath = (root: string) => join(root, '.void', 'dev-trigger-token'); + const modeOf = (path: string) => statSync(path).mode & 0o777; + + it.skipIf(process.platform === 'win32')( + 'creates a fresh token file owner-only (0600)', + async () => { + const root = createProjectRoot(); + + const result = await resolveCloudflareConfig(root, { assets: { binding: 'ASSETS' } }, SERVE); + + expect(modeOf(tokenPath(root))).toBe(0o600); + const vars = result.vars as Record; + expect(vars.__VOID_EMAIL_DEV_INBOX_TOKEN).toBe(readFileSync(tokenPath(root), 'utf8').trim()); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'tightens a pre-existing 0644 token file to 0600 and keeps its value', + async () => { + // A token persisted by an earlier void release, before the mode was set. + const root = createProjectRoot(); + mkdirSync(join(root, '.void'), { recursive: true }); + writeFileSync(tokenPath(root), 'stable-token\n'); + chmodSync(tokenPath(root), 0o644); + expect(modeOf(tokenPath(root))).toBe(0o644); + + const result = await resolveCloudflareConfig(root, { assets: { binding: 'ASSETS' } }, SERVE); + + expect(modeOf(tokenPath(root))).toBe(0o600); + // Tightening never rotates: saved curl scripts and the worker keep working. + expect(readFileSync(tokenPath(root), 'utf8')).toBe('stable-token\n'); + const vars = result.vars as Record; + expect(vars.__VOID_EMAIL_DEV_INBOX_TOKEN).toBe('stable-token'); + }, + ); + + it('leaves an already owner-only token file alone', async () => { + const root = createProjectRoot(); + mkdirSync(join(root, '.void'), { recursive: true }); + writeFileSync(tokenPath(root), 'stable-token\n', { mode: 0o600 }); + const before = statSync(tokenPath(root)); + + const result = await resolveCloudflareConfig(root, { assets: { binding: 'ASSETS' } }, SERVE); + + // Same inode, same mode, same ctime: no chmod was issued on a file that + // was already tight, so a read-only or foreign-owned token still loads. + const after = statSync(tokenPath(root)); + expect(after.mode).toBe(before.mode); + expect(after.ctimeMs).toBe(before.ctimeMs); + expect(after.ino).toBe(before.ino); + const vars = result.vars as Record; + expect(vars.__VOID_EMAIL_DEV_INBOX_TOKEN).toBe('stable-token'); + }); +}); + +describe('mergeBindings email config (build)', () => { + it('passes a root send_email binding through untouched', async () => { + const root = createProjectRoot({ email: { from: 'Acme ' } }); + const resolved = provisionedRootConfig(); + + const result = await resolveCloudflareConfig(root, resolved, BUILD); + + // The root entry survives on `resolved` and the patch does not clobber it + // (defu concatenates arrays, so a patch entry would double the binding). + expect(resolved.send_email).toEqual([{ name: 'SEND_EMAIL' }]); + expect('send_email' in result).toBe(false); + }); + + it('leaves __VOID_EMAIL_FROM to the root wrangler config on build', async () => { + const root = createProjectRoot({ email: { from: 'Acme ' } }); + + const result = await resolveCloudflareConfig(root, { assets: { binding: 'ASSETS' } }, BUILD); + + // Not injected by the plugin: the CLI writes it into the root wrangler.jsonc + // only once the domain is set up, and the managed deploy supplies its own. + expect((result.vars as Record | undefined)?.__VOID_EMAIL_FROM).toBeUndefined(); + }); +}); diff --git a/packages/void/test/unit/plugin-framework-triggers.test.ts b/packages/void/test/unit/plugin-framework-triggers.test.ts index 5c0fe65bb..e1f779cb6 100644 --- a/packages/void/test/unit/plugin-framework-triggers.test.ts +++ b/packages/void/test/unit/plugin-framework-triggers.test.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'pathslash'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; @@ -84,6 +92,35 @@ describe('frameworkTriggersPlugin', () => { expect(generated).toContain('export async function handleDevTrigger'); }); + // The generated module embeds the dev trigger token, which is the whole + // credential of the `/__void/inbox` routes — so it gets the same + // owner-only mode as `.void/dev-trigger-token`. Windows has no + // group/other bits, so there is nothing to assert there. + it.skipIf(process.platform === 'win32')('writes the dispatch module owner-only (0600)', () => { + const root = createTempRoot(); + const plugin = frameworkTriggersPlugin(root, { devTriggerToken: 'dev-token-test' }); + callConfigResolved(plugin); + + expect(statSync(frameworkTriggersDispatchPath(root)).mode & 0o777).toBe(0o600); + }); + + it.skipIf(process.platform === 'win32')( + 'tightens a dispatch module left at 0644 by an earlier release', + () => { + const root = createTempRoot(); + const target = frameworkTriggersDispatchPath(root); + mkdirSync(join(root, '.void'), { recursive: true }); + writeFileSync(target, '// stale\n', { mode: 0o644 }); + expect(statSync(target).mode & 0o777).toBe(0o644); + + const plugin = frameworkTriggersPlugin(root, { devTriggerToken: 'dev-token-test' }); + callConfigResolved(plugin); + + expect(statSync(target).mode & 0o777).toBe(0o600); + expect(readFileSync(target, 'utf-8')).toContain(`__DEV_TRIGGER_TOKEN = "dev-token-test"`); + }, + ); + it('emits handleDevTrigger that returns null for non-trigger requests', () => { const root = createTempRoot(); mkdirSync(join(root, 'crons'), { recursive: true }); diff --git a/packages/void/test/unit/plugin-inference.test.ts b/packages/void/test/unit/plugin-inference.test.ts index 1025787e9..dab22865e 100644 --- a/packages/void/test/unit/plugin-inference.test.ts +++ b/packages/void/test/unit/plugin-inference.test.ts @@ -60,6 +60,7 @@ describe('inferProjectBindings', () => { needsQueues: false, needsAI: false, needsSandbox: false, + needsEmail: false, needsLive: false, }); }); @@ -675,6 +676,108 @@ describe('detectFramework', () => { } }); + // Inbound handlers compile through `routingPlugin`, which the framework + // branches never install. Without this guard the build succeeds and every + // handler in `email/` is dead code. + it('rejects an email/ handler directory in framework adapter mode', () => { + const root = createTempRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ dependencies: { '@sveltejs/kit': '^2.0.0' } }), + ); + mkdirSync(join(root, 'email'), { recursive: true }); + writeFileSync( + join(root, 'email', '_default.ts'), + ` + import { defineEmail } from "void/email"; + export default defineEmail(async () => {}); + `, + ); + + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(root); + + try { + expect(() => voidPlugin()).toThrow( + 'email: inbound email handlers are only supported in Void apps', + ); + } finally { + cwd.mockRestore(); + } + }); + + // `compileNodeApp` never imports `__emailHandler`, so on `node`/`bun`/`deno` + // an `email/` directory compiled cleanly into a bundle that could not + // receive mail. Same shape as the live / sandbox / durable-objects guards + // in the node-target branch. + for (const target of ['node', 'bun', 'deno'] as const) { + it(`rejects an email/ handler directory with target: "${target}"`, () => { + const root = createTempRoot(); + mkdirSync(join(root, 'routes'), { recursive: true }); + mkdirSync(join(root, 'email'), { recursive: true }); + writeFileSync(join(root, 'void.json'), JSON.stringify({ target })); + writeFileSync( + join(root, 'routes', 'index.ts'), + 'export const GET = () => new Response("ok");', + ); + writeFileSync( + join(root, 'email', '_default.ts'), + ` + import { defineEmail } from "void/email"; + export default defineEmail(async () => {}); + `, + ); + + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(root); + + try { + expect(() => voidPlugin()).toThrow( + `email: inbound email handlers (email/) require target: "cloudflare". They are not available with target: '${target}'.`, + ); + } finally { + cwd.mockRestore(); + } + }); + } + + it('still builds a non-Cloudflare target without an email/ directory', () => { + const root = createTempRoot(); + mkdirSync(join(root, 'routes'), { recursive: true }); + writeFileSync(join(root, 'void.json'), JSON.stringify({ target: 'node' })); + writeFileSync(join(root, 'routes', 'index.ts'), 'export const GET = () => new Response("ok");'); + + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(root); + + try { + expect(() => voidPlugin()).not.toThrow(); + } finally { + cwd.mockRestore(); + } + }); + + // Outbound is a binding, not a compiled export, so it stays supported. + it('allows sendEmail without an email/ directory in framework adapter mode', () => { + const root = createTempRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ dependencies: { '@sveltejs/kit': '^2.0.0' } }), + ); + writeFileSync( + join(root, 'src', 'mailer.ts'), + ` + import { sendEmail } from "void/email"; + export const send = () => sendEmail({ to: "a@b.dev", subject: "x", text: "y" }); + `, + ); + + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(root); + + try { + expect(() => voidPlugin()).not.toThrow(); + } finally { + cwd.mockRestore(); + } + }); + it('rejects void/live in framework adapter mode', () => { const root = createTempRoot(); writeFileSync( diff --git a/packages/void/test/unit/prerender.test.ts b/packages/void/test/unit/prerender.test.ts index bbbd2fae1..2e5db8534 100644 --- a/packages/void/test/unit/prerender.test.ts +++ b/packages/void/test/unit/prerender.test.ts @@ -1,9 +1,11 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, relative } from 'pathslash'; +import { dirname, join, relative } from 'pathslash'; import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { getMiniflareDatabasePath, openSqliteDatabase } from '../../src/migrations/runner.ts'; import { buildPrerenderBindings, + collectPrerenderPaths, collectPrerenderPathsNode, collectPrerenderWorkerModules, } from '../../src/prerender.ts'; @@ -161,6 +163,67 @@ export default { }); }); +describe('collectPrerenderPaths (Cloudflare target)', () => { + afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } + }); + + // The dev server (`@cloudflare/vite-plugin`, `persistState: { path: '.void' }` + // -> `/.void/v3`), the local migration runner, the drizzle plugin and the + // auth bootstrap all address the local D1 file through + // `getMiniflareDatabasePath(join(root, '.void'), id)`. Prerender boots the built + // worker in its own Miniflare and MUST open that same file, otherwise a page that + // reads a migrated table renders against an empty database. This test seeds a + // table exactly where the runner writes it and asks the real bundled Miniflare + // (resolved through wrangler like prerender.ts does) to read it back. + it('opens the D1 database the local migration runner wrote', async () => { + const root = createTempWorkerDir(); + const persistDir = join(root, '.void'); + const workerDir = join(root, 'dist', 'ssr'); + mkdirSync(workerDir, { recursive: true }); + + const dbPath = getMiniflareDatabasePath(persistDir, 'local'); + mkdirSync(dirname(dbPath), { recursive: true }); + const db = await openSqliteDatabase(dbPath); + try { + db.exec('CREATE TABLE pages (path TEXT NOT NULL)'); + db.prepare('INSERT INTO pages (path) VALUES (?)').run('/from-migrated-db'); + } finally { + db.close(); + } + + writeFileSync( + join(workerDir, 'wrangler.json'), + JSON.stringify({ + main: 'index.js', + compatibility_date: '2026-02-12', + d1_databases: [{ binding: 'DB', database_name: 'default', database_id: 'local' }], + }), + ); + writeFileSync( + join(workerDir, 'index.js'), + ` +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname !== '/__void/prerender-paths') { + return new Response('Not Found', { status: 404 }); + } + const { results } = await env.DB.prepare('SELECT path FROM pages ORDER BY path').all(); + return Response.json({ paths: results.map((row) => row.path) }); + }, +}; +`, + ); + + await expect(collectPrerenderPaths({ root, workerDir, persistDir })).resolves.toEqual([ + '/from-migrated-db', + ]); + }, 60_000); +}); + describe('buildPrerenderBindings', () => { it('omits __VOID_* bindings when the token is missing', () => { const result = buildPrerenderBindings({ diff --git a/packages/void/test/unit/preset.test.ts b/packages/void/test/unit/preset.test.ts index 85b429c0a..93813e432 100644 --- a/packages/void/test/unit/preset.test.ts +++ b/packages/void/test/unit/preset.test.ts @@ -303,7 +303,7 @@ describe('detectPreset — strictBackend', () => { describe('findBackendSources', () => { it('lists every backend source relative to the root', () => { const root = setupProject({ - dirs: ['routes', 'pages', 'middleware', 'crons', 'queues'], + dirs: ['routes', 'pages', 'middleware', 'crons', 'queues', 'email'], files: { 'src/main.ssr.tsx': 'export default {};' }, }); expect(findBackendSources(root)).toEqual([ @@ -312,10 +312,19 @@ describe('findBackendSources', () => { 'middleware/', 'crons/', 'queues/', + 'email/', 'src/main.ssr.tsx', ]); }); + // `email/` compiles to the worker's `email()` export. A static deploy uploads + // no worker, so leaving it out here drops every inbound handler silently. + it('counts email/ as a backend, and agrees with hasBackendFiles', () => { + const root = setupProject({ dirs: ['email'] }); + expect(findBackendSources(root)).toEqual(['email/']); + expect(hasBackendFiles(root)).toBe(true); + }); + it('is empty for a project with no backend, and agrees with hasBackendFiles', () => { const root = setupProject({ pkg: { devDependencies: { vitepress: '^1.0.0' } } }); expect(findBackendSources(root)).toEqual([]); @@ -410,7 +419,7 @@ describe('resolveWranglerArgv (module-resolution launcher)', () => { it("prefers void's own wrangler over an incompatible app-root wrangler, resolving its REAL cli.js", () => { // The app root pins its OWN (potentially older/incompatible) wrangler, and a // separate "void-nested" wrangler is resolvable via module resolution. The - // provisioner targets wrangler 4.107's exact flags/JSON/errors, so void's own + // provisioner targets wrangler 4.131's exact flags/JSON/errors, so void's own // wrangler MUST win — the app-root one must not shadow it. The resolved entry // is the `main` CLI (`wrangler-dist/cli.js`), NOT the `bin.wrangler` wrapper. const root = setupProject({ diff --git a/packages/void/test/unit/provision.cases.ts b/packages/void/test/unit/provision.cases.ts index 0d4955805..d37a1b447 100644 --- a/packages/void/test/unit/provision.cases.ts +++ b/packages/void/test/unit/provision.cases.ts @@ -1039,7 +1039,7 @@ describe('provisionCloudflareResources — R2', () => { }); it('does NOT create on an R2 info failure that is not a confirmed not-found (auth/metrics) — surfaces the error', async () => { - // wrangler 4.107 `r2 bucket info` also runs a metrics/GraphQL query, so an + // wrangler 4.131 `r2 bucket info` also runs a metrics/GraphQL query, so an // EXISTING bucket can exit non-zero on an auth/metrics failure. The prior // "auth error → create" behavior is REVERSED: never create on such a signal. setup(JSON.stringify({ name: 'app' })); @@ -2020,7 +2020,7 @@ describe('provisionCloudflareResources — pinned transport (one base for wrangl }); it('the lister URL uses the pinned base with wrangler-EXACT concatenation (no trailing-slash trim)', async () => { - // wrangler 4.107's `performApiFetchBase` fetches + // wrangler 4.131's `performApiFetchBase` fetches // `${getCloudflareApiBaseUrl(...)}${resource}${queryString}` with the direct env // base used VERBATIM and the resource `/accounts/${accountId}/hyperdrive/configs` // interpolated RAW. A base ending in `/` therefore yields `…//accounts/…` for @@ -3026,7 +3026,7 @@ describe('provisionCloudflareResources — strict D1 identity', () => { describe('provisionCloudflareResources — top-level env pin via --env=', () => { it('pins the top-level env on EVERY wrangler call even when CLOUDFLARE_ENV names a different env/account', async () => { - // wrangler 4.107 reloads a project `.env`/`.env.local` into process.env on every + // wrangler 4.131 reloads a project `.env`/`.env.local` into process.env on every // command, so a project `.env` with `CLOUDFLARE_ENV=staging` would reselect the // `env.staging` block (and ITS account) for discovery/create even though // `provisionEnv` deleted the var — while `--update-config` patches the TOP-LEVEL diff --git a/packages/void/test/unit/wrangler-pin.test.ts b/packages/void/test/unit/wrangler-pin.test.ts new file mode 100644 index 000000000..9d134545a --- /dev/null +++ b/packages/void/test/unit/wrangler-pin.test.ts @@ -0,0 +1,93 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'pathslash'; +import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; +import { WRANGLER_EMAIL_PROSE_VERSION } from '../../src/cli/email-cloudflare.ts'; +import { resolveWranglerArgv } from '../../src/cli/preset.ts'; + +const TMP = join(import.meta.dirname, '.tmp-wrangler-pin'); + +/** + * The oldest wrangler whose `wrangler deploy` reconciles Email Routing rules from a + * top-level `addresses` array (CHANGELOG 4.112.0 #14470 + 4.113.0 #14471). Older + * wranglers pass the array through as an unknown key and never touch a rule. + */ +const MIN_EMAIL_ROUTING_WRANGLER = [4, 113, 0] as const; + +function parseSemver(version: string): [number, number, number] { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version); + if (!match) { + throw new Error(`unparsable wrangler version ${JSON.stringify(version)}`); + } + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function compareSemver(a: ReadonlyArray, b: ReadonlyArray): number { + for (let i = 0; i < 3; i += 1) { + if (a[i] !== b[i]) { + return a[i] - b[i]; + } + } + return 0; +} + +// The pinned wrangler — the ONE the deploy runner spawns — is the exact package +// `resolveWranglerArgv` resolves relative to `preset.ts` (void's own dependency, never +// an app-root wrangler), so read the manifest and bundle from that resolved entry rather +// than from a second, possibly different, resolution. Same idea as the platform's +// `PINNED_VOID_CLI_VERSION` test: a "keep in sync" comment does not hold, an assertion does. +describe('pinned wrangler (email-feature capability contract)', () => { + let root: string; + let cliEntry: string; + let wranglerPkg: { version: string }; + + beforeEach(() => { + rmSync(TMP, { recursive: true, force: true }); + root = join(TMP, 'app'); + mkdirSync(root, { recursive: true }); + // An app root with NO wrangler of its own — strict pnpm — so only void's module + // resolution can satisfy the launcher. + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'app' })); + const argv = resolveWranglerArgv(root); + cliEntry = argv[argv.length - 1]; + wranglerPkg = JSON.parse( + readFileSync(join(dirname(dirname(cliEntry)), 'package.json'), 'utf-8'), + ) as { version: string }; + }); + + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('resolves the real wrangler-dist/cli.js of a wrangler >= 4.113.0', () => { + expect(cliEntry.replace(/\\/g, '/')).toMatch(/\/wrangler\/wrangler-dist\/cli\.js$/); + expect( + compareSemver(parseSemver(wranglerPkg.version), MIN_EMAIL_ROUTING_WRANGLER), + ).toBeGreaterThanOrEqual(0); + }); + + it('is the exact wrangler the email prose parsers are pinned to', () => { + // The `wrangler email` parsers read prose captured from ONE wrangler + // (`test/fixtures/wrangler-email//`); the fixtures are static files, so a catalog + // bump keeps every parser test green while the shipped prose may have moved. A parser miss + // fails closed to `unknown — run void email setup` on every deploy, with nothing pointing at + // the cause. When this fails: re-capture the fixtures for the new version (README there) and + // move `WRANGLER_EMAIL_PROSE_VERSION`. + expect(wranglerPkg.version).toBe(WRANGLER_EMAIL_PROSE_VERSION); + }); + + it('ships a `wrangler deploy` that applies top-level `addresses` (Email Routing plan)', () => { + // The deploy path prints this heading while reconciling rules from `addresses` + // (`applyEmailRoutingAddresses`); without it, a config carrying `addresses` deploys + // the worker and silently creates no routing rule. + const bundle = readFileSync(cliEntry, 'utf-8'); + expect(bundle).toContain('Email Routing plan:'); + }); + + it('ships `wrangler auth token` (the session-bearer hand-off for the two REST-only steps)', () => { + // `wrangler auth token --json` is the documented, stable way to borrow the login + // session's bearer for the subdomain-routing and subaddressing calls wrangler has no + // command for. It is registered as `authTokenCommand` in the bundle. + const bundle = readFileSync(cliEntry, 'utf-8'); + expect(bundle).toContain('authTokenCommand'); + }); +}); diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index c29bc9654..3e796852a 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -386,6 +386,33 @@ describe('syncWranglerBindings', () => { expect(created).toEqual({}); }); + it('does NOT add a send_email binding even when needsEmail is true', () => { + // Outbound email goes through __VOID_PROXY/email/send (the proxy + // worker holds the platform-level send_email binding). User workers + // never get one, so wrangler sync intentionally skips emission. + setup('wrangler.jsonc', '{ "name": "test" }'); + const result = syncWranglerBindings(tmpDir, { ...noBindings, needsEmail: true }); + expect(result).toBe(false); + const parsed = JSON.parse(readFileSync(join(tmpDir, 'wrangler.jsonc'), 'utf-8')); + expect(parsed.send_email).toBeUndefined(); + }); + + it('leaves a user-defined send_email block alone (does not strip it)', () => { + // If a user manually added one, that's harmless dead config they + // should remove — but the framework never rewrites it. + setup( + 'wrangler.jsonc', + JSON.stringify({ + name: 'test', + send_email: [{ name: 'CUSTOM_EMAIL', destination_address: 'me@x.dev' }], + }), + ); + const result = syncWranglerBindings(tmpDir, { ...noBindings, needsEmail: true }); + expect(result).toBe(false); + const parsed = JSON.parse(readFileSync(join(tmpDir, 'wrangler.jsonc'), 'utf-8')); + expect(parsed.send_email).toEqual([{ name: 'CUSTOM_EMAIL', destination_address: 'me@x.dev' }]); + }); + it('adds D1 binding when needsD1 is true', () => { setup('wrangler.jsonc', '{ "name": "test" }'); const result = syncWranglerBindings(tmpDir, { ...noBindings, needsD1: true }); @@ -1632,7 +1659,7 @@ describe('writeProvisionedWranglerConfig', () => { }); it('fails closed when an ARRAY-shaped `queues` (present) would hide a producer/D1 binding collision on adopt — throws without writing', () => { - // wrangler 4.107 REQUIRES `queues` to be an OBJECT `{ producers, consumers }`. + // wrangler 4.131 REQUIRES `queues` to be an OBJECT `{ producers, consumers }`. // A PRESENT array-shaped `queues` makes the old `isRecord(queues)` guard in // `addQueueProducerOwners` return early, so a producer-like `{ binding: "DB" }` // is NOT collected — and a D1 adopt of binding `DB` would then slip through the @@ -3071,6 +3098,354 @@ describe('writeProvisionedWranglerConfig', () => { }); }); +describe('writeProvisionedWranglerConfig — email (Mode B): sendEmail / addresses / vars', () => { + const readRaw = (f: string): string => readFileSync(join(tmpDir, f), 'utf-8'); + const parseRaw = (f: string): Record => + parseJsoncRaw(readRaw(f), undefined, { allowTrailingComma: true }) as Record; + + // Comments everywhere a jsonc edit could disturb them, and trailing commas at + // every level — the shape a hand-edited wrangler.jsonc really has. + const emailFixture = `{ + // Void manages this file — every comment below must survive. + "name": "acme-support", // worker name + "vars": { + "APP_ENV": "production", // user var + }, + /* Email Sending binding, hand-declared with a destination restriction. */ + "send_email": [ + { "name": "SEND_EMAIL", "destination_address": "ops@acme.com" }, // restricted + ], + "addresses": [ + "support@mail.acme.com", // routed here + ], + "kv_namespaces": [ + { "binding": "KV", "id": "abc" }, // cache + ], +}`; + + it('sendEmail: creates the send_email array with exactly one { name } entry when absent, keeping comments', () => { + setup( + 'wrangler.jsonc', + `{ + // header + "name": "acme-support", // worker name +}`, + ); + const result = writeProvisionedWranglerConfig(tmpDir, { sendEmail: { name: 'SEND_EMAIL' } }); + expect(result.modified).toBe(true); + const raw = readRaw('wrangler.jsonc'); + expect(raw).toContain('// header'); + expect(raw).toContain('// worker name'); + expect(parseRaw('wrangler.jsonc').send_email).toEqual([{ name: 'SEND_EMAIL' }]); + }); + + it('sendEmail: never duplicates a matching entry and leaves its user-owned fields alone (byte-identical)', () => { + setup('wrangler.jsonc', emailFixture); + const before = readRaw('wrangler.jsonc'); + const result = writeProvisionedWranglerConfig(tmpDir, { sendEmail: { name: 'SEND_EMAIL' } }); + expect(result.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(before); + expect(parseRaw('wrangler.jsonc').send_email).toEqual([ + { name: 'SEND_EMAIL', destination_address: 'ops@acme.com' }, + ]); + }); + + it('sendEmail: appends beside a differently-named entry without touching it, and is idempotent', () => { + setup( + 'wrangler.jsonc', + `{ + "send_email": [ + { "name": "NOTIFY", "destination_address": "ops@acme.com" }, // theirs + ], +}`, + ); + const patch = { sendEmail: { name: 'SEND_EMAIL' } }; + expect(writeProvisionedWranglerConfig(tmpDir, patch).modified).toBe(true); + const raw = readRaw('wrangler.jsonc'); + expect(raw).toContain('// theirs'); + expect(parseRaw('wrangler.jsonc').send_email).toEqual([ + { name: 'NOTIFY', destination_address: 'ops@acme.com' }, + { name: 'SEND_EMAIL' }, + ]); + expect(writeProvisionedWranglerConfig(tmpDir, patch).modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(raw); + }); + + it('sendEmail: fails closed when the name is already held by another binding type — no write', () => { + setup('wrangler.jsonc', `{ "kv_namespaces": [{ "binding": "SEND_EMAIL", "id": "abc" }] }`); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { sendEmail: { name: 'SEND_EMAIL' } }), + ).toThrow( + /cannot adopt "SEND_EMAIL" into "send_email".*already used by a KV Namespace binding/s, + ); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('sendEmail: fails closed when send_email already declares the name twice — no write', () => { + setup( + 'wrangler.jsonc', + `{ "send_email": [{ "name": "SEND_EMAIL" }, { "name": "SEND_EMAIL" }] }`, + ); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { sendEmail: { name: 'SEND_EMAIL' } }), + ).toThrow(/binding "SEND_EMAIL" is declared 2 times in "send_email"/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('addresses: sets the whole array (creating it), keeping every comment and trailing commas', () => { + setup( + 'wrangler.jsonc', + `{ + // header + "name": "acme-support", // worker name + "vars": { + "APP_ENV": "production", // user var + }, +}`, + ); + const result = writeProvisionedWranglerConfig(tmpDir, { + addresses: ['support@mail.acme.com', '*@acme.com'], + }); + expect(result.modified).toBe(true); + const raw = readRaw('wrangler.jsonc'); + expect(raw).toContain('// header'); + expect(raw).toContain('// worker name'); + expect(raw).toContain('// user var'); + const parsed = parseRaw('wrangler.jsonc'); + expect(parsed.addresses).toEqual(['support@mail.acme.com', '*@acme.com']); + expect(parsed.vars).toEqual({ APP_ENV: 'production' }); + }); + + it('addresses: replaces an existing array outright (Void owns it) and is byte-identical on re-run', () => { + setup( + 'wrangler.jsonc', + `{ + "name": "acme-support", // worker name + "addresses": ["old@mail.acme.com", "gone@mail.acme.com"], + "kv_namespaces": [ + { "binding": "KV", "id": "abc" }, // cache + ], +}`, + ); + const patch = { addresses: ['support@mail.acme.com'] }; + expect(writeProvisionedWranglerConfig(tmpDir, patch).modified).toBe(true); + const raw = readRaw('wrangler.jsonc'); + expect(raw).toContain('// worker name'); + expect(raw).toContain('// cache'); + expect(parseRaw('wrangler.jsonc').addresses).toEqual(['support@mail.acme.com']); + expect(writeProvisionedWranglerConfig(tmpDir, patch).modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(raw); + }); + + it('addresses: an equal array is a no-op even when it carries comments inside', () => { + setup('wrangler.jsonc', emailFixture); + const before = readRaw('wrangler.jsonc'); + const result = writeProvisionedWranglerConfig(tmpDir, { + addresses: ['support@mail.acme.com'], + }); + expect(result.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('addresses: a DIFFERENT array cannot be written over comments nested inside the old one — throws without writing', () => { + // Void owns the array, but the writer never drops a comment: the generic + // comment gate refuses instead of silently discarding `// routed here`. + setup('wrangler.jsonc', emailFixture); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { addresses: ['sales@mail.acme.com'] }), + ).toThrow(/would drop a comment \(\/\/ routed here\)/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('addresses: null deletes the key, keeps the previous sibling’s trailing comment and the blank line is gone', () => { + setup( + 'wrangler.jsonc', + `{ + "name": "acme-support", // worker name + "addresses": ["support@mail.acme.com"], + "kv_namespaces": [ + { "binding": "KV", "id": "abc" }, // cache + ], +}`, + ); + const result = writeProvisionedWranglerConfig(tmpDir, { addresses: null }); + expect(result.modified).toBe(true); + const raw = readRaw('wrangler.jsonc'); + expect(raw).toBe(`{ + "name": "acme-support", // worker name + "kv_namespaces": [ + { "binding": "KV", "id": "abc" }, // cache + ], +}`); + expect(parseRaw('wrangler.jsonc')).not.toHaveProperty('addresses'); + // Deleting an absent key is a no-op. + expect(writeProvisionedWranglerConfig(tmpDir, { addresses: null }).modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(raw); + }); + + it('addresses: null on the LAST member of a strict .json file removes the preceding comma (no trailing comma left)', () => { + setup( + 'wrangler.json', + `{ + "name": "acme-support", + "addresses": ["support@mail.acme.com"] +}`, + ); + writeProvisionedWranglerConfig(tmpDir, { addresses: null }); + const raw = readRaw('wrangler.json'); + expect(raw).toBe(`{ + "name": "acme-support" +}`); + expect(JSON.parse(raw)).toEqual({ name: 'acme-support' }); + }); + + it('addresses: null keeps the removed member’s own trailing comment and a full-line comment above it', () => { + setup( + 'wrangler.jsonc', + `{ + "name": "acme-support", + // inbound mail + "addresses": ["support@mail.acme.com"], // managed by void + "vars": { "A": "1" }, +}`, + ); + writeProvisionedWranglerConfig(tmpDir, { addresses: null }); + const raw = readRaw('wrangler.jsonc'); + expect(raw).toContain('// inbound mail'); + expect(raw).toContain('// managed by void'); + expect(parseRaw('wrangler.jsonc')).toEqual({ name: 'acme-support', vars: { A: '1' } }); + }); + + it('addresses: null with CR-only line endings stays valid and keeps the trailing comment', () => { + const crOnly = `{\r "name": "acme-support", // worker name\r "addresses": ["support@mail.acme.com"],\r "vars": { "A": "1" },\r}`; + setup('wrangler.jsonc', crOnly); + writeProvisionedWranglerConfig(tmpDir, { addresses: null }); + const raw = readRaw('wrangler.jsonc'); + expect(parseErrors(raw)).toBe(0); + expect(raw).toContain('// worker name'); + expect(raw).not.toContain('\n'); + expect(parseRaw('wrangler.jsonc')).toEqual({ name: 'acme-support', vars: { A: '1' } }); + }); + + it('vars: creates the vars object when absent, keeping the trailing comment on the last member', () => { + setup( + 'wrangler.jsonc', + `{ + "name": "acme-support", // worker name +}`, + ); + const result = writeProvisionedWranglerConfig(tmpDir, { + vars: { __VOID_EMAIL_FROM: 'support@mail.acme.com' }, + }); + expect(result.modified).toBe(true); + const raw = readRaw('wrangler.jsonc'); + expect(raw).toContain('"name": "acme-support", // worker name'); + expect(parseRaw('wrangler.jsonc').vars).toEqual({ __VOID_EMAIL_FROM: 'support@mail.acme.com' }); + }); + + it('vars: upserts only the named keys — a changed value is overwritten, siblings and their comments stay, idempotent', () => { + setup( + 'wrangler.jsonc', + `{ + "vars": { + "APP_ENV": "production", // user var + "__VOID_EMAIL_FROM": "old@mail.acme.com", + }, +}`, + ); + const patch = { vars: { __VOID_EMAIL_FROM: 'support@mail.acme.com' } }; + expect(writeProvisionedWranglerConfig(tmpDir, patch).modified).toBe(true); + const raw = readRaw('wrangler.jsonc'); + expect(raw).toContain('// user var'); + expect(parseRaw('wrangler.jsonc').vars).toEqual({ + APP_ENV: 'production', + __VOID_EMAIL_FROM: 'support@mail.acme.com', + }); + expect(writeProvisionedWranglerConfig(tmpDir, patch).modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(raw); + }); + + it('vars: fails closed when the key is already another binding’s name, or when vars is not an object — no write', () => { + setup( + 'wrangler.jsonc', + `{ "kv_namespaces": [{ "binding": "__VOID_EMAIL_FROM", "id": "abc" }] }`, + ); + let before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { vars: { __VOID_EMAIL_FROM: 'x@y' } }), + ).toThrow(/cannot write var "__VOID_EMAIL_FROM".*already used by a KV Namespace binding/s); + expect(readRaw('wrangler.jsonc')).toBe(before); + + setup('wrangler.jsonc', `{ "vars": ["not", "an", "object"] }`); + before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { vars: { __VOID_EMAIL_FROM: 'x@y' } }), + ).toThrow(/"vars" must be an object mapping names to values/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('the full Mode B patch composes: send_email + addresses + vars in one write, every comment survives, idempotent', () => { + setup( + 'wrangler.jsonc', + `{ + // Void manages this file — every comment below must survive. + "name": "acme-support", // worker name + "vars": { + "APP_ENV": "production", // user var + }, + "kv_namespaces": [ + { "binding": "KV", "id": "abc" }, // cache + ], +}`, + ); + const patch = { + sendEmail: { name: 'SEND_EMAIL' }, + addresses: ['support@mail.acme.com'], + vars: { __VOID_EMAIL_FROM: 'support@mail.acme.com' }, + }; + expect(writeProvisionedWranglerConfig(tmpDir, patch).modified).toBe(true); + const raw = readRaw('wrangler.jsonc'); + for (const comment of [ + '// Void manages this file', + '// worker name', + '// user var', + '// cache', + ]) { + expect(raw).toContain(comment); + } + expect(parseRaw('wrangler.jsonc')).toEqual({ + name: 'acme-support', + vars: { APP_ENV: 'production', __VOID_EMAIL_FROM: 'support@mail.acme.com' }, + kv_namespaces: [{ binding: 'KV', id: 'abc' }], + send_email: [{ name: 'SEND_EMAIL' }], + addresses: ['support@mail.acme.com'], + }); + expect(writeProvisionedWranglerConfig(tmpDir, patch).modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(raw); + + // A later preflight finding routing off withdraws `addresses` only. + expect(writeProvisionedWranglerConfig(tmpDir, { addresses: null }).modified).toBe(true); + const withdrawn = readRaw('wrangler.jsonc'); + for (const comment of [ + '// Void manages this file', + '// worker name', + '// user var', + '// cache', + ]) { + expect(withdrawn).toContain(comment); + } + expect(parseRaw('wrangler.jsonc')).toEqual({ + name: 'acme-support', + vars: { APP_ENV: 'production', __VOID_EMAIL_FROM: 'support@mail.acme.com' }, + kv_namespaces: [{ binding: 'KV', id: 'abc' }], + send_email: [{ name: 'SEND_EMAIL' }], + }); + }); +}); + describe('writeProvisionedWranglerConfig — producer collision on EVERY upsert', () => { const readRaw = (f: string): string => readFileSync(join(tmpDir, f), 'utf-8'); const parseRaw = (f: string): Record => @@ -3313,7 +3688,7 @@ describe('name-keyed collector — send_email/ratelimits identity is `.name` onl }); it('a send_email entry carrying an optional `.binding` does NOT collide with a DIFFERENT resource using that string', () => { - // wrangler 4.107 keys send_email on `.name` ONLY (config-schema.json has no `.binding` + // wrangler 4.131 keys send_email on `.name` ONLY (config-schema.json has no `.binding` // property; upload keys by `output[name]`). A mixed array `[{name,binding},{name}]` must // therefore leave the `.binding` string ("QUEUE_JOBS") FREE for a queue producer — the old // union of `.binding` invented a false "Send Email" collision and blocked the append. @@ -3463,6 +3838,8 @@ describe('bindingEntriesForField — entry reader parity with bindingNamesForFie { field: 'browser', value: { binding: 'BROWSER', remote: true } }, // binding-list { field: 'unsafe', value: { bindings: [{ name: 'X', type: 'secret_text' }] } }, + // whole-object (not a binding — pinned whole by the deploy verifier) + { field: 'addresses', value: ['support@mail.acme.com', '*@acme.com'] }, ]; it('yields the SAME names as bindingNamesForField for a representative value of each shape', () => { @@ -3511,6 +3888,17 @@ describe('bindingEntriesForField — entry reader parity with bindingNamesForFie ); }); + it('addresses is a whole-object field: no binding names, no entries, never misread as a namespace-array', () => { + // `addresses` is `string[]` (wrangler ≥ 4.113 Email Routing rules). The default + // namespace-array reader would throw on its string members; the registry names it + // whole-object so neither reader sees a binding in it — the deploy verifier pins the + // value WHOLE instead (DEPLOY_WHOLE_OBJECT_PINNED_FIELDS). + const value = ['support@mail.acme.com', '*@acme.com']; + expect(bindingNamesForField('addresses', value)).toEqual([]); + expect(bindingEntriesForField('addresses', value)).toEqual([]); + expect(bindingNamesForField('addresses', undefined)).toEqual([]); + }); + it('fails closed when a key-map field is routed here (unreachable — asserts fail-closed)', () => { // All key-map fields (vars/wasm_modules/…) are EXCLUDED from the assert-set, so this reader is // never called with one. If a future misclassification did, it must throw rather than silently diff --git a/packages/void/vite.config.ts b/packages/void/vite.config.ts index f312f3d29..c0776519e 100644 --- a/packages/void/vite.config.ts +++ b/packages/void/vite.config.ts @@ -42,6 +42,8 @@ function injectWorkersTypesReferencePlugin() { 'runtime/sandbox.d.mts', 'runtime/live.d.mts', 'runtime/live-server.d.mts', + 'runtime/email.d.mts', + 'runtime/email/testing.d.mts', ]); if (!targets.has(output.fileName)) { continue; @@ -118,6 +120,8 @@ export default defineConfig({ 'src/runtime/isr.ts', 'src/runtime/log.ts', 'src/runtime/ai.ts', + 'src/runtime/email.ts', + 'src/runtime/email/testing.ts', 'src/runtime/sandbox.ts', 'src/runtime/remote/index.ts', 'src/runtime/remote/binding-handler.ts', @@ -154,6 +158,7 @@ export default defineConfig({ '@cloudflare/vite-plugin', 'better-sqlite3', 'cloudflare:workers', + 'cloudflare:email', '@void/vue', '@void/react', '@void/svelte', @@ -180,6 +185,9 @@ export default defineConfig({ 'picocolors', 'ofetch', 'pathslash', + // Dependency-free; bundled like the other CLI-only helpers so the deploy's wrangler-compatible + // interactivity gate (`isWranglerNonInteractiveOrCI`) adds no runtime dependency. + 'ci-info', ], }, }, diff --git a/platform/meta/design-docs/0047-email.md b/platform/meta/design-docs/0047-email.md index fed6fb726..9cb975e6b 100644 --- a/platform/meta/design-docs/0047-email.md +++ b/platform/meta/design-docs/0047-email.md @@ -1,9 +1,17 @@ --- title: Email — Send & Receive via Resend -status: deferred +status: superseded created: 2026-03-03 +superseded_by: 0078-email-cf-native.md --- +> **Superseded on 2026-08-30 by `0078-email-cf-native.md`.** The cost argument +> below was about Resend. Email was later built on Cloudflare Email Routing +> instead, which removes the $90/mo floor and the third-party dependency, so +> the deferral no longer applies. The inbound reasoning held up: routing to the +> correct user worker on Workers for Platforms was the hard part, and it is +> what `0078` spends most of its design on. + # Email — Send & Receive via Resend > Historical design record. Status, checklists and examples describe the work diff --git a/platform/meta/design-docs/0078-email-cf-native.md b/platform/meta/design-docs/0078-email-cf-native.md new file mode 100644 index 000000000..47882f472 --- /dev/null +++ b/platform/meta/design-docs/0078-email-cf-native.md @@ -0,0 +1,2128 @@ +--- +title: Email — CF-Native (outbound, inbound, shared sender, lifecycle and UX) +status: draft +created: 2026-04-24 +updated: 2026-09-11 +supersedes: 0047-email.md +--- + +> **2026-08-30 re-land.** This document was written as `0067-email.md` on the +> abandoned `feat/email` branch. `0067` was taken by +> `0067-cross-script-do-host-architecture.md` while that branch sat, so the +> design is renumbered here. Three decisions changed during the re-land and +> the body below has NOT been rewritten around them — read these first: +> +> 1. **The platform mail domain is configured per environment.** The original +> hard-coded `mail.void.cloud` in the framework runtime, the proxy's sender +> regex and the api's verification sender. That made a staging environment +> impossible, because a `mail.staging.void.cloud` sender was rejected by our +> own regex. It is now one var, `EMAIL_SENDER_DOMAIN`, declared on both the +> api and the proxy worker and injected into user workers as +> `__VOID_EMAIL_DOMAIN`. Every reader fails closed when it is absent. +> 2. **There is one project-slug binding, `__VOID_PROJECT_SLUG`.** The original +> added a second, bare `VOID_PROJECT_SLUG`. `main` already injected the +> underscored name for sandbox projects, so `provisionBindings` now injects +> it for every project and there is exactly one producer. +> 3. **Inbound routes on the wire recipient.** `createMessageProxy` strips the +> `+` prefix so handler authors see the address they wrote; the +> generated dispatcher used to strip it a second time, leaving an empty tag +> that no pattern could match. The dispatcher now reads the preserved +> `__voidPlatformTo`. + +> **2026-04-29 update — outbound moved off the user worker.** A multi-agent +> review found that with `SEND_EMAIL` emitted as a per-project worker +> binding, any user handler could call `env.SEND_EMAIL.send(...)` and +> bypass the proxy's quota, suspend kill-switch, and metering. Sealing +> this gap by hiding the binding name didn't change the architectural +> fact that a shared platform resource (the `mail.void.cloud` sender, +> with pooled reputation) was sitting on every user worker. +> +> **Resolution:** outbound now follows the same shape as AI / revalidate. +> The user worker holds only `__VOID_PROXY` (+ token + project id). The +> framework's `sendEmail()` POSTs MIME bytes + recipients to +> `__VOID_PROXY/email/send`; the **proxy** worker holds the +> account-level `send_email` binding and enforces: +> +> - **Per-project verified-destinations allowlist** (mirrored from the +> `email_destinations` table into `email-allowlist:` ROUTING +> KV by every CRUD op + project delete; ~60s convergence per colo). +> - **Suspend kill-switch** (now also gates inbound). +> - **Burst + monthly quotas.** +> +> User-facing API (`sendEmail`, result types, error codes, dev inbox, +> test harness) is unchanged. The relevant sections below have been +> updated; the original on-worker-binding design is preserved in the git +> history of this doc. + +> **2026-04-29 follow-up — per-project destination consent.** A review +> finding (I-1) called out that when project A adds an address that +> another project already verified via CF Email Routing, the previous +> code trusted CF's `verified` flag and instantly marked the row +> verified for project A — bypassing the per-project consent the privacy +> story promises (the recipient consented to project B, not A). +> +> **Resolution: Option C + Option B.** A new `verification_source` +> column distinguishes how a row reached `verified`: +> +> - **Option C — owner-relaxed CF reuse** (`verification_source='cf'`). +> When the address matches the project owner's email and CF already +> has it verified, we trust CF: same human, same consent. Covers the +> "I made a second project" workflow. +> - **Option B — platform-side reverify** (`verification_source='platform'`). +> When CF reports the address verified by anyone _other_ than the +> project owner, we DO NOT inherit consent. The row inserts as +> `pending` and we issue our own HMAC-signed confirmation link to the +> recipient. The row only flips to `verified` when they click; CF's +> verified flag is irrelevant for the gate. +> +> Implementation pieces: +> +> - Migration `0013_email_verification_source.sql` adds +> `verification_source TEXT NOT NULL DEFAULT 'cf'` with a check +> constraint of `('cf' | 'platform')`. Existing rows default to `'cf'`. +> - Token helper `platform/packages/api/src/auth/confirm-token.ts` — +> `base64url(JSON) + '.' + base64url(HMAC-SHA256)` carrying +> `(v, pid, did, addr, iat, exp)` with 24h TTL. Single-use is enforced +> by DB state (clicking flips status; re-clicks find an +> already-verified row and return idempotent success). +> - Platform email helper `platform/packages/api/src/email/send-verification.ts` +> POSTs to the proxy's new `/email/internal/send` endpoint, which is +> gated by HMAC of the sentinel string +> `void-platform-internal-email-v1` under `JWT_SECRET`. Only the api +> and proxy hold `JWT_SECRET`, so user workers cannot reach the +> internal endpoint. +> - Confirm endpoint `GET /email/destinations/confirm?token=...` — +> public, mounted before the auth middleware, returns a small HTML +> confirmation page on success. +> - GET `/destinations` refresh now skips +> `verification_source='platform'` rows so a CF-side `verified=true` +> doesn't override the platform gate. _(2026-09-15)_ It also promotes a +> CF-pending row only when Cloudflare's verified destination carries the +> tag that row registered: when the destination was deleted and another +> project's add recreated it, CF's click answered that other +> registration, so a non-owner row stays pending (disallow + allow +> re-registers it and, the address now being CF-verified, takes the +> Option B platform link), while the owner's own address adopts the live +> tag with the inherited-consent audit (Option C). _(2026-09-15)_ A +> VERIFIED `cf` row keeps its consent and is never re-read against +> Cloudflare: the human at that address clicked the link mailed for +> this project's own registration, so an out-of-band deletion of the +> destination (an operator in the Cloudflare dashboard, or the +> delete-vs-register window the DELETE route names) is not a platform +> revocation — the revocation surfaces are `void email disallow`, the +> admin revoke, and the owner-email change — and until the address is +> re-verified on the account Cloudflare's `send_email` refuses the send +> (the proxy reports `UNVERIFIED_DESTINATION`); `disallow` + `allow` +> re-registers the row at any time (the DELETE 404s harmlessly on the +> dead tag). +> - Per-project rate-limit on `POST /destinations`: 5/minute and +> 50/day in `email-add-rate::*` KV counters, with the +> same NaN-poison defense as the proxy's send counter. Blocks Mallory +> from spamming reverify mail. +> - `autoAddOwnerDestination` is unchanged in behavior — by definition +> the address IS the owner's email, so it threads +> `verification_source='cf'` explicitly (Option C every time). +> _(2026-09-15)_ All three Option C writes (the auto-add, the POST's +> owner branch, and the listing refresh's owner adopt) decide to +> inherit from an unlocked read of the owner's email and only write +> after Cloudflare answers, while the login-side revoke can only demote +> rows that exist — and are `verified` — when it runs. So the +> owner-audited insert (`tryCreateEmailDestination`) and promotion +> (`promoteEmailDestinationWithOwnerAudit`) each run inside one +> transaction behind `SELECT … FOR SHARE` on the owner's `users` row +> and write nothing when `users.email` no longer matches the snapshot +> (the auto-add then releases the tag; the POST answers its existing +> 409, and a retry takes Option B; the listing leaves the row pending). +> _(2026-09-15)_ The login-side revoke +> (`updateUserRevokingInheritedDestinations` in `routes/auth.ts`) commits +> the email write, the demotion and each affected project's +> `email-allowlist:` tighten in ONE transaction, tighten last and inside +> it. Its only retry is the next login, which retries only while the +> stored address still differs from the provider's, so a mirror write +> swallowed after the commit was never retried and the proxy kept +> authorizing the old address until the owner happened to list +> destinations. A lost tighten now rolls the whole change back and the +> sign-in proceeds on the stored address; a rolled-back login can leave a +> key ahead of Postgres only in the tightening direction (deny), which +> the listing re-widens to match the rows. A best-effort re-derive after +> the commit undoes a listing or confirm click that re-widened the key +> between the tighten and the commit; the residual (a concurrent +> writer's put landing after both) is the DELETE route's, the 0067 +> project-lock class. +> +> Tests cover each path: Option C trust, Option B platform-pending + +> confirmation email dispatch, refresh-skip, confirm-endpoint happy +> path, expired/tampered/unknown-id failure modes, idempotent re-click, +> rate-limit, and the migration default. + +# Email — CF-Native (outbound, inbound, shared sender) + +> **2026-09-11 extension.** Sections [Hosting modes and +> lifecycle](#hosting-modes-and-lifecycle), [Open decisions](#open-decisions) +> and [Open questions](#open-questions) were added from evidence gathered on +> 2026-09-10 and 2026-09-11, after the re-land. Claims elsewhere that the +> evidence contradicted were rewritten in place; each carries a _(corrected +> 2026-09-11)_ mark. Claims marked _(measured)_ were verified by reading +> source or by execution — pointers are in [References](#references). + +## Context + +[`0047-email.md`](./0047-email.md) designed a full Resend-based integration and was **deferred** because the outbound value over a plain Resend SDK install was too thin, the inbound-via-webhooks path was real platform work but couldn't justify the build on its own, and the Scale plan carried a $90/mo floor regardless of usage. + +Since then we've re-scoped around **Cloudflare's native Email Service** (Email Routing + Email Workers + the `send_email` binding): + +- **No third-party dependency, no flat fee.** Outbound rides the existing `send_email` binding; cost is whatever Cloudflare charges (currently free within generous limits). +- **Inbound is native.** Email Workers deliver messages straight into the user worker via an `email(message, env, ctx)` handler — no webhook-relay plumbing through `api.void.cloud` → dispatch → `*.void.app`. +- **Already on the stack.** Users are on Workers; this is one more binding, not a new vendor. +- **DX delta is much larger.** Raw CF email requires hand-rolling MIME, editing wrangler config, has no local-dev story, and needs platform plumbing for the multi-tenant case. Void closes all four gaps. + +The earlier "just use the SDK" argument that sank 0047 doesn't apply here: the alternative isn't a three-line SDK, it's hand-constructing RFC 5322 messages, editing `wrangler.jsonc`, and (for any multi-tenant deployment) building cross-worker email dispatch from scratch. + +## Scope + +Single rollout covering outbound, inbound, and the platform infrastructure that makes both work end-to-end for every Void project. + +**Framework (`packages/void`):** + +- `void/email` outbound: `sendEmail(opts)` with a structured result, MIME construction, attachments, multi-recipient fan-out +- `email/` top-level directory convention for inbound: `defineEmail`, `parseEmail`, `replyEmail`, file-based routing with subaddressing + dynamic params +- Local dev inbox at `/__void/inbox` for outbound; round-trip dispatch back to local handlers (pending) for inbound +- Test harnesses: `createEmailTestHarness` (outbound), `createInboundTestHarness` (inbound) +- `WorkerEntrypoint` codegen so non-fetch handlers are RPC-callable from the platform router +- `from` defaulting to `+noreply@mail.void.cloud` when omitted +- Pre-flight quota check + async delivery log via `__VOID_PROXY` (fail-open when proxy is unbound) +- Binding inference (drives `manifest.bindings.email` for the dev-inbox plugin and analytics; no on-worker `send_email` binding) +- `void email usage|logs` CLI + +**Platform (`platform/packages/proxy`, `platform/packages/api`):** + +- `mail.void.cloud` shared sender zone (one-time CF Email Routing setup) +- Inbound router worker (`platform/packages/proxy/src/email-router.ts`) — receives all mail at `mail.void.cloud`, parses `+`, dispatches into the user worker via WfP RPC +- `/email/check-quota`, `/email/log-send`, `/email/log-recv` proxy endpoints with KV-backed monthly counters, per-minute burst cap, and suspend kill-switch +- `VOID_PROJECT_SLUG` env injection at deploy +- **Verified destinations** — `email_destinations` table per project; `POST /projects/:id/email/destinations` triggers the CF verification email; per-project allowlist injected as `allowed_destination_addresses` on the worker's `send_email` binding at deploy time. Project owner's email is auto-added on project create. +- Admin UI page (`platform/packages/api/src/admin/routes/email.tsx`): per-project usage, suspend toggle, monthly + burst limit overrides, counter reset, destinations list with add/remove +- API routes: `GET /projects/:id/email/{usage,logs}`, `GET|POST|DELETE /projects/:id/email/destinations[/:id]` + +## Non-Goals + +- **Custom-domain email for domains NOT on Cloudflare DNS** — a CNAME-only custom hostname cannot carry MX. _(scope change 2026-09-11)_ Domains on a Cloudflare zone are in scope; see [Custom domains on Cloudflare](#custom-domains-on-cloudflare). +- **Provider abstraction (Resend / SES / Postmark) wrapper.** Users can call those SDKs directly today; the wrapper would not earn its keep without concrete demand. See [Future work](#future-work) for the trigger conditions. +- **Templating** (React Email / Vue components / MJML). +- **Typed verified-sender codegen.** +- **User-facing dashboard pages** — _(corrected 2026-09-11)_ one small Email section on the project page is in scope; see [Mode A — developer surfaces](#developer-surfaces). Broader dashboard work stays deferred per `CLAUDE.md`. +- **Bounce handling.** CF doesn't deliver bounces to Email Workers; needs VERP or provider-side webhooks. +- **Cost-passthrough billing for outbound.** CF email is free at our volumes. +- **Programmatic Email Routing _rule_ creation on the managed platform.** The shared catch-all is a one-time setup; per-project routing rules aren't needed there. _(corrected 2026-09-11)_ Rule creation IS the plan for `void deploy --backend cloudflare`, where the developer owns the zone — see [Mode B](#mode-b--the-developers-own-cloudflare-account). + +## Cloudflare's Constraints (Be Honest) + +_(corrected 2026-09-11 — the original predated Email Sending onboarding.)_ + +The `send_email` binding has two regimes, and which one an account is in +decides who it can reach: + +| | Email Routing only | + mail domain onboarded to **Email Sending** | +| ---------------- | --------------------------------------------------------------------------- | -------------------------------------------- | +| sender | any address on a routing domain the account owns | same | +| who can receive | **verified destination addresses only** | anyone | +| account ceiling | **200 verified destinations, shared across every domain and every project** | none | +| DNS added | MX + SPF + DKIM, added and locked on enable | `cf-bounce` MX, SPF, DKIM, DMARC | +| plan | Workers Free or Paid | Workers Paid | +| our consent gate | allowlist + HMAC link | unchanged | + +- **From-domain:** must be a zone on Cloudflare DNS that the account owns, with Email Routing enabled. A subdomain of an existing zone (`mail.example.com`) is allowed for both routing and sending. There is no email without a zone — `workers.dev` alone cannot send or receive. +- **Binding restrictions:** `destination_address` pins one recipient; `allowed_destination_addresses` pins a list; `allowed_sender_addresses` pins the from side. With none set, the binding reaches whatever the regime above allows. +- **The 200-destination cap is the real constraint.** It is per account, not per domain or per project, so on a shared platform it is a ceiling on the whole platform's distinct recipients until the mail domain is onboarded to Email Sending. Onboarding lifts Cloudflare's gate; per-project consent stays in our own allowlist, which is a product rule rather than a Cloudflare one. +- **Two operational quirks:** outbound sent through the binding shows as _dropped_ in the Email Routing summary even when delivered (read Email Sending metrics or the proxy tail), and `message.reply()` throws when the inbound message carries more than 100 `References` entries. +- **Practical implication:** without onboarding, CF-native outbound fits app-internal use (notifications to the owner, ops alerts, forms piped to a verified inbox, reply-by-email on top of inbound). With onboarding it reaches end users too. Reputation is pooled on the shared domain; a project that needs its own signs with its own domain through [Custom domains on Cloudflare](#custom-domains-on-cloudflare). + +## DX Papercuts → What Void Fixes + +| Papercut in raw CF Email Workers | Void fix | +| ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Hand-rolled RFC 5322 MIME (headers, boundaries, encodings, attachments) required for every send | `sendEmail({ from, to, subject, text, html, attachments, ... })` — MIME built for you | +| Must manually add `[[send_email]]` to `wrangler.jsonc` | No on-worker binding — the platform proxy holds it; `sendEmail()` POSTs to `__VOID_PROXY/email/send` | +| `wrangler dev` doesn't simulate email — real sends during local dev, or hand-rolled mocks | Local dev inbox: captured in-memory, browsable at `/__void/inbox`, HTML + raw `.eml` | +| No unified error shape — `send_email` throws raw Worker errors | `sendEmail` returns a 3-case discriminated union; typed error codes; per-recipient deliveries | +| Reply-to / CC / BCC / custom headers require manual header construction | First-class options fields; Void handles header encoding (RFC 2047 for non-ASCII names) | +| Attachments require base64 + multipart/mixed MIME by hand | `attachments: [{ filename, content, contentType }]`; `content` accepts `string`/`Uint8Array`/`Blob` | +| Hand-write `email(message, env, ctx)` export, manually parse multipart with `postal-mime` | Drop a file in `email/`, default-export a `defineEmail()` handler — Void generates the export | +| All inbound flows through a single function — branching by recipient is hand-rolled | File-based sub-routing: `email/[user].ts`, `email/support+[ticket].ts`, etc. | +| `EmailMessage.reply()` requires constructing a fully-formed reply with threading headers | `replyEmail(message, { text, html })` builds threading-aware reply MIME and dispatches | +| Subaddressing (`+tag` local-part suffix) requires manual parsing in every handler | Built into the router; `params.` populated automatically | +| `postal-mime` import + stream consumption boilerplate in every handler | `parseEmail(message)` wraps it; `ParsedEmail` re-exported as the canonical type | +| No test fixtures for inbound — users build `EmailMessage` mocks themselves | `createInboundTestHarness().deliver({ from, to, subject, text })` invokes the real dispatcher | +| Multi-tenant inbound dispatch from a shared zone is unsolved | `mail.void.cloud` shared sender + inbound router worker dispatches by `+` | + +## Design + +### Outbound: `void/email` + +#### API surface — `sendEmail` + +```ts +import { sendEmail } from 'void/email'; + +const result = await sendEmail({ + from: 'Acme ', // string | { email, name } — optional, defaults to +noreply@mail.void.cloud + to: 'user@example.com', // string | Address | Array + subject: 'Welcome', + text: 'Hi there', // at least one of text/html required + html: '

Hi there

', + replyTo: 'support@acme.dev', + cc: [...], + bcc: [...], + headers: { 'X-Campaign': 'welcome' }, + attachments: [ + { filename: 'receipt.pdf', content: pdfBytes, contentType: 'application/pdf' }, + ], +}); + +if (!result.ok) { + if ('error' in result) { + // pre-flight failure — no envelopes attempted + console.error(result.error.code, result.error.message); + } else { + // mid-batch failure — some recipients may have succeeded + for (const d of result.deliveries) { + if (!d.ok) console.error(`failed to ${d.recipient}:`, d.error); + } + } +} +``` + +#### Types (exported) + +```ts +export type Address = string | { email: string; name?: string }; + +export interface Attachment { + filename: string; + content: string | Uint8Array | ArrayBuffer | Blob; + contentType?: string; // inferred from filename if omitted + contentId?: string; // for inline images (cid:) + disposition?: 'attachment' | 'inline'; +} + +export interface SendEmailOptions { + from: Address; + to: Address | Address[]; + subject: string; + text?: string; + html?: string; + replyTo?: Address; + cc?: Address | Address[]; + bcc?: Address | Address[]; + headers?: Record; + attachments?: Attachment[]; + ctx?: { waitUntil(p: Promise): void }; // for the async log call +} + +export interface SendEmailDelivery { + recipient: string; // the envelope `To` for this CF send + messageId: string; // CF's returned messageId +} + +export type SendEmailRecipientResult = + | { recipient: string; ok: true; messageId: string } + | { recipient: string; ok: false; error: SendEmailError }; + +export type SendEmailResult = + | { ok: true; ids: SendEmailDelivery[] } + | { ok: false; error: SendEmailError } // pre-flight only + | { ok: false; deliveries: SendEmailRecipientResult[] }; // mid-batch failures + +export interface SendEmailError { + code: + | 'BINDING_MISSING' + | 'INVALID_FROM' + | 'INVALID_TO' + | 'UNVERIFIED_DESTINATION' + | 'MIME_ERROR' + | 'QUOTA_EXCEEDED' + | 'UPSTREAM_ERROR'; + message: string; + cause?: unknown; +} +``` + +#### Multi-recipient fan-out + failure semantics + +The CF `EmailMessage` envelope is single-recipient, so `sendEmail` iterates `to` ∪ `cc` ∪ `bcc` and calls `binding.send()` once per recipient with the same raw MIME (Cc/Bcc are encoded into the MIME headers for display; the envelope alone drives delivery). + +Three discriminated result cases: + +- **Pre-flight failure** — validation / MIME / missing binding / quota denied. No envelopes attempted. Result: `{ ok: false, error }`. +- **All-success** — every recipient succeeded. Result: `{ ok: true, ids }`. +- **Mid-batch failure** — sends were attempted; at least one failed. Result: `{ ok: false, deliveries }`. Runtime continues through every recipient regardless of failures and collects per-recipient outcomes. **No** aggregate top-level error — distinct recipients can fail with distinct errors and synthesizing one would mislead callers. + +Retry rule: on `deliveries`, retry only recipients with `ok: false`. On pre-flight `error`, retry the whole call. Never retry on the absence of `ok: true` alone — re-sending to recipients with `ok: true` will deliver duplicates. + +#### Default sender resolution + +When `from` is omitted, the framework reads `env.__VOID_PROJECT_SLUG` and `env.__VOID_EMAIL_DOMAIN` (both injected at deploy by the platform) and substitutes `+noreply@`. Explicit `from` values pass through unchanged. If either binding is absent, the call returns `INVALID_FROM` with a clear message — and _(measured 2026-09-11)_ that is what happens under `void dev` today, because neither binding is injected there; see [Known gaps](#known-gaps). + +#### Binding inference + +Two-tier detection in `packages/void/src/inference/walk.ts`: + +1. **Import-source match** — `case "void/email": result.needsEmail = true;` +2. **Regex fallback** for JSX/TSX paths that skip the lexer + +The third tier (`env.SEND_EMAIL` AST detection) was removed when outbound +moved to the proxy: the user worker no longer has a `SEND_EMAIL` binding, +so reading `env.SEND_EMAIL` returns undefined regardless of inference. + +`needsEmail` drives `manifest.bindings.email` (used by analytics and the +local dev-inbox plugin) and the `email/` directory inclusion in the +default scan dirs. Opt-out via `bindings: { email: false }` in plugin +options. Wrangler sync **does not emit** `[[send_email]]` on user +projects — the proxy worker carries the platform's account-level +binding (see `platform/packages/proxy/wrangler.jsonc`). + +#### MIME construction + +_(corrected 2026-09-11)_ The re-land ships its own builder in `runtime/email/mime.ts` — `buildMime`, `encodeWord`, `foldHeader` — rather than `mimetext`, because six review rounds ended with rules no small library exposes: RFC 5322 §2.1.1's 998-octet line limit enforced on every rendered line, address lists folded at commas, RFC 2047 words chunked by code point, quoted parameters escaped, and a rejection rather than an illegal encoding when a value cannot be folded. Attachment totals are pre-checked against CF's ~10MB base64-inflated cap; oversize payloads surface as `MIME_ERROR` with measured-vs-allowed bytes rather than a generic upstream failure. + +Unicode display-name fixture exercises RFC 2047 encoded-words (e.g. `"Jürgen Müller" `, Japanese, emoji) — locks behavior in and guards against regressions if the MIME backend is ever swapped. + +### Inbound: top-level `email/` directory + +#### Convention + +``` +crons/ # cron handlers → scheduled() export +queues/ # queue handlers → queue() export +email/ # email handlers → email() export +├── _default.ts # fallback when no per-address match +├── support.ts # static: support@ +├── support+[ticket].ts # subaddressing: support+ticket-123@... → params.ticket +├── [user].ts # dynamic local-part: anything@... → params.user +└── [user]+[tag].ts # dynamic + subaddressing +routes/ # HTTP only → fetch() export +``` + +`email/` sits at the top level alongside `crons/` and `queues/` — non-HTTP handlers all live at the project root since each is wired into a separate worker export. `routes/` is reserved for HTTP routing. + +#### Match precedence (most specific wins) + +1. Static + tagged: `support+[ticket].ts` for `support+ticket-123@...` +2. Static: `support.ts` for `support@...` +3. Dynamic + tagged: `[user]+[tag].ts` +4. Dynamic: `[user].ts` +5. `_default.ts` — fallback + +If no handler matches (and there's no `_default.ts`), the dispatcher does nothing and returns control to CF, which delivers a non-delivery report to the sender. Nested folders under `email/` are intentionally ignored. + +Local-part-only matching — handlers match on the part before `@`, regardless of domain. Multi-domain projects that need per-domain branching can read `message.to` inside the handler. Domain-aware routing is deferred until usage demands it. + +#### Handler signature + +```ts +import { defineEmail, parseEmail, replyEmail } from 'void/email'; + +export default defineEmail(async (message, env, ctx, info) => { + // message: ForwardableEmailMessage-shaped proxy (see "envelope/action contract" below) + // message.from, message.to, message.headers, message.raw, message.rawSize + // message.setReject(reason), message.forward(to, headers?), message.reply(emailMessage) + // + // info?: { params: Record } — populated for dynamic segments + // + const parsed = await parseEmail(message); + console.log(parsed.subject, info?.params.ticket); + + await replyEmail(message, { text: 'Got it, ticket created.' }); +}); +``` + +`defineEmail()` is an identity function at runtime — it exists for IDE completion on the handler signature without forcing users to import `EmailHandler` separately. Matches Void's `defineX` family. A bare `export default async (message, env, ctx) => { ... }` works too. + +**Why explicit `parseEmail` (vs auto-parsed wrapper).** Inbound parse is destructive: `message.raw` is a `ReadableStream` that can only be consumed once. Auto-parsing on every dispatch wastes work for handlers that only need `setReject` based on the recipient and forces a synchronous-buffer model that fights CF's streaming primitive. + +#### Threading-aware replies + +`replyEmail(message, opts)`: + +1. Reads the original `Message-ID` and `References` from the message headers +2. Builds reply MIME via the outbound MIME builder, with `In-Reply-To` / `References` spliced in and `Subject: Re: ` (only adds `Re:` if not already prefixed) +3. Records a reply intent on the message proxy; the platform router enacts it on the real `ForwardableEmailMessage` + +Defaults: `from = message.to`, `to = message.from`, `subject = "Re: "`. Override any in opts. + +#### Framework boundary — inbound is Void-app-only + +_(measured 2026-09-11)_ Inbound handlers compile through `routingPlugin`, which no framework branch of `voidPlugin()` installs; the framework wrapper (`plugin-framework-triggers.ts`) wires crons and queues only. So under TanStack Start, React Router, vinext, SvelteKit, Nuxt, Analog and Astro there is nowhere to attach an `email()` export. Rather than let those projects ship dead handlers and bounce real mail, an `email/` directory under a detected framework is a **build error**, next to the existing auth / sandbox / live guards. The guard keys on the handler directory, not on `bindings.needsEmail`: outbound `sendEmail` is a binding and works in every framework. Wiring inbound into the framework entries needs the `WorkerEntrypoint` conversion below applied to each wrapper and is separate work. + +| | Void app | Class A (TanStack Start, React Router) | Class B/C (SvelteKit, Nuxt, Analog, Astro) | +| ------------------------- | -------- | ---------------------------------------------- | ------------------------------------------ | +| `sendEmail` in production | yes | yes | yes | +| dev inbox | yes | yes, once the app declares an `assets` binding | no — `BINDING_MISSING`; use the harness | +| `email/` inbound handlers | yes | build error | build error | + +#### Codegen + +`packages/void/src/router/email.ts` scans `email/*.{ts,mts,js,mjs}` (top-level) and produces an `EmailHandlersScan`: + +```ts +interface EmailHandlersScan { + defaultHandler?: string; // "_default.ts" + addresses: Array<{ + address: string; // "support" | "support+[ticket]" | "[user]" + filePath: string; // "support+[ticket].ts" + pattern: { + kind: 'static' | 'static-tag' | 'dynamic' | 'dynamic-tag'; + regexSource: string; // anchored, targets the lowercased local-part + paramNames: string[]; + }; + }>; +} +``` + +`router/compile.ts` emits an `__emailHandler` virtual export inside `virtual:void-routes`. Patterns are baked as a regex table tried in specificity order: + +```ts +// generated by compileRouteTable +const __emailDefault = () => import('/email/_default.ts'); +const __emailAddr0 = () => import('/email/support+[ticket].ts'); +const __emailAddr1 = () => import('/email/support.ts'); +const __emailAddr2 = () => import('/email/[user].ts'); +const __emailPatterns = [ + { re: /^support\+([^+@]+)$/, params: ['ticket'], load: __emailAddr0 }, + { re: /^support$/, params: [], load: __emailAddr1 }, + { re: /^([^+@]+)$/, params: ['user'], load: __emailAddr2 }, +]; + +export async function __emailHandler(message, env, ctx) { + const local = + String(message?.to ?? '') + .toLowerCase() + .split('@')[0] ?? ''; + for (const p of __emailPatterns) { + const m = p.re.exec(local); + if (!m) continue; + const params = {}; + for (let i = 0; i < p.params.length; i++) params[p.params[i]] = m[i + 1]; + const mod = await p.load(); + if (typeof mod.default === 'function') return mod.default(message, env, ctx, { params }); + return; + } + const dmod = await __emailDefault(); + if (typeof dmod.default === 'function') return dmod.default(message, env, ctx, { params: {} }); +} +``` + +The worker entry (`compileWorkerEntry`) imports `__emailHandler`, builds the message proxy, runs the dispatcher, and returns the action queue — see [Cross-worker dispatch](#cross-worker-dispatch). + +### Cross-worker dispatch + +#### Why the user worker must be a `WorkerEntrypoint` + +WfP dispatch namespace stubs are typed as `Fetcher` — `.fetch()` and `.connect()` only. A plain default-export object like `export default { fetch, email, queue }` exposes **only `.fetch()`** through the namespace stub. The user worker extends `WorkerEntrypoint` from `cloudflare:workers` so its class methods become `Rpc.Provider` entries on `Fetcher`, making `.email()` / `.queue()` / `.scheduled()` callable from the router. + +`compileWorkerEntry` emits this shape for every WfP-deployed worker, not just email-handling ones. Cron and queue handlers also become RPC-callable from the platform side as a bonus, useful for future platform features. + +**Compatibility dates.** Void preserves the user's `compatibility_date` with no floor, and a review asked whether the class shape breaks workers pinned before the 2024-04-03 RPC date. It does not: the `rpc` flag gates the **caller's** `Fetcher` stub, not the callee's class. _(measured)_ In workerd 1.20260701.1 a `WorkerEntrypoint` subclass serves `fetch`, `scheduled`, `queue` and RPC `email()` at dates back to 2021-11-03 and under an explicit `no_rpc`. The only caller is the platform proxy, at 2026-02-12. + +#### Why the user worker can't receive a real `ForwardableEmailMessage` + +Even with `WorkerEntrypoint`, the platform router cannot call `dispatch.get(name).email(realMessage)` and expect it to work. `ForwardableEmailMessage` is **not in workerd's RPC `BaseType` allowlist**, and `EmailMessage` is not `JSG_SERIALIZABLE`. RPC marshaling fails with `DataCloneError` before any bytes are sent. The receiver's `email()` method is never invoked. + +Sources verifying this: + +- `BaseType` allowlist: workerd `types/defines/rpc.d.ts` lines 64–83 — eleven types, no email +- Wire `SerializationTag` enum: `src/workerd/io/worker-interface.capnp` lines 424–469 — same 11 tags +- Serialization rejection path: `src/workerd/jsg/ser.c++:241–330` (`Serializer::WriteHostObject`); throws via `throwDataCloneErrorForObject` for any unregistered JSG type +- RPC argument marshaling: `src/workerd/api/worker-rpc.c++:176–188` (`serializeJsValue`) — zero email-specific handling +- Test fixture asserting the failure mode: `src/workerd/api/tests/js-rpc-test.js:852–858` +- `ForwardableEmailMessage` declaration: `types/defines/email.d.ts:28` — plain interface, no `RpcTarget` brand + +The `email?(message: ForwardableEmailMessage)` declaration on `WorkerEntrypoint` (PR #5538, commit `862cb02c0`, type-only diff with no C++ runtime support) is intended for the CF email runtime calling the user worker directly — not for cross-worker RPC dispatch. + +#### Envelope / Action contract + +The router serializes the inbound message into an `EmailEnvelope` (only fields in the RPC `BaseType` allowlist), invokes `dispatch.get(name).email(envelope)`, and awaits an `EmailAction[]` describing what the user handler decided. + +```ts +// from "void/email" +interface EmailEnvelope { + from: string; + to: string; + headers: Record; // plain object, not Headers + raw: ReadableStream | Uint8Array; + rawSize: number; +} + +type EmailAction = + | { type: 'reject'; reason: string } + | { type: 'forward'; to: string; headers?: Record } + | { type: 'reply'; from: string; to: string; raw: Uint8Array }; +``` + +Inside the user worker's `email(envelope)` method, the framework builds a _proxy_ `ForwardableEmailMessage` via `createMessageProxy(envelope)`. The proxy exposes all standard properties (`from`, `to`, `headers`, `raw`, `rawSize`) and methods (`setReject`, `forward`, `reply`), but each method **records an intent into an action queue** instead of acting directly. After the user handler returns, the codegen returns the action queue as the RPC result. The router enacts each action on the real `ForwardableEmailMessage` (with `setReject` honored as terminal per CF's runtime semantics). + +`replyEmail(message, opts)` keeps its existing public API; internally it builds the reply MIME (same threading-aware logic) and calls `proxy.reply({ from, to, raw })`. The router constructs a real `cloudflare:email` `EmailMessage` from the action and calls the original `message.reply(...)` on its side. + +User-handler ergonomics are unchanged from a single-tenant native CF Email Worker — authors still write `defineEmail(async (message, env, ctx, info) => { … })` and call imperatively. The framework hides the RPC round-trip. + +#### Cross-worker pre-buffering + +The router pre-buffers `message.raw` to a `Uint8Array` before serializing the envelope: + +1. **Stream consumer rule:** `parseEmail` reads the stream once and exhausts it. Buffering on the router side makes the body replayable across the RPC boundary if the receiving framework dispatcher needs it more than once. +2. **RPC stream lifetime:** a `ReadableStream` IS in `BaseType` and survives RPC, but it's still a one-shot transferable. Buffering avoids subtle timing failures. + +#### RPC result materialization — accepted limit _(measured 2026-09-15)_ + +`await stub.email(envelope)` in `email-router.ts` materializes the tenant's **entire** return value in the shared `void-proxy` isolate before `MAX_ACTIONS` or any per-field cap in `validateActions` can look at it. A Workers-for-Platforms tenant ships any `WorkerEntrypoint`, so the framework's action queue is a DX guardrail, not the boundary — and nothing on the proxy side can bound the result before it lands. This is a property of Workers RPC, recorded here with the numbers so it is not re-litigated. + +**The transport cap is 32 MiB of _serialized_ bytes, checked by the callee.** Docs: "The maximum serialized RPC limit is 32 MiB" ([RPC → Limitations](https://developers.cloudflare.com/workers/runtime-apis/rpc/#limitations)). Source: `MAX_JS_RPC_MESSAGE_SIZE = 1u << 25` in `src/workerd/api/worker-rpc.h`, asserted in `serializeJsValue` — for a return value that runs in the **callee**, and the caller sees `Error: Serialized RPC arguments or return values are limited to 32MiB, but the size of this value was: N bytes.` No WfP-specific figure exists (the [WfP limits page](https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/reference/limits/) has none); a dispatch-namespace stub goes through the same `worker-rpc.c++` path as a service binding. + +**Why the cap does not bound the caller.** `deserializeJsValue` calls `jsg::Deserializer::readValue` once, materializing the whole graph with JS execution forbidden for the duration; it runs inside the `awaitIo` callback the moment the response arrives (`worker-rpc.c++`, where the `JsRpcPromise` is built), whether or not JS ever awaits the promise. The `CallResults` message always carries the by-value result, so promise pipelining cannot decline it, and `cloudflare:workers` exposes no API that inspects a result's size before materialization. Serialized bytes and materialized bytes are therefore different budgets, and the ratio is the tenant's to choose. + +_(measured)_ workerd 1.20260701.1 via miniflare, caller → callee over a service binding, caller heap read through the workerd inspector (`Runtime.getHeapUsage`), value returned from `email()` and retained: + +| Returned value | Serialized | Result | Caller heap delta | Wall | +| ------------------------------------------ | ---------- | ---------------------------- | ----------------------------- | ------- | +| `[null × 1e6]` | 0.95 MiB | ok | 3.8 MiB (4.00 B/elem) | 21 ms | +| `[null × 1.6e7]` | 15.26 MiB | ok | 61.0 MiB | 279 ms | +| `[null × 33,554,400]` | 32.00 MiB | ok | **128.0 MiB** | 587 ms | +| `[null × 33,554,500]` | 32.00 MiB+ | callee throws (33,554,513 B) | 0 | 458 ms | +| `[{ …, junk: 'x'.repeat(8 MiB) }]` | 8.00 MiB | ok | 8.0 MiB (1.0x — no amplifier) | 4 ms | +| `[{ …, junk: 'x'.repeat(32 MiB) }]` | 32.00 MiB+ | callee throws | 0 | 5 ms | +| `[sharedObj × 1.6e7]` (V8 back-references) | 30.52 MiB | ok | 61.0 MiB (4.00 B/elem) | 395 ms | +| `[{} × 1e6]` | 2.86 MiB | ok | 47.0 MiB (~47 B/elem) | 157 ms | +| `[{} × 4e6]` | 11.44 MiB | ok | 170.9 MiB | 702 ms | +| `[{} × 11,184,800]` | 32.00 MiB | ok | **502.6 MiB** | 2234 ms | + +workerd's V8 uses pointer compression (4 B per tagged slot; Node measures 8 B and 682 MiB for the last row), so a dense array of nulls is a 4x amplifier and an array of empty objects ~16x. Against the 128 MB isolate ceiling ([Limits → Memory](https://developers.cloudflare.com/workers/platform/limits/#memory)), a single in-cap return value is enough. Open-source workerd enforces no memory limit — the 502.6 MiB call succeeded locally — so the production eviction itself cannot be measured outside production. + +**What scales it down, and what does not.** Producing the value costs the tenant its own CPU (build + serialize run inside the callee's invocation, under `routing.limits.cpuMs`): ~1 MiB of caller heap per callee-CPU-ms for nulls, ~0.4 MiB/ms for `{}` (V8 ValueSerializer, measured in Node on an M-series laptop). So a plan cap scales the per-call figure — free 10 ms ≈ 10 MiB, pro 50 ms ≈ 50 MiB — but the solo/pro `cpuMs` override ceiling is 30 000 ms and `custom` is uncapped, so the worst rows are reachable there, and concurrent deliveries to one project materialize their results concurrently in one isolate, multiplying the plan-capped figure. + +**Blast radius, as documented.** "When an isolate exceeds 128 MB, the Workers runtime lets in-flight requests complete and creates a new isolate for subsequent requests. During extremely high load, the runtime may cancel some incoming requests to maintain stability." The offending invocation fails (`exceededMemory`) and that message is not enacted; what Email Routing then tells the sending MTA is not measured here. Unrelated proxy traffic pays an isolate replacement on that machine and, under extreme load, possible cancellations — the "disrupt unrelated proxy traffic" in the review is real and bounded to that. + +**Options weighed.** (i) Rely on the transport cap — it does not bound, per the table. (ii) Return a string, `Uint8Array` or `RpcTarget` and check length before parsing — cooperative only: a hostile entrypoint returns the array anyway and the deserializer accepts it. (iii) Return a `ReadableStream` — same objection; there is no lazy deserialization. (iv) Real bounds, all architectural: (a) call the tenant over `stub.fetch()` with a reserved path, so the result is a `Response` whose body the router reads under a byte cap no matter what the tenant does — a framework contract change that inherits the `/__void/*` shadowing hazard; (b) move the dispatch into a dedicated email-dispatch worker, so an eviction empties an isolate serving only inbound mail; (c) dispatch email with a lower `cpuMs` than HTTP — a soft, hardware- and plan-dependent bound that gives up the deliberate limits parity with the HTTP router. None fits a bug-fix round; handed back under [Open decisions](#open-decisions). + +#### Outbound flow + +``` +user code → sendEmail() + ├─ 1. Build MIME locally (mime.ts, hardened: comma-quoting, foldHeader + │ quote-aware, In-Reply-To/References reserved against user input, + │ bare addr-spec rejects comma/semicolon/quotes) + ├─ 2. Default `from` to +noreply@mail.void.cloud if + │ omitted (slug shape validated; reject @/+/whitespace) + ├─ 3. POST __VOID_PROXY /email/send + │ body: { + │ projectId, aiToken, // HMAC-authenticated by middleware + │ from, recipients, // dedup'd, lowercased + │ mimeBase64, byteSize, + │ requestId, fromDomain + │ } + │ + │ The proxy: + │ a. Decode + size-check (≤ 10 MB) + │ b. Suspend kill-switch (email-suspended:) + │ c. Read email-allowlist: KV; partition recipients + │ into allowed / unverified + │ d. Burst + monthly counter against allowed.length only + │ (denied-by-allowlist sends don't burn quota) + │ e. EmailMessage.send() per allowed recipient via the proxy's + │ account-level send_email binding + │ f. Inline log + return SendEmailResult-shaped JSON + └─ 4. Pass response through unchanged — the framework just maps + unknown error codes to UPSTREAM_ERROR for forward-compat. +``` + +The user worker no longer has a `send_email` binding. Direct +`env.SEND_EMAIL.send()` returns undefined-of-undefined and faults — no +quota bypass surface. + +#### Inbound flow + +``` +external sender → mail.void.cloud MX + → CF Email Routing catch-all rule on mail.void.cloud + → email-router worker (platform/packages/proxy/src/email-router.ts) + ├─ 1. Parse local-part: + + ├─ 2. Look up project by slug (ROUTING KV) + ├─ 3. Pre-buffer raw to Uint8Array; serialize envelope + ├─ 4. await dispatch.get(workerName).email(envelope) → EmailAction[] + ├─ 5. Enact each action on real ForwardableEmailMessage + │ (setReject is terminal; otherwise forward/reply in order) + └─ 6. ctx.waitUntil(log-recv → __VOID_PROXY) +``` + +#### `+` mechanics + +Outbound: framework reads `env.VOID_PROJECT_SLUG` (injected at deploy) and rewrites `from` if omitted. + +Inbound: the router parses the local-part. `` is everything before the first `+`; `` is everything after. The full original `to` address (`+@mail.void.cloud`) is preserved in the envelope, so the framework dispatcher matches against `email/.ts` handlers (or `_default.ts`) — the slug part is invisible to user handlers. + +Trade-off: the user's `email/support.ts` handler is reached via `+support@mail.void.cloud`, which is awkward UX-wise compared to `support@`. Documented as the shared-sender reality; custom domains (future work) give clean apex addresses. + +### Local development + +#### Dev inbox (outbound capture) + +A dev-only Vite plugin (`plugin-email-dev-inbox.ts`) registers `/__void/inbox` routes. When `sendEmail` is called under `void dev`, it doesn't touch the binding — instead: + +1. Builds the MIME as normal (so MIME bugs surface locally) +2. Stores the message in an in-memory ring buffer (cap: 100 messages, FIFO eviction) +3. Returns `{ ok: true, ids: [{ recipient, messageId: "dev_" }, ...] }` + +Routes: + +- `GET /__void/inbox` — HTML list view, newest first +- `GET /__void/inbox/:id` — rendered HTML body in a sandboxed iframe + headers + attachments +- `GET /__void/inbox/:id/raw` — raw `.eml` download +- `DELETE /__void/inbox` — clear the buffer + +The buffer survives HMR but not full dev-server restart. **Production safety:** the middleware is registered behind `import.meta.env.DEV` (tree-shakes out of production) AND handlers re-check a runtime dev flag. + +**Per-call opt-out:** `sendEmail({ ..., __sendInDev: true })` bypasses the inbox and sends for real. Per-call rather than env-var-gated so a single "test the real thing" send doesn't require restarting the dev server. + +**Round-trip dispatch (pending).** The inbox UI will grow two affordances: a "Deliver to local handler" button on each captured message (when the recipient's local-part matches a local `email/...` handler) and a "Paste / upload `.eml`" form that runs raw messages through the dispatcher. + +#### Test harnesses — `void/email/testing` + +```ts +import { createEmailTestHarness, createInboundTestHarness } from 'void/email/testing'; + +// Outbound +const outbound = createEmailTestHarness(); +await sendEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 'hi', text: 'yo' }); +expect(outbound.messages[0].subject).toBe('hi'); + +// Inbound +const inbound = createInboundTestHarness({ + routes: { 'support+[ticket]': supportHandler, _default: defaultHandler }, +}); +const result = await inbound.deliver({ + from: 'user@example.com', + to: 'support+ticket-7@app.dev', + subject: 'help', + text: 'I have a problem', +}); +expect(result.handler).toBe('support+[ticket]'); +expect(result.params).toEqual({ ticket: '7' }); +expect(inbound.replies).toHaveLength(1); +``` + +Both work in Vitest without any worker-runtime mocking. The inbound harness uses the same precedence rules + envelope/action contract as production. + +### Platform infrastructure + +#### Default sender setup (`mail.void.cloud`) + +One-time platform infrastructure, per environment (see [`meta/operations.md`](../operations.md#email) for the runbook). _(corrected 2026-09-11)_ + +- The mail domain is a **subdomain of the existing `void.cloud` zone** — `mail.void.cloud`, `mail.staging.void.cloud` — added through Email Routing's subdomain setting. `void.cloud` already has Email Routing on. No zone registration, no nameserver change. As of 2026-09-11 neither subdomain has any DNS record. +- Cloudflare adds and locks MX, SPF and DKIM on the subdomain. +- The same name is onboarded to **Email Sending**, which adds the `cf-bounce` MX, SPF, DKIM and DMARC records and lifts the 200-destination account cap. +- Catch-all routing rule on the subdomain → `void-proxy` worker (the proxy's `email()` export is bound by the `email-router.ts` module). Wrangler has no catch-all command; it is the dashboard or `PUT /zones/{z}/email/routing/rules/catch_all`. +- Standard `pnpm run deploy` ships the proxy with the new code. + +Per-project setup: nothing. Every project deployed with the new framework codegen is automatically reachable, and the hourly rollover rewrites every project's `email-slug:` mirror so older projects are covered within an hour of the api deploy. Existing pre-`WorkerEntrypoint` deployments will hit the "no email handler" reject path; they need to redeploy. _(2026-09-15)_ The five email-identity slugs (`noreply`, `postmaster`, `abuse`, `bounce`, `security` — `EMAIL_RESERVED_SLUGS` in `api/src/reserved-slugs.ts`, folded into `RESERVED_SLUGS`) joined the reserved list only with this feature, so a project registered under one of them earlier still holds it and the create-time check never sees it; the email path therefore refuses the slug on its own — the hourly backfill writes no `email-slug:` mirror for it and deletes one written before the reservation (the migration), the proxy answers `/email/send` with `INVALID_FROM` for it whatever the mirror says, and the inbound router rejects mail addressed to it before the routing read — so a legacy `noreply` project can neither send as the platform's verification sender nor receive its bounces. + +**Reputation pooling.** All `*.void.app` projects share `mail.void.cloud`'s sender reputation. Mitigations: + +- Conservative default outbound quotas +- Burst-rate cap per project (sliding-window in proxy) +- Manual suspend kill-switch in the admin UI + +Per-project DKIM signing isn't possible on the shared domain, so reputation pooling is unavoidable there. _(scope change 2026-09-11)_ A project with reputation-sensitive sending brings its own Cloudflare zone — [Custom domains on Cloudflare](#custom-domains-on-cloudflare) — and signs with its own domain. + +#### Inbound router worker (`platform/packages/proxy/src/email-router.ts`) + +Bound as the proxy worker's `email()` export. Receives all inbound mail at `mail.void.cloud`: + +1. Parses `+` from `message.to` +2. Looks up the project's worker name in `ROUTING` KV (same KV the HTTP dispatcher uses) +3. Pre-buffers `message.raw` to bytes, serializes the envelope +4. Calls `dispatch.get(workerName).email(envelope)` — RPC into the user worker +5. Awaits `EmailAction[]`, enacts each on the real `ForwardableEmailMessage` +6. Async logs to `__VOID_PROXY` `/email/log-recv` via `ctx.waitUntil` + +Distinct error shapes for clarity in tail logs: + +- `setReject('mail.void.cloud: missing project slug')` — empty slug +- `setReject('no project found for slug "X"')` — KV miss +- `setReject('project "X" has no worker (static deploy)')` — static-only deploy +- `setReject('project has no email handler')` — pre-`WorkerEntrypoint` deploy needs redeploy +- `setReject('project handler error')` — generic dispatch failure + +#### `__VOID_PROXY` metering + +Reuses the unified service binding pattern the AI proxy + revalidation already use (`__VOID_PROXY` + `__VOID_PROXY_TOKEN` + `__PROJECT_ID`). The same proxy worker also handles `/ai/*`, `/d1/*`, `/kv/*`, `/r2/*`, `/revalidate`, and now `/email/*`. + +Auth pattern: caller sends `{ projectId, aiToken, ... }` JSON body; the proxy's middleware verifies `aiToken` is HMAC of `projectId` with the shared `JWT_SECRET`. Same auth as every other proxy route — no email-specific token plumbing. + +Three endpoints in `platform/packages/proxy/src/email.ts`: + +- `POST /email/check-quota` — pre-flight outbound quota check. Returns 429 if denied; suspend kill-switch denied first +- `POST /email/log-send` — async per-recipient delivery log +- `POST /email/log-recv` — async inbound delivery log (called by router worker, not user code) + +#### Quotas + suspend + +Counters live in `ROUTING` KV under monthly buckets: + +| Key | Purpose | +| ----------------------------------------- | -------------------------------------------------------- | +| `email-sent::` | Monthly outbound counter | +| `email-recv::` | Monthly inbound counter | +| `email-burst::` | Per-minute outbound counter (TTL 5m) | +| `email-limit:` | Per-project monthly outbound override (default 200) | +| `email-burst-limit:` | Per-project per-minute burst override (default 10) | +| `email-suspended:` | Suspend kill-switch — non-null value blocks all outbound | + +Default plan limits (placeholders pending product input): + +| Plan | Outbound / month | Inbound / month | Burst / minute | +| ----- | ---------------- | --------------- | -------------- | +| Free | 200 | 1,000 | 10 | +| Hobby | 10,000 | 50,000 | 60 | +| Pro | 100,000 | 500,000 | 600 | + +The shape is what matters; the numbers will move with real usage data. Inbound is metered separately (users don't trigger it; attackers do; reasonable inbound volumes are higher than outbound) and overage logs but doesn't reject — hard inbound rejection requires CF rule changes that risk silent drops. + +**Both quota counters are best-effort, and the monthly cap is not a backstop for the burst cap.** Every counter here is a KV read-modify-write. Concurrent isolates read the same value and overshoot by `(parallel_isolates × per_request_recipients)`, and the wider monthly window does not make that safer: KV accepts at most one write per second per key, so above ~1 send/second the month counter stops advancing for the rest of that second and every request in that window charges against the same stale read. KV answers the rejected write with a 429, which the proxy's `.catch()` swallows. The overshoot therefore scales with request rate and degrades the monthly cap by the same factor as the burst cap. + +What the caps do buy is a bound on accidental floods and a cheap reject for the ordinary case. The actual abuse control is the suspend kill-switch — `email-suspended:` plus the platform-wide `routing.suspended` marker — which is read uncached on every send so a suspend converges immediately. Making either cap strict means moving the counter into a Durable Object (or fronting it with CF rate limiting); that is a platform architecture decision and is deliberately out of scope for this design. + +CF caps message size at ~25MB raw / ~10MB base64-inflated for `send_email`. The framework enforces that ceiling client-side and reports as `MIME_ERROR` before contacting the proxy. + +#### Verified destinations + +CF's `send_email` only delivers to addresses verified as destinations on the sender account, so the platform manages a per-project allowlist on top of CF's account-wide list: + +- **Schema** — `email_destinations` table (`id`, `project_id`, `address`, `status: 'pending' | 'verified' | 'failed'`, `cf_destination_id`, `verified_at`). +- **CF API client** — `platform/packages/api/src/provisioning/email-routing.ts` wraps `POST/GET/DELETE /accounts/:id/email/routing/addresses`. +- **API routes** — `GET|POST|DELETE /projects/:id/email/destinations[/:id]`. POST triggers CF to email the recipient with a verification link; GET refreshes status from CF for any rows still `pending`. +- **Auto-verify project owner** — `platform/packages/api/src/routes/projects.ts` calls `addDestination(user.email)` when a project is created (via `ctx.waitUntil`, non-blocking). The owner gets the verification email in their GitHub-OAuth-linked inbox; one click and their project can send to itself out of the box. +- **Per-project allowlist enforcement in the proxy** — _(re-land)_ the user worker carries no `send_email` binding. The api mirrors each project's verified rows into `email-allowlist:` in `ROUTING` KV (`provisioning/email-allowlist.ts`), and the proxy checks every send and every inbound reply against that mirror. Both revoke paths tighten the mirror before deleting the row, and the owner-email change tightens it inside the transaction that demotes the inherited rows, before the commit. Destinations are account-wide on CF's side; the mirror is what makes consent per project. _(2026-09-15)_ Project teardown fails closed (503, row kept, retry) on the `email-slug:` / `email-allowlist:` deletes, slug first, before any best-effort cleanup: `__VOID_PROXY_TOKEN` never expires and the proxy reads a missing routing blob as "not suspended", so after the cascade those two keys are all that stops a retained token from sending. +- **Cross-project safety on delete** — destinations are CF account-wide; before calling `deleteDestination` we check `countProjectsUsingDestination` and only remove from CF if no other project references the same `cf_destination_id`. + +The platform/team never opens the CF dashboard. The recipient (the person receiving the verification email) is the only human in the loop, and they only click a link in their own inbox. For every address except the owner's own, that mail is **ours** — HMAC-signed link, sent from `noreply@`, resendable — because Cloudflare's verification is per account rather than per project, and Cloudflare has no API to resend its mail. + +#### Admin UI — `platform/packages/api/src/admin/routes/email.tsx` + +Per-project page at `/admin/email/:projectId`: + +- Usage summary (sent / received / limit / burst cap / status) for the current month +- Suspend toggle (`email-suspended:`) +- "Set monthly limit" + "Set burst limit" forms (`email-limit:*` / `email-burst-limit:*` overrides) +- "Reset counter" button for the current month +- Verified-destinations table with add / remove + +Mirrors the existing admin route patterns (e.g. `platform/packages/api/src/admin/routes/deployments.tsx`). Direct KV/DB reads + writes; no API layer between admin and storage. An environment-level page beside it is designed in [Operator surfaces](#operator-surfaces). + +#### CLI — `void email` + +``` +void email usage # monthly outbound/inbound counts + quota +void email logs [--limit N] # recent email delivery activity +void email destinations # list verified recipients +void email allow
# add recipient + trigger CF verification +void email disallow
# remove recipient +``` + +Hits the API routes above. Output formatted for the terminal — useful for power users debugging "did my mail go out?" or wiring a new recipient without an admin trip. _(measured 2026-09-11)_ None of these print the project's own address; `void email status` in [Developer surfaces](#developer-surfaces) is the fix. + +## Hosting modes and lifecycle + +_(added 2026-09-11)_ + +Void projects run in three places, and email has a different shape in each: + +| Mode | Who owns the Cloudflare account | Mail domain | Who does the zone work | +| ------------------------------------------------------- | ------------------------------- | --------------------------------------- | -------------------------------- | +| **A. Managed platform** — `void deploy` | VoidZero | `mail.void.cloud` (one per environment) | VoidZero, once | +| **B. Own account** — `void deploy --backend cloudflare` | the developer | a zone the developer owns | the developer, helped by the CLI | +| **C. Self-hosted platform** | the company | `mail.` (one per environment) | the company's operator, once | + +One rule holds in all three: **a developer never sees a Cloudflare object.** +No zone, no routing rule, no destination id, no token scope. A developer sees +an address, a list of recipients, a counter, and an error code. Everything +else belongs to whoever owns the account — VoidZero in A, the developer's own +`wrangler login` session in B, the company's operator in C. + +Three findings changed the plan and are worth stating up front: + +1. **A `wrangler login` session already carries `email_routing (write)` and + `email_sending (write)`**, and wrangler 4.107+ ships a full `wrangler email` + command group. Mode B needs no new token and no direct API client for most + of the setup. _(measured)_ +2. **Verified destinations are capped at 200 per Cloudflare account, shared + across every domain and every project.** Onboarding the mail domain to + Email Sending removes that gate; per-project consent stays in our own + allowlist. +3. **Nothing today tells a developer their own address.** Not the CLI, not + the dashboard, not the API, and the two bindings are neither typed nor + injected in `void dev`. _(measured)_ This is the single largest UX gap and + it is the same gap in all three modes. + +### The lifetime + +``` + DEVELOP DEPLOY CONFIGURE VERIFY OPERATE TEARDOWN + ─────── ────── ───────── ────── ─────── ──────── +developer void dev void deploy add recipient void email status void email usage void project delete + dev inbox prints the (consent link) send a test dashboard Email tab → destinations + harnesses address map error codes revoked, rows + gone +operator — — one-time env setup health checklist kill switch delete catch-all +(A, C) (script → admin) send + receive caps, suspend (stops inbound) + probe +own account void dev void deploy prompt on the wrangler tail CF dashboard wrangler email +(B) dev inbox --backend cf first deploy address map routing disable + detects state (wrangler email …) +``` + +The developer lane is the same in all three modes. That is deliberate: the +address changes, the commands do not. + +### Personas + +**Developer.** Writes `sendEmail(...)` and `email/*.ts`. Wants to know: what is +my address, who can I send to, did it work. Must never need a Cloudflare +account (A, C) or a Cloudflare concept (B). + +**Operator.** Owns the platform account in A or C. Does the zone work once +per environment, watches health, holds the kill switch. Is a human at +VoidZero in A and a platform admin at the company in C. + +**Own-account developer (B).** Both roles in one person, but the split still +holds in time: the setup prompt happens once, then they are a developer. + +### Mode A — managed platform + +#### Developer surfaces + +The address comes first, on every surface, because it is the thing nobody +can find today. + +**`void email status`** — new, and the default subcommand when none is given: + +``` +Sender acme+noreply@mail.void.cloud +Inbound acme+@mail.void.cloud + email/support+[ticket].ts ← acme+support+@… + email/_default.ts ← everything else +Recipients alice@acme.dev verified + ops@acme.dev pending — resend? void email allow ops@acme.dev --resend +This month 12 / 200 sent · 3 received +``` + +Backed by `GET /projects/:id/email/usage` gaining `domain`, `sender`, and +`inbound` fields. `usage`, `destinations`, `allow`, `disallow`, `logs` stay. +A project with a custom domain gets a second block per domain — sender, +inbound map, and the domain's status — and `void email domain add | status | +list | remove` beside them. + +**`void deploy`** prints the inbound map at the end whenever `email/` handlers +ship. That is the moment the developer cares: + +``` +◆ Deployed acme +│ Inbound email +│ acme+support+@mail.void.cloud → email/support+[ticket].ts +│ acme+*@mail.void.cloud → email/_default.ts +``` + +**Dashboard → project → Email.** Same data as `void email status`, plus +"Add recipient" and "Resend". This section is small, follows the existing +per-section loader fan-out on the project page, and is the only place a +non-CLI user can add a recipient. + +**`void dev`.** Inject `__VOID_PROJECT_SLUG` and `__VOID_EMAIL_DOMAIN` from the +linked project, and type both in `.void/env.d.ts`. See [Known gaps](#known-gaps). + +#### Operator surfaces + +**One-time environment setup, as a script first.** The seven manual steps in +`operations.md` become `vp run email:setup --env --check | --apply` +in the platform repo. `--check` runs the probes below and prints a +checklist; `--apply` runs the idempotent fixes. It exists because VoidZero's +own staging needs it now — the mail domains do not exist yet — and because +the admin page reuses the same module. + +**`/admin/email`, environment level.** Today `/admin/email/:projectId` is +per-project. Add the environment page beside it, shaped like +`signup-control.tsx`, and feed its rows into `/admin/health` so the banner +shows email: + +| Row | Probe | Fix | +| ----------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| domain agrees | api `EMAIL_SENDER_DOMAIN` vs proxy `/health` (make the boolean a string) | edit wrangler vars | +| routing ready | `GET /zones/{z}/email/routing` → `status` | `POST …/routing/dns` | +| catch-all → proxy | `GET …/rules/catch_all` names `void-proxy[-staging]` | `PUT …/rules/catch_all` | +| `send_email` bound | proxy `/health` `sendEmail` | redeploy proxy | +| Email Sending onboarded | `GET …/sending/subdomains/{id}/dns/status` | `POST …/sending/subdomains` (permission group undocumented — see [Open questions](#open-questions)) | +| slug mirror | count of projects lacking `email-slug:` | already self-heals hourly; button runs it now | +| token scope | a 403 on any read above, mapped to the permission's name | extend `CF_API_TOKEN` | + +Plus **Send test mail** to the operator's address, and **Suspend all +sending**, which already exists as the `routing.suspended` marker. + +### Mode B — the developer's own Cloudflare account + +#### What happens today _(measured)_ + +``` +void deploy --backend cloudflare + email/ handlers + sendEmail() + │ + ├─ deploy no warning · no send_email binding · no email vars + ├─ outbound sendEmail() → BINDING_MISSING + └─ inbound handler runs, then every action is LOST + support+T-42@zone → _default.ts (wrong handler: the + dispatcher strips after + the first `+` unconditionally) + setReject / forward / reply → returned as RPC data → CF ignores it + → message ACCEPTED, then DISCARDED. No bounce. No log. +``` + +The inbound half is worse than unsupported. Cloudflare delivers, the worker +reports success, and the mail vanishes. + +#### Runtime changes (framework, ~200–250 lines) + +``` +sendEmail ──► dev inbox? ──► env.SEND_EMAIL? ──► __VOID_PROXY? ──► BINDING_MISSING + │ NEW + └─ import('cloudflare:email') → one EmailMessage per recipient +``` + +1. **Direct `SEND_EMAIL` transport** in `runtime/email.ts`, tried after the dev + inbox and before the proxy. The MIME builder is shared. +2. **Provision the binding.** `computeDesiredCloudflareResources` adds + `send_email: [{ name: "SEND_EMAIL" }]` to the root `wrangler.jsonc` when + `needsEmail`. Root entries already pass through the verifier verbatim. +3. **Sender default.** `void.json` gains `email.from`. Replies default to + `message.to` when there is no slug — in this mode `message.to` is a real + address on the developer's own zone. +4. **No-prefix inbound.** When no `__VOID_PROJECT_SLUG` is configured (an + own-account worker), the dispatcher matches the full local part, so + `support+T-42@zone` reaches `email/support+[ticket].ts` — once the zone's + subaddressing setting is on; see below, it is off by default. +5. **Enact actions on a native message.** When the envelope is a real + `ForwardableEmailMessage`, apply reject / forward / reply on it instead of + returning them. +6. **Deploy guidance** — the detection and prompt below. + +#### One keystroke, measured _(rewritten 2026-09-11 after the deep research)_ + +Three facts, all read from wrangler's source and Cloudflare's OpenAPI schema +this day, change the shape of Mode B from "prompt, then a dashboard visit" +to **one Enter on the first deploy, zero on the next**: + +| Fact | Where it was read | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `wrangler auth token --json` is a stable, documented command "for use with other tools and scripts" (since 4.57.0). It prints the session's OAuth bearer, refreshed if expired. | wrangler 4.107/4.130 `cli.js` `authTokenCommand` (`status: "stable"`); docs `workers/wrangler/commands/general` "auth token". Measured: `{type:"oauth",token:"cfoa…"}` | +| That bearer works against `api.cloudflare.com` like any token. Keyring storage (`default.enc`) is **opt-in**; the default is plaintext `default.toml`. | measured `GET /zones`, `GET /zones/{z}/email/routing/*` with it; `cli.js` `wantsKeyring = envOverride ?? isKeyringEnabled() ?? false`; CHANGELOG 4.107.0 (#14156) | +| wrangler ≥ 4.113 makes `wrangler deploy` **reconcile Email Routing rules** from a top-level `addresses` array: literal → this worker, `*@apex` → catch-all, via `POST /accounts/{a}/email/routing/rules/plan`. | CHANGELOG 4.112.0 (#14470) + 4.113.0 (#14471); 4.130 `cli.js` `applyEmailRoutingAddresses`; OpenAPI operation `email-routing-routing-rules-plan-account-routing-rules` (GA, `x-forge-hidden`) | + +And one fact that breaks an assumption this document made: +**subaddressing is a per-zone setting, OFF by default.** `GET +/zones/{z}/email/routing` reads `support_subaddress: false` on `void.cloud`, +`void.app` and a third zone. A literal rule for `support@zone` does **not** +match `support+T-42@zone` until the zone's setting is on (`PATCH +/zones/{z}/email/routing { support_subaddress: true }`, `Zone Settings +Write`; no wrangler command). Mode A is unaffected — its catch-all takes +everything — but Mode B's flagship `email/support+[ticket].ts` needs the +setting flipped, and the one-Enter flow does that. + +``` +$ void deploy --backend cloudflare + + Email in use email/support+[ticket].ts · email/_default.ts · sendEmail() in 2 files + domain mail.acme.com (void.json email.from) + zone acme.com account Acme (f721b8e5…) · session dev@acme.com + apex MX aspmx.l.google.com left alone — mail lives on the subdomain + routing mail.acme.com not enabled · subaddressing off + sending mail.acme.com not onboarded + rules support@mail.acme.com → acme-support (absent) + binding SEND_EMAIL (absent) + + This changes YOUR Cloudflare account. acme.com itself is not touched. + + enable Email Routing on mail.acme.com Cloudflare writes and locks 3 MX + 1 SPF record there + + turn on subaddressing for acme.com support+anything@ reaches support@ + + onboard mail.acme.com for Email Sending MX/SPF/DKIM on cf-bounce.mail.acme.com, _dmarc.mail.acme.com (p=reject) + + wrangler.jsonc send_email: [{ name: "SEND_EMAIL" }], addresses: ["support@mail.acme.com"] + + routing rule (applied by wrangler deploy) support@mail.acme.com → acme-support + ! email/_default.ts a catch-all exists only on an apex; other @mail.acme.com mail bounces + +◆ Set up email on mail.acme.com? ● Yes / ○ No ⏎ ← the one keystroke + + ✔ routing mail.acme.com enabled · subaddressing on + ✔ sending mail.acme.com onboarded + ✔ config wrangler.jsonc updated + … + Email Routing plan: ← wrangler's own output + mail.acme.com + + support@mail.acme.com -> worker (acme-support) + Email Routing addresses applied. + + ✔ deployed acme-support + inbound support@mail.acme.com → email/support+[ticket].ts + outbound sendEmail() from support@mail.acme.com +``` + +Second deploy, nothing changed: every row reads ready, no prompt, no +writes, `Email Routing rules are up to date.` Zero keystrokes. + +#### The flow, by lane + +``` +developer void CLI (deploy.ts, step 3b-ii — before any project code runs) void's pinned wrangler Cloudflare API dashboard +───────── ──────────────────────────────────────────────────────────────── ───────────────────── ────────────── ───────── +deploy ───▶ needsEmail · scanEmailHandlersSync · domain = host(email.from) + PREFLIGHT, read-only + resolveMx(domain), resolveMx(apex), resolveTxt(_dmarc.*) ── public DNS + whoami --json ──────────────────────────────────────────▶ tokenPermissions has email_routing:write + email_sending:write? + email routing settings ────────────────────────▶ GET /zones?name= · GET …/email/routing ──▶ Status · support_subaddress + email routing rules list ──────────────────────▶ GET …/rules · …/rules/catch_all ──▶ mine · foreign · absent + email sending settings ──────────────────────▶ GET …/email/sending/subdomains ──▶ Enabled · not found + checklist +[Enter] ◀── confirm(default Yes) CI/non-TTY: checklist + "run `void email setup`", deploy without email + APPLY, only rows not ready + [apex] email routing enable ───────────────────▶ POST …/email/routing/enable {} ──▶ MX ×3 + SPF, locked + [sub] auth token --json (env WRANGLER_WRITE_LOGS=false) ─▶ {type,token} held in a closure, seconds + fetch POST /zones/{z}/email/routing/dns {name:"mail."} ─────────────────────────▶ MX ×3 + SPF on the subdomain [UNVERIFIED] + fetch PATCH /zones/{z}/email/routing {support_subaddress:true} ───────────────────────▶ subaddressing on [UNVERIFIED] + non-2xx → print the Settings → Subdomains line; withhold `addresses` this run ─────────────────────────────────────▶ (fallback only) + [send] email sending enable ─────────────────▶ POST …/email/sending/subdomains {name} ──▶ cf-bounce MX/SPF/DKIM + _dmarc + write wrangler.jsonc: send_email [{name:"SEND_EMAIL"}] · addresses [...]; re-parse rootConfig + build (send_email + addresses pass through to dist/ssr/wrangler.json; verifier pins `addresses` whole-object) + wrangler deploy ─────────────────────────────────────────▶ PUT script (send_email in metadata) + POST /accounts/{a}/email/routing/rules/plan {owner_worker_tag, rules, catch_all_rules} + POST …/rules (literal → worker, source:wrangler) · PUT …/rules/catch_all (*@apex) + print the address map (never visited on the happy path) +``` + +Rules that hold the whole thing together: + +- **Void never refreshes, stores or forwards a token.** Everything with a + wrangler command goes through void's own pinned wrangler (the deploy + runner, `createDeployWranglerRunner`, `assertUploaderStillPinned`). The + session bearer is borrowed through `wrangler auth token --json` for the + two calls that have no command — subdomain routing enable and the + subaddressing PATCH — inside one closure, and dropped. The child gets + `WRANGLER_WRITE_LOGS=false`: without it, wrangler appends the token to + `~/.wrangler/logs/wrangler-*.log`, mode `-rw-r--r--` (read in + `shouldLogToDisk`). This reverses the earlier "REST from Void must use + `CLOUDFLARE_API_TOKEN` only" rule, on purpose. +- **Slot 3b-ii**: after the bundle hash, before the env-schema probe, so no + project code has run when a credentialed call is made, and `rootConfig` + is re-parsed after the write so the laundering guard needs no exception. +- **Rules and catch-all are wrangler's job.** Void derives `addresses` from + the `email/` scan and writes the root `wrangler.jsonc`; the existing + `wrangler deploy` applies the plan. Added and updated changes apply + silently; deleted and conflict changes get one default-No prompt in a + TTY and a `UserError` in CI — after the upload. Void prunes foreign + addresses at preflight so the plan is never destructive on its account. +- **`addresses` is written only when routing is ready for every listed + domain, and rewritten every run** — deleted (never `[]`, which deletes + every owned rule) when a later preflight finds routing off, with the + reason printed. Otherwise the plan 400s with `2062` after the upload on + every deploy. +- **Derivation.** `email/.ts` and `email/+[tag].ts` → + `@`. `email/_default.ts` → `*@`, apex path only. + `email/[name].ts` (dynamic local part) needs a catch-all, so it is + **refused on a subdomain** with a message. +- **`send_email: [{ name: "SEND_EMAIL" }]`** is written only when routing is + ready or sending is onboarded — a bare account never carries the binding, + because what `wrangler deploy` does with it there is unmeasured. Under + `void dev`, `mergeBindings` strips it so the dev inbox stays the only + sink. +- **`addresses` joins `DEPLOY_WHOLE_OBJECT_PINNED_FIELDS`.** Today it fails + open: the plugin's config customizer passes unknown keys through + (reproduced), so a build could redirect the account's inbound mail via + `dist/ssr/wrangler.json`. +- **The interactivity gate must match wrangler's**: `ci-info.isCI || +!(stdin.isTTY && stdout.isTTY)`. Void's `isCI` (stdin only, or + `VOID_TOKEN`) disagrees when stdout is piped or `CI=1` is exported, and + the two halves of one deploy would then call the same run interactive and + not. + +#### Refusals and degraded branches + +| Branch | What the developer sees | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| no `email.from` | `add "email": { "from": "you@mail.acme.com" } to void.json` — deploying without email | +| apex has foreign MX, `email.from` on the apex | `acme.com already receives mail (aspmx.l.google.com). Void never enables routing over live mail. Use something@mail.acme.com.` stop | +| session predates 4.81 (no email scopes) | `wrangler login` (one browser Allow) — stop | +| routing status `unlocked` / `misconfigured*` | print the status, stop — never re-lock records a developer unlocked | +| `_dmarc.` / `cf-bounce.` TXT exists | refuse sending onboarding; onboarding writes its own `_dmarc` `p=reject` and the API 409s (`dmarc.multiple`, `dkim.conflict`…) | +| sending enable fails (Free) | `Workers Paid needed for arbitrary recipients. Inbound works; sendEmail() to verified destinations only.` continue without it. 2026-09-14: the `send_email` binding written alongside remembers the refusal — settled on later deploys, for `--require-email` and `void email status`; only `void email setup` offers the retry | +| subdomain POST refused | one-time dashboard step printed (`Email → Settings → Subdomains → add mail.acme.com`); `addresses` withheld this run | +| rule on my address owned elsewhere | address pruned from `addresses`, reported; wrangler's plan stays non-destructive | +| existing `send_email` entry not named `SEND_EMAIL` | refuse with the rename — a second binding would be silent | +| zone in another account / not on Cloudflare / partial zone | distinguished via `GET /zones?name=` without `account.id`; `partial`/`secondary` refused | +| CI / non-TTY, not ready | checklist + `run void email setup once, commit wrangler.jsonc, redeploy`; `--require-email` fails instead. `--provision` is NOT an escape: it fails closed in CI | + +#### Human steps that remain, and why + +1. One Enter on the first deploy — it changes their account and locks DNS. +2. `wrangler login` once, ≥ 4.81 — the session is the credential. +3. `email.from` typed once — wrangler cannot enumerate zones (`email +routing list` shows routing-enabled zones only); auto-pick is decision 9. +4. Workers Paid for `sendEmail()` to arbitrary recipients — billing is + dashboard-only. +5. A verification click only when a handler `forward()`s to a destination. +6. The Subdomains form only if the subdomain POST is refused. +7. A y/n on destructive rule changes — wrangler's own semantics. + +#### Build list + +Land first, own PR: bump `wrangler` (`pnpm-workspace.yaml:30`, +`^4.107.0` → ≥ 4.113, recommend the `@cloudflare/vite-plugin` 1.54.7 / +wrangler 4.131 pair so one wrangler exists in the tree; minimum pair +1.46.0 / 4.113.0). Re-fixture the `--json` parsers and deploy flags; add a +bundled-`cli.js` grep test for `Email Routing plan:` and `authTokenCommand`. + +Feature PR, `packages/void/src`: + +| File | Change | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli/email-cloudflare.ts` _(new)_ | preflight (DNS probes, prose parsers pinned to the shipped wrangler with fixtures), `addresses` derivation, conflict pruning, checklist + address-map renderers, apply steps, `fetchWithSessionBearer` — the only function that ever sees the token; error map (2062, 2040, 2036/10105) | +| `cli/deploy.ts` | `computeDesiredCloudflareResources` returns `needsEmail` + handlers; step 3b-ii; `addresses` in `DEPLOY_WHOLE_OBJECT_PINNED_FIELDS` and `verifyGeneratedDeployConfig`; `--require-email`; address map after deploy; wrangler-compatible interactivity gate | +| `cli/wrangler.ts` | `ProvisionedWranglerPatch` gains `sendEmail` (name-keyed upsert) and `addresses` (whole-array); comment-preserving jsonc writes; registry entry `addresses: whole-object` | +| `cli/deploy.ts` runner | `runWranglerAuthToken()` — capture, `WRANGLER_WRITE_LOGS=false`, stdout never logged, never placed in env | +| `cli/email.ts` | `void email setup [--backend cloudflare]`; Mode B block in `void email status` | +| `config.ts`, `schema.json` | `email.from` | +| `index.ts` `mergeBindings` | strip `send_email` under serve | +| `runtime/email.ts`, `router/compile.ts` | the runtime items 1, 4, 5 above | +| tests | derivation table; prose fixtures per wrangler version; 3b-ii ordering; `addresses` pin; token never in env/stdout; CI branches; 2040/2036; argv per branch | + +Upstream, in parallel: a workers-sdk PR adding `--subdomain` to `wrangler +email routing enable` and a `--subaddressing` switch; when the pinned +wrangler carries them, delete `fetchWithSessionBearer`. + +#### Still unverified — one staging session settles most of it + +1. `POST /zones/{z}/email/routing/dns { name: "mail." }` enables + routing on the subdomain, with the OAuth session, and what a repeat + POST does. The code issues it only when the apex zone already has + routing enabled; a disabled zone plus a subdomain `name` is a case the + code never sends and the probe should still record. Attested by the OpenAPI body field (`name`, not deprecated) + and a Support quote in `terraform-provider-cloudflare#5890`; never + executed. wrangler's own `routing enable` calls the **deprecated** + `POST …/routing/enable`; `…/routing/dns` is the current one. +2. `PATCH /zones/{z}/email/routing { support_subaddress: true }` with the + session (same permission group as 1). +3. The other writes on the session (`sending enable`, rules, catch-all) — + inferred from wrangler's own commands, never run here. +4. End-to-end `wrangler deploy --env= …` applying `addresses` from the + redirected `dist/ssr/wrangler.json`; only `--dry-run` on 4.107 was run. +5. How to READ "is `mail.` a routing subdomain": `dig MX` is empty + on `mail.void.cloud` today, `GET …/routing/dns?subdomain=` is now + formally deprecated, and the plan endpoint accepted a literal on an + unregistered subdomain as `added` — so the plan is not a readiness check. +6. Workers Free: what `sending enable`, `sending settings`, and a deploy + with a bare `send_email` binding do. `401 / 2036` on + `GET /accounts/{a}/email/sending/zones` was seen on one account with no + onboarded domain and an unread plan; the documented not-entitled code + is `403 / 10105`. Used as a hint line only. 2026-09-14: `sending settings` + answers not-found on Free and on Paid-never-onboarded alike, so no read + can tell them apart; the committed `send_email` binding — written only by + a run that already attempted `enable` — is the memory of a refusal + (binding present + sending pending = settled), the deploy never retries, + and `void email setup` offers the retry on its own prompt. +7. The API-token permission set for the plan endpoint (`x-api-token-group: +null`) — the CI story runs on `CLOUDFLARE_API_TOKEN`. +8. Two-label subdomains; what the plan reports after a dashboard edit of a + wrangler-owned rule; a worker rename (rules stay with the old tag → + `conflict`). + +#### Decisions handed back + +- **9. Domain input**: require `email.from` (recommended, zero writes to + `void.json`) vs. pick the zone through the bearer and write + `email.from = noreply@` back. +- **10. Apex path**: allow with the one Enter when apex MX is empty + (recommended; the checklist names the catch-all claim and `_dmarc` + `p=reject`) vs. require `--apex`. +- **11. Ship the session-bearer bridge now** behind the live-POST + verification, file the upstream wrangler PR in parallel, retire the + bridge when the pin carries it (recommended) vs. wait. +- **12. Two projects on one zone** (`addresses` is top-level only, one + catch-all per apex): per-environment `email.from`, or a hard refusal + when a desired address is already routed elsewhere instead of a prune. +- **13. Ownership of a hand-written `addresses` array**: Void owns the whole + array in Mode B (recommended; refuse when it holds entries Void did not + derive) vs. record managed entries and merge. + +### Mode C — self-hosted platform + +#### State today _(measured)_ + +Self-hosting is not a supported story in the repo. Every setup document is +written for VoidZero's own account. On the email path, three code sites pin +VoidZero hosts; everything else already follows `EMAIL_SENDER_DOMAIN`: + +| Site | What it pins | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `platform/packages/api/src/email/send-verification.ts:76-84` | the api host in the confirmation link | +| `platform/packages/api/src/email/send-verification.ts:90-95` | the proxy origin the api posts verification mail to | +| `platform/packages/api/src/email.ts:1` + `api/wrangler.jsonc:152` | the legacy `Void ` sender for welcome / invite / alert mail, on a different domain from `EMAIL_SENDER_DOMAIN` | + +The prose in `getting-started-prompt.txt` and `docs/guide/email.md` also names +`mail.void.cloud` literally, so an Acme developer would read the wrong +address. + +#### Design + +- **Config surface** — `EMAIL_SENDER_DOMAIN` on api and proxy, as today, plus + `API_ORIGIN` / `PROXY_ORIGIN` (or a service binding) for the two + hard-coded hosts, and the platform sender derived from + `EMAIL_SENDER_DOMAIN` so the legacy `noreply@void.cloud` folds into it. +- **Operator surfaces** — identical to Mode A: the setup script and the + environment-level admin page. The company's mail domain _is_ the platform + domain; nothing per project changes. +- **Developer surfaces** — identical to Mode A, with the address computed + from the environment's domain, never from prose. +- **Docs** — every literal `mail.void.cloud` in the guide and the prompts + becomes ``, and the CLI prints the real one. + +Per-project custom domains follow [Custom domains on Cloudflare](#custom-domains-on-cloudflare) +unchanged: the customer's zone, the customer's token, a subdomain when the +apex carries corporate MX. The company's mail domain remains the platform +domain for every project that brings no zone of its own. + +## Custom domains on Cloudflare + +_(scope change 2026-09-11 — this section replaces "Why custom domains aren't +included". The reasons that section gave still hold for the models it +rejected; a third model, which it dismissed on a premise this design no +longer keeps, is the one adopted here.)_ + +A project can send as and receive at its own domain — `support@acme.com`, +`noreply@mail.acme.com` — when that domain's DNS zone is on Cloudflare. The +zone stays in the customer's own Cloudflare account. The customer grants the +platform a scoped API token once; the platform configures the zone and runs +both directions on the customer's behalf. + +"On Cloudflare" means a zone under Cloudflare DNS in an account the customer +controls. A domain that only CNAMEs to Void through Cloudflare for SaaS is +**not** on Cloudflare in this sense and stays out of scope: Email Routing +needs MX records on a Cloudflare zone, and RFC 2181 forbids MX beside a +CNAME. Those customers keep the provider path in [Future Work](#future-work). + +### Why this model + +| Model | What it needs from the customer | Why it is or is not the one | +| ------------------------------------------------ | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| **Bring your zone — a scoped token** _(adopted)_ | create one API token, click one verification mail, one dashboard step for a subdomain | zone stays theirs; apex MX untouched; works on Free for routing, Paid for sending; the domain signs its own DKIM, so reputation is per customer | +| NS delegation of the whole domain to our account | move all of their DNS to us | we would own and have to manage every record they have; greenfield DNS UI | +| `mail.acme.com` as its own zone in our account | delegate one subdomain | Cloudflare's subdomain setup is **Enterprise-only** | +| Self-hosted SMTP relay | nothing | reinvents what Email Routing gives for free | + +0078 rejected the token model for violating "users never touch Cloudflare". +That premise is kept for the **managed platform's shared sender** and +dropped for a customer's **own domain**: the customer already touches +Cloudflare to own the zone, and the token is the same act of trust they give +`wrangler login`. The rule that survives is the one from [Hosting modes and +lifecycle](#hosting-modes-and-lifecycle): after the one-time grant, a +developer sees an address, recipients, a counter and an error code — never a +zone, a rule or a token scope. + +### The two paths + +A routing rule can only name a worker in its own account — the rule's action +value is a bare worker name, and worker names are unique per account — so +inbound from a customer zone cannot reach `void-proxy` directly. It crosses +accounts as a **forward** to an address we own, and outbound crosses back as +a **REST send** with the customer's token. + +``` +INBOUND support+T-42@acme.com + │ MX in the customer's account (mail.acme.com, or the apex when its MX is empty) + ▼ + catch-all rule: forward → acme+inbound@mail.void.cloud + │ one verified destination address in the customer's account, per project domain + │ SRS rewrites the envelope sender · ARC seal · `From:` kept · `To:` kept (probe) + ▼ + mail.void.cloud MX (our account) → catch-all → void-proxy router + │ recognises the bridge address, reads the original recipient from `To:` + ▼ + project worker email() to = support+T-42@acme.com viaDomain = acme.com + dispatcher runs in no-prefix mode (Mode B item 4) + +OUTBOUND sendEmail({ from: "noreply@acme.com", … }) + │ proxy: from-domain is registered to this project → use that domain's token + ▼ + POST /accounts/{customer}/email/sending/send_raw { from, recipients, mime_message } + DKIM = acme.com (or mail.acme.com) · quota = the customer's · message_id returned +``` + +**Inbound identity.** The bridge is one address per project domain, +`+inbound@` — one verification click per domain rather +than one per handler. The router recovers the recipient the sender wrote from +the `To:` header; when no address on a registered domain is there (a BCC), +the message goes to `_default.ts` with the bridge address as `to`. That is +the one loss in the design, and it is documented rather than papered over. +`replyEmail` on a bridged message defaults `from` to the address that +received it, on the customer domain, and goes out over the REST path. + +**Trust.** The router asserts `viaDomain` only when the inbound +`Authentication-Results` shows the forwarder's DKIM pass; a message sent +straight to the bridge address is handled as ordinary shared-domain mail. The +reply-sender pin gains a second accepted shape: any address on a domain +registered to the receiving project. + +### The flow — `void email domain add acme.com` + +Mirrors `void domain add`: a row that goes `pending → active`, printed +instructions for the steps only a human can do, and `status` to poll. + +``` +$ void email domain add acme.com + MX acme.com carries MX for google.com → apex is off limits + using mail.acme.com [change with --subdomain] + Token opening the Cloudflare token form, pre-filled for this domain… + https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=…&name=Void%20email%20%E2%80%94%20acme.com + in the browser: Continue to summary → Create Token → Copy + (a TTL is fine — the platform reports when it expires) + Clipboard watching for the token — or press Enter to paste it instead + got it · clipboard cleared + Verify token active · zone acme.com found in account …f721 · scopes ok + Stored on the platform, encrypted for this project · nothing kept on this machine + Routing mail.acme.com is not on Email Routing yet + → add it: Email Routing → acme.com → Settings → Subdomains (dashboard only) + Sending mail.acme.com onboarded — cf-bounce MX, SPF, DKIM, DMARC added + Bridge acme+inbound@mail.void.cloud added as a destination — waiting for the verification click + Status pending · run `void email domain status acme.com` +``` + +Who does what: + +| Step | Who | How | +| ------------------------ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| token | customer, once | three clicks: the CLI opens the dashboard form pre-filled with name and scopes; the customer clicks through and pastes — [details](#creating-the-token-in-three-clicks) | +| zone lookup, MX check | platform | `GET /zones?name=`, public DNS — no token needed for MX | +| routing on the apex | platform | `POST /zones/{z}/email/routing/dns`, only when MX is empty | +| routing on a subdomain | customer, once | dashboard — no API exists; the CLI prints the exact path and polls | +| Email Sending onboarding | platform | `POST /zones/{z}/email/sending/subdomains { name }` (token auth undocumented — probe) | +| bridge destination | platform + one click | `POST /accounts/{c}/email/routing/addresses`; the verification mail lands in our router, which surfaces the link in `status` — auto-completing it is decision 7 | +| catch-all → bridge | platform | `PUT /zones/{z}/email/routing/rules/catch_all` once the bridge is verified | +| status → active | platform | every probe green; the map printed | + +`void email domain status`, `list`, `remove`. `remove` deletes the rule and +the bridge destination in the customer's account best-effort, then the row +and the token. The customer can also revoke the token at Cloudflare at any +time; the next probe sees the 403 and the row reads `token revoked`, with +inbound continuing until they delete the rule themselves. + +### Data and the token + +- `email_domains` — `project_id`, `domain` (the address domain, apex or + subdomain), `zone_id`, `account_id`, `mode: 'apex' | 'subdomain'`, `status: +'pending' | 'active' | 'token_revoked' | 'token_expired' | 'failed'`, `bridge_address`, + `bridge_destination_id`, `verification_data` (which steps remain, and the + verification link once captured), `token_id` and `token_expires_on` from + `GET /user/tokens/verify` (so `status` can name the token to revoke and + warn before a TTL runs out), timestamps. +- The token is stored with `encryptProjectSecret` under the project's + envelope key, per domain, and only the api and proxy ever decrypt it. It + is never written to a worker, a manifest or a log. +- Scopes, minimum: zone-scoped `Zone Read` (to find the zone id) and + `Email Routing Rules Edit`; account-scoped `Email Routing Addresses Edit` + and `Email Sending Edit`; `Zone Settings Edit` only for the apex path. + `DNS Read` is not needed — MX is read over public DNS. Account-level + scopes are necessarily account-wide; the token summary page shows that + before the customer clicks **Create Token**. + +### Creating the token in three clicks + +_(added 2026-09-11)_ The token is the one step that only the customer can +do, so it is the step to make short. Cloudflare's dashboard accepts a +[template URL](https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/) +that pre-fills the token form — name, permission groups, resources. The CLI +opens it; the customer clicks **Continue to summary**, **Create Token**, +**Copy** — and the CLI takes the token from the clipboard. Three clicks, +nothing typed (plus a login if the browser has none). The link is the +pattern Cloudflare's own `deploy.workers.cloudflare.com` and several +third-party CLIs already use; those CLIs stop at a paste prompt, which stays +here as the fallback. + +``` +CLI browser CLI + │ MX check over public DNS │ │ + │ → apex or subdomain decided │ │ + │ build the link (scopes fit the path)│ │ + │ snapshot the clipboard │ │ + │ open ─────────────────────────────▶ │ form is pre-filled │ + │ watch the clipboard (≤ 10 min) │ 1 Continue to summary │ + │ │ 2 Create Token │ + │ ◀──────────────────────── clipboard │ 3 Copy │ + │ shape ok → GET /user/tokens/verify → clear the clipboard │ + │ GET /zones?name=acme.com · scope check │ + │ send over TLS → platform stores with encryptProjectSecret │ + │ drop it from memory · continue with Zone / Routing … │ +``` + +**The whole grant, with its branches.** Five parties; the developer +clicks three times and types nothing. + +``` +developer void CLI Cloudflare dashboard Cloudflare API Void platform +───────── ──────── ──────────────────── ────────────── ───────────── +add acme.com ▶ MX lookup (public DNS) + apex or mail.acme.com + build the link (keys fit the path) + snapshot the clipboard + open ───────────────────────▶ login if needed ¹ + form pre-filled +click 1 ───────────────────────────────────▶ Continue to summary + (narrow account / zone here) +click 2 ───────────────────────────────────▶ Create Token ────────────▶ user-owned token created +click 3 ───────────────────────────────────▶ Copy → clipboard + poll the clipboard ≤ 10 min + new value · 40 chars · no vendor prefix + ──────────────────────────────────────────────────────▶ GET /user/tokens/verify + ◀────────────────────────────────────────────────────── status active · id · expires_on + clear the clipboard + POST /projects/:id/email-domains { domain, token } ─────────────────────────────────▶ encryptProjectSecret + drop the token from memory row pending · token_id · expires_on + ◀────────────────────────────── GET /zones?name=acme.com (zone:read) + ◀────────────────────────────── one read per needed endpoint ² + all green → setup steps with the stored token +◀───────────── pending · the next human step ◀───────────────────────────────────────────────────────┘ + ◀────────────────────────────── runtime: proxy decrypts, send_raw +``` + +¹ Whether the pre-fill survives a login redirect is unverified. +² Rules, addresses, sending, and zone settings on the apex path. A 403 +names the missing group; the CLI re-runs the link with that key added. + +| Branch | What the developer sees | +| ------------------------------------- | -------------------------------------------------------------------------------------------- | +| clipboard unchanged, or wrong shape | nothing; still waiting. Enter switches to the paste prompt | +| verify says not active | "that is not an active Cloudflare token — still waiting" | +| 10 minutes, nothing | the paste prompt; Esc cancels with nothing stored anywhere | +| zone not found with this token | `failed · acme.com is not in an account this token can see` → re-run `add` | +| 403 on a scope probe | `failed · missing Email Routing Addresses Edit` → re-run `add`; the link carries the key | +| `expires_on` passed, or revoked at CF | `status` reads `token expired` / `token revoked`; `add` again replaces the token, rules stay | +| `remove` | rule and bridge destination deleted with the token, best effort; then the row and ciphertext | + +**The clipboard watch.** The write side already exists — `void auth token` +runs `pbcopy` / `clip` / `xclip -selection clipboard` +(`cli/auth-cmd.ts:38-51`); the read side is the mirror: `pbpaste`, +`powershell -NoProfile -Command Get-Clipboard`, `xclip -o -selection +clipboard` or `wl-paste`. The rules that keep it from being creepy: + +- The CLI reads only between opening the link and getting a token, never + otherwise, and says so on screen. +- It ignores whatever was on the clipboard before the link opened + (the snapshot), and accepts only a _new_ value shaped like a Cloudflare + token: 40 characters of `[A-Za-z0-9_-]`, no vendor prefix (`ghp_`, + `github_pat_`, `sk-`, `xox` are the same length or longer and are refused + without a network call). Only a value that passes the shape check is sent + to `GET /user/tokens/verify`, and only `status: active` is accepted. +- It clears the clipboard once it has the token (`pbcopy + &accountId=* # required by the form; "all accounts" — the customer narrows it in the summary if they like + &zoneId=all # required by the form; narrow to acme.com in the summary (probe 7 asks whether a zone id here is honoured) + &name=Void%20email%20%E2%80%94%20acme.com +``` + +```json +[ + { "key": "zone", "type": "read" }, + { "key": "email_routing_rule", "type": "edit" }, + { "key": "email_routing_address", "type": "edit" }, + { "key": "email_sending", "type": "edit" }, + { "key": "zone_settings", "type": "edit" } +] +``` + +`zone_settings` is added only on the apex path — the MX check runs over +public DNS before the link is built, so the CLI already knows which path it +is on. `zone: read` is what `GET /zones?name=` needs to find the zone id; +the earlier scope list forgot it. + +Where the keys come from, because the documented key table stops at +`workers_*`, `d1`, `queues`, `dns`, `zone`, `zone_settings`: + +| Key | Permission group (dashboard label) | Evidence | +| ----------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `email_routing_rule` | Email Routing Rules Read/Write (`email_routing_rule_*`) | dashboard permission-group dump in `Cloudflare-Mining/Cloudflare-Datamining` (`data/account/token_permission_groups_dash.json`, 2024-12-29): every group's `label` is `_read` / `_write`; used in the wild by GhostMail iOS and erxes | +| `email_routing_address` | Email Routing Addresses Read/Write | same dump; GhostMail iOS | +| `email_sending` | Email Sending | **one source only** — erxes `MailConfigUpdate.tsx` (2026-09-01); the product launched after the dump. Probe 5 | +| `zone`, `zone_settings` | Zone Read, Zone Settings Read/Write | documented; same dump | + +What this does not do, so nobody expects it: + +- The dashboard never hands the token back. There is no redirect, no + callback, no local listener to receive it — the paste is the transport. + Cloudflare's own docs: "Template URLs only pre-fill the token creation + form. Users must still complete the token creation process in the + dashboard." +- A two-click **Allow** page (OAuth, the way `wrangler login` works) needs + an OAuth client registered by Cloudflare. No self-service registration is + documented; wrangler's client id is a partner arrangement. If VoidZero can + get one, this whole subsection collapses to "click Allow" — decision 8. +- Creating the token by API (`POST /accounts/{id}/tokens`) needs a token + with `API Tokens: Edit` first — the same chicken, one egg earlier. +- The developer's local `wrangler login` session cannot replace the + platform's token, but not for the reason first written here. `wrangler +auth token --json` hands the session bearer to other tools by design + (keyring storage is opt-in; see Mode B), so the CLI _could_ do the + one-time setup from the developer's machine — the catch-all → bridge + forward via REST, the destination via `wrangler email routing addresses +create`. What it cannot do is send at runtime: the platform's proxy needs + a credential of its own for `send_raw`, and the OAuth bearer expires in + an hour with the refresh token staying wrangler's. Option for later: + setup through the session, and a pasted token narrowed to `Email +Sending: Edit` only. + +### If Void had a Cloudflare OAuth client + +_(added 2026-09-11)_ "A Cloudflare app" can mean four things. One of them +removes the token step entirely; Void cannot self-serve it. + +| What | What it is | Can Void get it? | Helps here? | +| ------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------- | +| Cloudflare Apps marketplace | JS widgets injected into sites; installs ended 2024-01, sunset 2024-08-31 | no — gone | no | +| **OAuth client** (what `wrangler login` is) | the customer clicks **Allow**; Void receives an access + refresh token | not self-service; a partner arrangement with Cloudflare | **yes** — the token step becomes one click | +| Tenant API | Channel/Alliance partners create and own Cloudflare accounts for customers | partner agreement | only for accounts Void creates — not a customer's own zone | +| Access / OIDC | identity for humans | yes | no — not API access | + +With the client, the grant collapses to this, and every step after "stored" +is unchanged: + +``` +$ void email domain add acme.com + Cloudflare opening the consent page… [Allow] (one click; pick the account if you have several) + ◀ access token + refresh token · scopes zone:read · email_routing:write · email_sending:write + Stored refresh token encrypted for this project · the platform refreshes it; nothing on this machine + Zone acme.com found in account …f721 + … +``` + +The web variant is better still: a **Connect Cloudflare** button in the +dashboard's Email section, with the callback on `api.void.cloud`, so the +grant never passes through the developer's machine at all. The CLI variant +uses a `localhost` callback, as wrangler does (`http://localhost:8976/oauth/callback`). + +What it costs, so the owner can weigh the ask: + +- **Breadth.** An OAuth grant is user-wide — every account the user belongs + to, every zone in them, for the granted scopes. `wrangler whoami` shows + this today. A template-link token can be narrowed to one zone; a grant + cannot. Keep the token path as the narrow option for a company with many + zones; OAuth is the default for everyone else. +- **Refresh.** The access token is short-lived; the platform stores the + refresh token (`encryptProjectSecret`, same envelope) and refreshes at + `POST /oauth2/token`. A revoked grant is the existing `token_revoked` + path. `POST /oauth2/revoke` on `remove`. +- **Scopes exist already.** Cloudflare's consent catalogue has + `email_routing:write` ("settings, rules, and destination addresses"), + `email_sending:write` and `zone:read` — they are what wrangler asks for. + Nothing new has to be built on Cloudflare's side except the client. + +Why not reuse wrangler's client id: the OAuth endpoints are public and the +client is a public PKCE client, so it works, and PartyKit (before its +acquisition), `every-app` (2026-08), `orangeshell`, `corral`, `BPB-Wizard` +and others do exactly that. The consent screen is Cloudflare's for wrangler, +the scope set is wrangler's, and Cloudflare can cut it off any day. Not a +basis for a product. NuxtHub, Orange, Prowler and erxes chose the template +link instead — the same choice as this document until the client exists. + +The ask, in one line: an OAuth client for Void with scopes `zone:read`, +`email_routing:write`, `email_sending:write` and two redirect URIs — +`https://api.void.cloud/cloudflare/callback` and `http://localhost:/oauth/callback`. + +The same link and paste field belong in the dashboard's Email section +(Mode A) as a **Create token at Cloudflare** button next to a paste box; +the CLI and the dashboard share the link builder. The account-token form +(`/?to=/:account/api-tokens&…`) is the better home for a company, because +the token survives the employee — but it needs an Administrator, and the +send endpoint documents a `10103 bad_token_type` error without saying which +type it rejects. User token is the default until probe 6 answers that. + +### What changes in the existing rules + +- **Reply-sender pin** — accepts the receiving slug on the shared domain + _or_ any address on a domain registered to the project. +- **Recipient allowlist** — Cloudflare's verified-destination gate does not + apply once the customer's domain is onboarded for sending, and the + reputation at stake is the customer's own. Whether our allowlist still + gates custom-domain sends is decision 6. +- **Quotas and kill switch** — the platform's burst cap and suspend switch + keep applying to every send that passes through the proxy, custom domain + or not; the daily quota is the customer's account's. +- **The 200-destination cap** — the bridge consumes one destination in the + _customer's_ account per project domain, not one in ours. + +### Limits, stated plainly + +- The customer's account must be on **Workers Paid** to send; routing alone + works on Free. +- Three steps stay human: the token (three clicks and a paste), the + routing-subdomain dashboard form, and the verification click (until + decision 7 automates it). +- A BCC'd message cannot be routed to a named handler; it reaches + `_default.ts`. +- A domain that is not on Cloudflare DNS is not covered. + +### Mode B + +The developer's own zone needs none of this. Their worker holds the +`send_email` binding, the rules target their worker directly, and there is +no bridge and no stored token — [Mode B](#mode-b--the-developers-own-cloudflare-account) +already covers it. + +### Probes before building + +1. Whether `+` is accepted in a destination address (`acme+inbound@…`). +2. The verification mail's sender and link shape, so the router can + recognise it and, if decision 7 says so, fetch it against a pinned host. +3. Whether the original `To:` header survives a plain forward. +4. Whether an API token with `Email Sending: Edit` can call + `POST /zones/{z}/email/sending/subdomains`. +5. Whether `{ "key": "email_sending", "type": "edit" }` pre-fills the + Email Sending permission in the dashboard form (one third-party source + only; the routing keys have two plus the dashboard dump). +6. Whether `POST /accounts/{id}/email/sending/send_raw` accepts an + account-owned token, or only a user-owned one (`10103 bad_token_type`). +7. Whether `zoneId=` in the template URL narrows the form to that + zone, or only `all` is honoured. +8. Whether the dashboard's **Copy** button places the bare token on the + clipboard (no label, no trailing newline) on macOS, Windows and Linux. + +## Backwards Compat + +Pre-launch — clean cutover. Notes for context: + +- `sendEmail` user-facing API is unchanged. Result shape, error codes, + multi-recipient fan-out, dev inbox, test harness all behave the same. +- `from` defaulting only applies when omitted; explicit `from` passes through. +- Pre-`WorkerEntrypoint` user deployments: outbound now routes through + `__VOID_PROXY` (which they already have) so it works without a redeploy. + Inbound still hits the router's "no email handler" reject path until + they redeploy. +- User wrangler.jsonc files that previously had `[[send_email]] { name = "SEND_EMAIL" }` + emitted by `void dev` are no longer rewritten. Stale blocks are + harmless (no production code references them) and can be deleted by + the user; the framework neither emits nor strips them. +- `manifest.bindings.email` field was removed from the deploy manifest + shape. The platform doesn't use it anymore. + +## File Layout + +``` +packages/void/ (framework, in ../void) +├── package.json # ./email + ./email/testing exports +├── src/ +│ ├── runtime/ +│ │ ├── email.ts # sendEmail, createEmail, default-from resolution, proxy hooks +│ │ └── email/ +│ │ ├── mime.ts # MIME builder (mimetext wrapper) +│ │ ├── inbound.ts # defineEmail, parseEmail, replyEmail, createMessageProxy +│ │ ├── dev-inbox.ts # in-memory ring buffer + sink registry +│ │ ├── testing.ts # createEmailTestHarness, createInboundTestHarness +│ │ └── types.ts # SendEmailOptions, SendEmailResult, EmailEnvelope, EmailAction +│ ├── router/ +│ │ ├── email.ts # scanEmailHandlers + compileEmailPattern +│ │ └── compile.ts # __emailHandler dispatcher emit; WorkerEntrypoint subclass +│ ├── inference/walk.ts # needsEmail (3-tier import / regex / env detection) +│ ├── plugin-inference.ts # bindings.email opt-out +│ ├── plugin-routing.ts # passes emailDir; watches email/ +│ ├── plugin-email-dev-inbox.ts # /__void/inbox Vite plugin +│ ├── cli/ +│ │ ├── wrangler.ts # no on-worker [[send_email]] emit (proxy holds the binding) +│ │ ├── email.ts # `void email usage|logs` subcommands +│ │ └── email-args.ts # subcommand parser +│ └── index.ts # mergeBindings for send_email +└── test/unit/ + ├── email-mime.test.ts + ├── email-send.test.ts # send + default-from + proxy quota + partial failures + └── email-inbound.test.ts # scanner, compile emit, defineEmail, parseEmail, + # buildReplyMessage, createInboundTestHarness + +platform/packages/api/src/ (platform) +├── schema.ts # MODIFIED — add email_destinations table +├── drizzle/0011_add_email_destinations.sql +├── db.ts # MODIFIED — destination CRUD helpers +├── routes/ +│ ├── email.ts # GET usage, GET logs, GET|POST|DELETE destinations +│ └── projects.ts # MODIFIED — auto-add owner email on project create +├── admin/routes/ +│ └── email.tsx # admin page: usage, suspend, limits, destinations +├── provisioning/ +│ ├── email-routing.ts # NEW — CF Email Routing addresses API client +│ └── provision.ts # MODIFIED — inject VOID_PROJECT_SLUG plain_text binding +└── routes/deploy.ts # MODIFIED — emit send_email with allowed_destination_addresses + +platform/packages/proxy/src/ (platform) +├── email.ts # /email/check-quota, /email/log-send, /email/log-recv +├── email-router.ts # email() export — slug+tag parse, dispatch, enact actions +├── body.ts # MODIFIED — asNumber helper +└── index.ts # MODIFIED — wire /email routes; export email() handler + +User project layout: + +my-app/ +├── crons/ # → scheduled() export +├── queues/ # → queue() export +├── email/ # → email() export (NEW) +└── routes/ # → fetch() export +``` + +## Rollout + +Single ordered sequence. Each step is independently testable; later steps depend on earlier ones. + +1. **Framework: outbound runtime + types + dev inbox + tests.** `sendEmail`, MIME builder, dev-inbox plugin, `createEmailTestHarness`. Standalone — no platform dep. +2. **Framework: inbound runtime + scanner + dispatcher emit + tests.** `defineEmail`, `parseEmail`, `replyEmail`, `email/` scanner, `__emailHandler` codegen, `createInboundTestHarness`. +3. **Framework: `WorkerEntrypoint` codegen migration.** `compileWorkerEntry` emits the class form with `email(envelope)` returning actions. Affects all WfP-deployed workers. +4. **Framework: default-from + `__VOID_PROXY` integration.** `from` defaults to `+noreply@mail.void.cloud`; pre-flight quota + async log via the unified proxy. Fail-open when proxy is unbound. +5. **Framework: `bindings.email` flag in deploy manifest.** `packageBuild` / `packageFrameworkBuild` set `manifest.bindings.email = true` when `needsEmail` is detected, so the platform knows to emit `send_email` on the worker. +6. **Platform: `VOID_PROJECT_SLUG` injection.** `platform/packages/api/src/provisioning/provision.ts` adds the plain-text binding alongside the existing proxy bindings. +7. **Platform: proxy email handlers + router worker.** `/email/check-quota`, `/log-send`, `/log-recv` in `platform/packages/proxy/src/email.ts` (with monthly + per-minute burst caps + suspend kill-switch); `email-router.ts` wired as the proxy's `email()` export. +8. **Platform: destinations schema + CF API client + API routes.** `email_destinations` table; `provisioning/email-routing.ts`; `GET|POST|DELETE /projects/:id/email/destinations[/:id]`. Auto-add project owner's email on project create. +9. **Platform: `send_email` binding emit at deploy.** `routes/deploy.ts` reads verified destinations and emits `{ type: 'send_email', allowed_destination_addresses: [...] }` per project. +10. **Platform: API + admin UI.** `GET /projects/:id/email/{usage,logs}` + `admin/routes/email.tsx` (usage, suspend, limits, destinations). +11. **Framework: `void email usage|logs|destinations|allow|disallow` CLI.** +12. **Ops: mail subdomain setup.** _(corrected 2026-09-11)_ Add `mail.void.cloud` as an Email Routing subdomain of the existing `void.cloud` zone, onboard it to Email Sending, add the catch-all rule pointing at the proxy worker. No zone registration, no nameserver change. One-time human action per environment — see [`meta/operations.md`](../operations.md#email). +13. **Deploy.** `pnpm run deploy:staging` first to validate the dispatch path against real WfP runtime; then production. +14. **Tag release.** + +Steps 1–5 + 11 ship in the framework via the normal release. Steps 6–10 are platform changes. Step 12 is the only ops gate before production sending works. + +### After the re-land merges _(added 2026-09-11)_ + +15. **Operator, before any production send:** the setup script, run against + staging first; Email Sending onboarding; the three checks in the + operations.md "Before you deploy this the first time" list. +16. **Developer surfaces:** `usage` fields → `void email status` → deploy + output → dashboard section. Each is independently shippable. +17. **Mode B:** (a) the wrangler catalog bump to ≥ 4.113 as its own PR; + (b) one staging session for the two unexecuted writes (subdomain + routing POST, subaddressing PATCH) with the session bearer; (c) the + feature PR — runtime items 1–5, `cli/email-cloudflare.ts`, the 3b-ii + step, the `addresses` pin, the refusals as tests — per the build list + under "One keystroke, measured". +18. **Mode C:** the three code sites and the config surface; the docs pass; + the environment-level admin page. +19. **Custom domains on Cloudflare:** the four probes on a test zone first; + then `email_domains` + token storage, the REST send path in the proxy, + the bridge recognition in the router and the no-prefix dispatch (shared + with Mode B item 4), the `void email domain` commands, the dashboard + rows. Ships after 16 and 17, since it reuses both. + +## Known gaps + +_(added 2026-09-11)_ Defects found while designing the lifecycle. The first +group is ours to fix inside the current PRs, because the claim or the code is +ours; the second is tracked follow-up work. + +| Where | Defect | Fix in | +| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| `void dev` | `sendEmail` without `from` returns `INVALID_FROM` — the slug and domain bindings are not injected in dev, and the shipped prompt promises the default | void#160 (decision 5) | +| `docs/guide/email.md`, `getting-started-prompt.txt` | name `mail.void.cloud` as if it were the only possible domain | void#160 | +| `meta/operations.md` | said the mail zone needs registration and nameservers; said the slug backfill is a one-time pass; omitted the 200-destination cap and Email Sending | done, `814423a0` | + +Follow-ups: the Mode B runtime (six items), Mode B detection and prompt, +`void email status` and the `usage` fields, the dashboard Email section, dev +injection and typing of the two bindings, the environment-level admin page +and setup script, the three Mode C code sites, and the proxy's bounce text +for a framework-type project (the routing blob already carries `type`; +`email-router.ts` never reads it, so the bounce says "needs redeploy" to a +user for whom redeploying cannot help). _(added 2026-09-15)_ Tenant +visibility of a dropped outbound action: a `forward` or `reply` refused by +the per-project gate (recipient not a verified destination, burst or +monthly limit) is logged only in the proxy's own console output, the +inbound record still reads `handled`, and `GET /projects/:id/email/logs` +still answers `pending` — so a project cannot see that its reply never +left. The guide documents the gate; surfacing the drop needs the +Analytics Engine-backed log query that `void email logs` is waiting on. +Verification pickup: a Cloudflare-verified destination row moves from +`pending` to `verified` (and into the allowlist KV) only when the +project's destinations are listed (`GET /projects/:id/email/destinations`) +or the address is re-added; nothing else re-reads Cloudflare, so a send +after the click still returns `UNVERIFIED_DESTINATION` until a listing +runs. Cloudflare has no verification webhook; a deploy-time or +scheduled refresh is the candidate. The guide says to run +`void email destinations` after the click. + +## Open decisions + +_(added 2026-09-11)_ Recommendations are the author's; the decision is the +owner's. + +1. **Mode B setup: inside `void deploy --backend cloudflare` behind detection + and a prompt (recommended, now that the wrangler session has the scopes), + or a separate `void email setup` only.** The separate command still exists + for CI and for re-runs; the question is whether the first deploy offers it. +2. **Mode B apex policy: refuse an apex with foreign MX (recommended), or + prompt.** +3. **Operator tooling: script first, admin page second (recommended), or the + reverse.** +4. **Fold the legacy `noreply@void.cloud` platform sender under + `EMAIL_SENDER_DOMAIN` (recommended — it is the third Mode C blocker and a + second domain to keep DNS for).** +5. **Fix the `void dev` `INVALID_FROM` gap inside void#160 now (recommended, + it is small and the prompt already promises it), or track it.** +6. **Custom domains: does our recipient allowlist still gate sends from a + customer's own domain?** Recommended: no — Cloudflare's gate is lifted by + their onboarding and the reputation is theirs; keep the burst cap and the + kill switch, which protect us. +7. **Custom domains: auto-complete the bridge verification** by fetching the + link Cloudflare mails to our router, against a pinned sender and link + host (recommended once probe 2 pins the format), **or surface the link** + in `void email domain status` for the developer to click (the safe first + version). +8. **Ask Cloudflare for an OAuth client** (the mechanism behind + `wrangler login`), so a custom domain is one **Allow** click with no + token to paste — see [If Void had a Cloudflare OAuth client](#if-void-had-a-cloudflare-oauth-client). + Not self-service; a partner ask. Recommended to ask, not to wait — the + three-click template link ships first either way, and stays as the + narrow-scope option. +9. – 13. **Mode B one-keystroke flow** — domain input, apex path, the + session-bearer bridge, two projects on one zone, ownership of a + hand-written `addresses` array. Listed with recommendations under + [Decisions handed back](#decisions-handed-back). +10. **Inbound RPC result materialization** _(added 2026-09-15)_ — the + proxy cannot bound a tenant's `email()` return value before workerd + materializes it (128 MiB of nulls or ~500 MiB of `{}` fit the 32 MiB + serialized cap; see [RPC result materialization](#rpc-result-materialization--accepted-limit-measured-2026-09-15)). + **Decided 2026-09-15: accepted as a limit for this PR.** The eviction + cost to unrelated traffic is one isolate replacement, and a tenant pays + its own CPU budget to reach it. The architectural bounds stay on the + table as a follow-up, none of them a bug-fix: an HTTP contract via + `stub.fetch()` with a byte-capped body read, a dedicated email-dispatch + worker, or a lower `cpuMs` for email dispatch than for HTTP. + +11. **Mode B setup by direction** _(added 2026-09-15; raised by review three + times)_ — setup is keyed on `needsEmail` (an `email/` handler or a + `void/email` import), so an inbound-only app is also onboarded for + Email Sending (one `sending enable` call; on Workers Free one refused + POST and the hint) and receives the inert `SEND_EMAIL` binding. + `replyEmail` needs neither. Onboarding carries no per-domain charge — + Cloudflare meters accepted outbound messages — but on Workers Paid it + locks `_dmarc` (`p=reject`) and DKIM records on the mail subdomain that + an inbound-only app never uses. **Open.** Splitting by direction is + cheap in the walker (it already records the `void/email` specifier) + but touches the preflight `ready` predicate (also the "binding present + - sending pending" refusal memory), the checklist, apply, the address + map, deploy's `satisfied`, and `void email setup/status`; and it changes + the default failure mode: an app whose `sendEmail` lives in a + dependency, a dynamic import, or reads `env.SEND_EMAIL` directly would + ship without the binding and fail at runtime with `BINDING_MISSING`. + The guide documents the per-domain rule (Sending section). Owner's call. + +## Open questions + +_(added 2026-09-11)_ Only a live probe settles these. + +- Whether `POST /zones/{z}/email/routing/dns` with `name: "mail.example.com"` + enables routing on a subdomain. The OpenAPI body has the `name` field + ("Domain of your zone", not deprecated) and a Support quote in + `terraform-provider-cloudflare#5890` says it does; nobody has executed it. + `wrangler email routing enable` calls the deprecated `…/routing/enable`. +- Which token permission group the Email Sending subdomain endpoints accept. + The OpenAPI schema lists none. The wrangler OAuth scope + `email_sending (write)` is what `wrangler email sending enable` uses; + the enable itself was not run here. `GET /accounts/{a}/email/sending/zones` + answered `401 / 2036 Unauthorized` on an account with no onboarded + sending domain (plan unread) and `200` on two accounts with one — the + endpoint and the code are in neither the schema nor the docs. +- Whether `mail.staging.void.cloud`, two labels deep, is accepted by the + subdomain form. The docs show one-label examples only. +- The eight custom-domain probes listed under + [Probes before building](#probes-before-building). + +## Future Work + +Things we deliberately did not build. Listed for context, not as a roadmap commitment — each will only get built if real usage shows the demand. + +### Provider abstraction for domains not on Cloudflare + +_(narrowed 2026-09-11 — domains on a Cloudflare zone are covered by [Custom domains on Cloudflare](#custom-domains-on-cloudflare).)_ If sending from a domain whose DNS is elsewhere becomes a frequent ask, the cleanest path is a thin wrapper over Resend / SES / Postmark. Providers sign with DKIM, so verification is just TXT records on the user's existing DNS — no NS delegation, no CF zone ownership, no second `void domain add`-shaped flow. Sender stays at the apex. + +Until then: users install the provider's SDK directly in their handler. Three lines. No platform involvement, no wrapper that has to track API changes. + +```ts +// void.config.ts +export default defineConfig({ + email: { + provider: 'resend', // | "ses" | "postmark" | "cloudflare" (default — shared mail.void.cloud) + }, +}); +``` + +Per-domain setup flow: + +1. User runs `void email add-sender noreply@acme.com` +2. Platform calls the provider's API to register the domain; provider returns DKIM/SPF/DMARC TXT records +3. CLI prints the records for the user to add to their DNS +4. `void email status noreply@acme.com` polls for verification +5. Once verified, the address joins the project's `email_senders` table — typed-sender codegen surfaces it + +Runtime: `sendEmail` dispatches to the configured provider; CF native (`mail.void.cloud` shared sender) remains the default. Dev inbox works identically regardless of backend. Phase-3 metering and logs continue to work — the proxy is provider-agnostic. Cost passthrough (e.g. Resend at $20/50k) needs a billing model; product decision when this scopes. + +### Component templating + +- Convention: `emails/.{tsx,vue,svelte,solid}` — adapter-aware, mirrors how pages work +- Build: render at request time to inlined HTML + plain-text via a lightweight HTML-to-text pass; cache rendered output keyed by props hash +- API: `await sendEmail({ template: WelcomeEmail, props: { name } })` — props typed from the template's component signature +- `void email preview` CLI: dev server at `http://localhost:5174/__emails__/` — lists templates, prop-form input, hot reload +- React Email / `@react-email/components` is the leading prior art + +### Typed verified senders + +Codegen queries the platform API for the project's verified senders + Email Routing zones, generates `.void/email.d.ts`: + +```ts +declare module 'void/email' { + type VerifiedSender = 'noreply@acme.dev' | 'support@acme.dev' | '+noreply@mail.void.cloud'; + + interface SendEmailOptions { + from: VerifiedSender | { email: VerifiedSender; name?: string }; + } +} +``` + +Surfaces "you can't send from this address yet" as a TypeScript error at edit time, not a runtime `INVALID_FROM`. + +### Bounce handling + +- Hard bounces from CF aren't delivered to Email Workers today (CF returns to the sending MTA) +- For provider-backed sending, bounces arrive via webhooks — wire them into a `email/_bounce.ts` convention for handler-side suppression list management +- For CF-native sending, bounces remain a gap — documented honestly + +### Other follow-ups + +- Analytics Engine wiring for persistent delivery logs (currently `void email logs` returns `pending: true`; tail-only) +- Auto abuse detection / reputation alerting (currently only manual suspend toggle) +- Dev-inbox round-trip UI (deliver-to-local-handler button, paste-`.eml` form) + +## References + +- [`0047-email.md`](./0047-email.md) — the deferred Resend design, preserved for context +- Cloudflare Email Workers docs: `send_email` binding, Email Routing, `EmailMessage`, `email()` export +- workerd RPC types: `types/defines/rpc.d.ts` (BaseType allowlist), `types/defines/wfp.d.ts` (DispatchNamespace), `types/defines/email.d.ts` (ForwardableEmailMessage) +- workerd RPC implementation: `src/workerd/api/worker-rpc.c++`, `src/workerd/jsg/ser.c++`, `src/workerd/io/worker-interface.capnp` +- `postal-mime` — inbound parser +- `runtime/email/mime.ts` — outbound MIME builder (own implementation; `mimetext` was the original plan) +- `react-email` / `@react-email/components` — templating prior art (future work) +- `packages/void/src/runtime/env.ts:70` — `requireRuntimeBinding` shared helper +- `packages/void/src/inference/walk.ts:6-103` — existing binding inference pattern +- `packages/void/src/cli/wrangler.ts:179-244` — existing wrangler-sync pattern +- `platform/packages/proxy/src/*` — AI proxy + unified `__VOID_PROXY` pattern that this design extends +- `platform/packages/api/src/admin/routes/deployments.tsx` — admin page pattern this design follows + +Evidence for the 2026-09-11 sections (framework paths under +`packages/void/src`; platform paths under `platform/packages/`): + +- Wrangler scopes and commands: `wrangler whoami` on 2026-09-11 lists + `email_routing (write)`, `email_sending (write)`; `wrangler email` exists in + 4.107.0 (the framework pin) and 4.130.0. `rules create` takes + `--action-type worker`; `routing enable` resolves the zone by exact name + (`getZoneIdByDomain`); `sending enable` walks up labels (`resolveDomain`). +- Mode B today: `runtime/email.ts:505-516` (`BINDING_MISSING`), + `router/compile.ts:1603-1611` (strip after first `+`), + `compile.ts:2856-2862` + `runtime/email/inbound.ts:296-361` (actions + returned as data), `cli/deploy.ts:2468-2681` (no email in desired + resources). All executed against a scratch copy of `playground/kitchen-sink`. +- Address discovery gap: `cli/email.ts` prints no sender; + `platform/packages/dashboard/pages/projects/[id].tsx` has no email section; + `routes/email.ts:77-84` returns no domain; `codegen/env-types.ts` does not + type the bindings; `index.ts` / `plugin-*.ts` do not inject them in dev; + `runtime/email.ts:441-447` returns `INVALID_FROM` before the inbox at `:493`. +- Mode C blockers: `platform/packages/api/src/email/send-verification.ts:76-95`, + `platform/packages/api/src/email.ts:1`, `platform/packages/api/wrangler.jsonc:152`. +- Slug mirror self-heal: `platform/packages/api/src/index.ts:585-600` inside the hourly + rollover. +- Admin patterns: `admin/routes/signup-control.tsx`, `admin/routes/system.tsx:140-178`, + `admin/views/dashboard.tsx:49-86`. +- Cloudflare docs: Email Routing subdomains (dashboard flow); send bindings + ("the sender address must always belong to a domain you have onboarded"); + limits ("Destination addresses per account: 200 … shared across all + domains"; "Before you onboard a sending domain, you can send emails only to + verified destination addresses"); catch-all `PUT …/rules/catch_all` with + `{ type: "worker", value: [""] }`; local inbound trigger + `POST /cdn-cgi/local/email`. +- Token template link: Cloudflare docs "API token template URLs" + (`fundamentals/api/how-to/account-owned-token-template/`) — user and + account URL formats, `[{ key, type }]` encoding, "only pre-fill the token + creation form". Key names beyond the documented table: + `Cloudflare-Mining/Cloudflare-Datamining` + `data/account/token_permission_groups_dash.json` (labels + `email_routing_rule_write`, `email_routing_address_write`, + `zone_settings_write`, …), `sendmebits/ghostmail-ios` README + (`email_routing_address`, `email_routing_rule`), `erxes/erxes` + `MailConfigUpdate.tsx` (`email_sending`, `email_routing_rule`), + `Cloudflare-Mining/Cloudflare-Datamining` `data/types/src/api/apiToken.ts` + (`ApiTokensAdhocTemplate { permissionGroupKeys, accountId?, mode?, zoneId? }`). + Mode B deep research (2026-09-11, 16 agents, read-only): wrangler 4.107 / + 4.125 / 4.130 `wrangler-dist/cli.js` (`authTokenCommand` stable since 4.57.0 + #11682; keyring opt-in `wantsKeyring = envOverride ?? isKeyringEnabled() ?? false`; + `shouldLogToDisk` + `WRANGLER_WRITE_LOGS`; `applyEmailRoutingAddresses`, + `fetchEmailRoutingPlan`, `isNonInteractiveOrCI`), workers-sdk + `packages/wrangler/CHANGELOG.md` 4.112.0 #14470 / 4.113.0 #14471, + `packages/wrangler/src/email-routing/{client,enable,dns-get}.ts`, docs PR + cloudflare-docs#31772 (unpublished `addresses` page: "Catch-all entries + support apex domains only"), `cloudflare/api-schemas` `openapi.json` + (`email_email_setting_dns_request_body.name`; GET `?subdomain=` deprecated + 2026-09; plan operation `x-forge-hidden`; `email_email_setting_status` + enum; sending 409 codes), measured reads with the session bearer on + three accounts (`support_subaddress: false` on every zone; `401 / 2036` + on `…/email/sending/zones`), `@cloudflare/vite-plugin` 1.43.0 `dist/index.mjs` + (config customizer passes unknown keys through — reproduced with a scratch + build), framework `cli/deploy.ts:3729-3865` (slot 3b-ii, `--provision` CI + gate), `cli/preset.ts:238` (`resolveWranglerArgv`). + Paste-back pattern: `orange-framework/orange-js` `packages/cli/src/cf-auth.ts`, + `cloudflare/deploy.workers.cloudflare.com` `configure-account.js`. + Email Sending REST errors: `email-service/api/send-emails/rest-api/` + (`10103 bad_token_type`). OAuth: wrangler 4.125 `wrangler-dist/cli.js` + (client `54d11594-…`, `dash.cloudflare.com/oauth2/{auth,token,revoke}`, + redirect `localhost:8976/oauth/callback`, scope catalogue incl. + `email_routing:write`, `email_sending:write`); third-party reuse found by + code search for that client id (`partykit/partykit` + `cf-auth/auth-variables.ts`, `every-app/every-app` + `cli/src/lib/cloudflare/auth.ts`); Cloudflare Apps discontinuation notice + (2024-08-31); Tenant docs `tenant/get-started/` ("after you sign your + partner agreement"). `GET /user/tokens/permission_groups` needs + `API Tokens: Read`; none of our stored tokens has it, so the dump stands in. +- Compatibility date: workerd 1.20260701.1 via Miniflare; a + `WorkerEntrypoint` subclass returns 200 at `2021-11-03` and `2024-01-01` + with no flags. +- Custom domains: rule action `value` is a bare worker name (`maxItems: 1`, + no account field) — no cross-account path; "Forward actions require + exactly one verified destination address"; forwarding applies SRS and an + ARC seal and "The `From:` header of the message is not modified" (`To:` + not stated); `POST /accounts/{id}/email/sending/send_raw` takes `from`, + `recipients`, `mime_message` and accepts an API token with "Email Sending: + Edit"; `POST /zones/{z}/email/sending/subdomains` gives a subdomain its + own `dkim_selector` and `return_path_domain`; Cloudflare subdomain setup is + Enterprise-only; API tokens can be zone-scoped and carry `expires_on`. diff --git a/platform/meta/lessons.md b/platform/meta/lessons.md index f557270ef..08f2d599f 100644 --- a/platform/meta/lessons.md +++ b/platform/meta/lessons.md @@ -10,6 +10,22 @@ - Commands: avoid HEREDOC commit messages with apostrophes; run `pnpm test ` without `--`; from the repository root, use `vp run build:all` for a full SDK + platform build. - `vp run` argv: unlike `pnpm`, `vp run