From 3724728f01b63dc1978fda1d2642d8ffcd1c2958 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 30 Aug 2026 23:27:29 +0800 Subject: [PATCH 1/8] feat(email): inbound routing, sendEmail, dev inbox and test harness Re-lands the abandoned feat/email branch onto current main. Re-applied file by file rather than rebased: the branch was four months and several hundred commits behind, and its reversal commits replay against ground main had rewritten. - email/ handlers with the same scanning conventions as routes/, so src/email/ works. support+[ticket].ts matches support+anything@. - sendEmail() from void/email POSTs MIME to the platform proxy. The account-level send binding never reaches the user worker. - Dev inbox at /__void/email, void email CLI, void/email/testing harness. The generated worker entry is now a WorkerEntrypoint subclass, because email() is not reachable through a Workers-for-Platforms dispatch stub on a plain object export. compileWorkerEntry had grown from 8 to 11 parameters while the branch sat; its option type was rebuilt from main's current signature and the generated output diffed to prove every interpolation main added survived (ISR stamp, live reload, notFound, remote bindings, sandbox export). emailDir went on the existing options object, not into a new positional slot. Fixes a double strip the seam between the two repos hid: the proxy removes the + prefix so handlers see the address the author wrote, and the generated dispatcher removed it a second time, leaving an empty tag that no anchored pattern could match. The dispatcher now routes on the preserved wire recipient. Matching no longer lowercases the address first, which was also lowercasing the captured groups. The codegen tests passed throughout while the dispatcher could not route a single real message, because they searched the generated source for substrings. They now run it. The platform mail zone is configuration (__VOID_EMAIL_DOMAIN), not the hard-coded literal the original used. sendEmail() throws rather than guessing when it is absent and from was omitted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW --- docs/.vitepress/config.ts | 1 + docs/guide/email.md | 334 +++++ docs/reference/cli.md | 53 + docs/reference/config.md | 3 + packages/void/AGENT_PROMPT.md | 4 +- packages/void/getting-started-prompt.txt | 2 + packages/void/package.json | 12 + packages/void/schema.json | 4 + packages/void/src/cli/cli.ts | 14 + packages/void/src/cli/client.ts | 124 ++ packages/void/src/cli/create-project.ts | 10 + packages/void/src/cli/email-args.ts | 63 + packages/void/src/cli/email.ts | 137 ++ packages/void/src/cli/help.ts | 32 + packages/void/src/config.ts | 7 +- packages/void/src/index.ts | 11 + packages/void/src/inference/walk.ts | 18 +- packages/void/src/plugin-email-dev-inbox.ts | 221 ++++ packages/void/src/plugin-inference.ts | 11 +- packages/void/src/plugin-routing.ts | 79 +- packages/void/src/project-paths.ts | 3 + packages/void/src/router/compile.ts | 260 +++- packages/void/src/router/email.ts | 247 ++++ packages/void/src/router/scan.ts | 11 +- packages/void/src/runtime/email.ts | 467 +++++++ packages/void/src/runtime/email/dev-inbox.ts | 164 +++ packages/void/src/runtime/email/inbound.ts | 596 +++++++++ packages/void/src/runtime/email/mime.ts | 625 +++++++++ packages/void/src/runtime/email/testing.ts | 417 ++++++ packages/void/src/runtime/email/types.ts | 140 ++ packages/void/test/unit/compile.test.ts | 269 ++-- packages/void/test/unit/email-args.test.ts | 92 ++ packages/void/test/unit/email-inbound.test.ts | 1151 +++++++++++++++++ packages/void/test/unit/email-mime.test.ts | 442 +++++++ packages/void/test/unit/email-send.test.ts | 640 +++++++++ packages/void/test/unit/inference.test.ts | 26 + .../void/test/unit/plugin-inference.test.ts | 1 + packages/void/test/unit/wrangler.test.ts | 27 + packages/void/vite.config.ts | 5 + playground/kitchen-sink/email/_default.ts | 11 + .../kitchen-sink/email/support+[ticket].ts | 26 + pnpm-lock.yaml | 12 +- 42 files changed, 6574 insertions(+), 198 deletions(-) create mode 100644 docs/guide/email.md create mode 100644 packages/void/src/cli/email-args.ts create mode 100644 packages/void/src/cli/email.ts create mode 100644 packages/void/src/plugin-email-dev-inbox.ts create mode 100644 packages/void/src/router/email.ts create mode 100644 packages/void/src/runtime/email.ts create mode 100644 packages/void/src/runtime/email/dev-inbox.ts create mode 100644 packages/void/src/runtime/email/inbound.ts create mode 100644 packages/void/src/runtime/email/mime.ts create mode 100644 packages/void/src/runtime/email/testing.ts create mode 100644 packages/void/src/runtime/email/types.ts create mode 100644 packages/void/test/unit/email-args.test.ts create mode 100644 packages/void/test/unit/email-inbound.test.ts create mode 100644 packages/void/test/unit/email-mime.test.ts create mode 100644 packages/void/test/unit/email-send.test.ts create mode 100644 playground/kitchen-sink/email/_default.ts create mode 100644 playground/kitchen-sink/email/support+[ticket].ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 5a58f1b40..f12f66498 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -223,6 +223,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..9f45244ae --- /dev/null +++ b/docs/guide/email.md @@ -0,0 +1,334 @@ +--- +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) { + console.error(result.error.code, result.error.message); +} +``` + +## Setup + +Zero config. 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. +- **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. + +That's it. `sendEmail({ to: '...', subject: '...', text: '...' })` works on first deploy. + +## 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, you can send to that address from your project. 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 sending from your project to yourself works out of the box. + +::: 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. 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). | + +`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. + +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 is not supported yet. + +## 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. 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 address, each with its own messageId. + +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. + +```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` | The platform proxy binding is unavailable. Deploy through Void, or check the proxy. | +| `INVALID_FROM` | `from` was missing, malformed, or not the project sender. | +| `INVALID_TO` | `to` was empty or contained an invalid address. | +| `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. | +| `UPSTREAM_ERROR` | Other binding failure; the original error is attached as `error.cause`. | + +## Local development + +During `void dev`, sends are captured to an in-memory inbox instead of going to Cloudflare. Browse them at: + +``` +http://localhost:5173/__void/inbox +``` + +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. + +The buffer holds the most recent 100 messages and survives HMR but not a full server restart. No disk persistence. + +To bypass the inbox for a single send during dev (e.g. to verify real delivery against a verified address), pass `sendInDev: true`: + +```ts +await sendEmail({ + from: 'acme+noreply@mail.void.cloud', + to: 'verified@acme.dev', + subject: 'Real send during dev', + text: 'This will hit the binding.', + sendInDev: true, +}); +``` + +## 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 + +Receive inbound mail 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. + +```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. | +| `message.forward(to, hdrs?)` | Forward to a verified destination address. | +| `replyEmail(message, opts)` | Reply with a threaded `In-Reply-To` / `References`. | +| `message.reply(EmailMessage)` | CF native — bring your own constructed reply. | +| 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" + [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+[ticket].ts` (static + tag) +2. `support.ts` (static) +3. `[user]+[tag].ts` (dynamic + tag) +4. `[user].ts` (dynamic) +5. `_default.ts` (fallback) + +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 via the message's native `reply()`. Defaults: `from = message.to`, `to = message.from`, `subject = "Re: "`. Override any of them in opts. + +### Configuring inbound delivery + +In production, inbound delivery requires a Cloudflare Email Routing rule that points your domain at your worker. **Void manages this for you** — there's nothing to configure in the Cloudflare dashboard. (Provisioning lands in the platform's Phase 3; until then, deployed handlers sit dormant. Locally, use the dev inbox round-trip described below.) + +### Testing inbound handlers + +Use `createInboundTestHarness` to run handlers through the real dispatcher with a synthesized message and capture replies / forwards / rejects: + +```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: '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: '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`. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index fa0d23919..bd9549296 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -37,6 +37,11 @@ Use this page as a command reference. If you are setting up a project for the fi | `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 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 mcp` | Start the Void MCP server | | `void init` | Setup wizard for new or existing projects | @@ -881,6 +886,54 @@ Pass `--verbose` to additionally print the raw multi-line status breakdown (DB s Project resolution for domain commands follows the same order as deploy (`--project`, `VOID_PROJECT`, linked project). +## 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 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. While persistent log storage is still being wired up, the command says so and points you at `wrangler tail` for real-time activity. + +### `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 — and the project can send to them from then on. Re-run `void email destinations` to check whether they have verified yet. + +The project owner's email is added automatically when the project is created, so sending to yourself works without this step. + +### `void email disallow` + +``` +void email disallow
[--project ] +``` + +Remove one recipient from the project's destination list. Outbound mail to that address is rejected from the next deploy onward. + ## Agent ### `void init --agents` diff --git a/docs/reference/config.md b/docs/reference/config.md index e6309dddb..4dc596086 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -431,9 +431,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 the deploy manifest, Wrangler config generation/sync, 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. Outbound mail is sent through the Void platform proxy, which owns the `send_email` binding — none is written into your worker. 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/void/AGENT_PROMPT.md b/packages/void/AGENT_PROMPT.md index 838f44350..2259dddf9 100644 --- a/packages/void/AGENT_PROMPT.md +++ b/packages/void/AGENT_PROMPT.md @@ -1,6 +1,6 @@ ## Void -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, 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, 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, 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 + an `email/` directory for inbound handlers), 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. `drizzle-orm` and `drizzle-kit` ship with void (no extra install). Migrations live in `db/migrations/`. @@ -16,4 +16,4 @@ Requests: `void project logs` only has rows when a worker actually ran. 5xx gene Self-host deploy: `void deploy` targets the Void platform; to deploy to the user's OWN Cloudflare account instead, use `void deploy --backend cloudflare` (add `--provision` on the first deploy to create the D1/KV/R2/Queues/Hyperdrive resources the source needs). It uses local wrangler auth, so pin an account (`account_id` in `wrangler.jsonc` or `CLOUDFLARE_ACCOUNT_ID`) and keep real secrets in `wrangler secret put` — every `.env*` file this backend loads (`.env`, `.env.local`, `.env.production`, `.env.production.local`) ships as plaintext worker vars, `.local` included. Provisioning a Hyperdrive config reads `DATABASE_URL` from the shell, not from `.env*`. v1 supports full Void apps on the Cloudflare Workers target only (D1/KV/R2/Queues/Hyperdrive; auth and ISR on D1/SQLite); framework SSR of every kind (TanStack Start, React Router, vinext, SvelteKit, Nuxt, Analog, Astro), static/SPA/SSG apps (including `output: "static"`), WebSocket/Durable Object apps (`*.ws.ts`), a custom `migrations_pattern`, node/bun/deno targets, and PostgreSQL apps with auth all fail closed with guidance. Auth apps must ship checked-in migrations that produce the Better Auth schema. `--provision` is a single-operator, dev-machine step and is disabled in CI. -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. diff --git a/packages/void/getting-started-prompt.txt b/packages/void/getting-started-prompt.txt index 1c1e39365..881c4598f 100644 --- a/packages/void/getting-started-prompt.txt +++ b/packages/void/getting-started-prompt.txt @@ -19,6 +19,8 @@ To deploy to your OWN Cloudflare account instead of the Void platform, use `void Database workflow: define Drizzle tables in `db/schema.ts`, import `db` from `void/db` and tables from `@schema`. Use `void db push` to prototype (applies schema directly, no migration files). Use `void db generate` when ready to create production migration files in `db/migrations/`. `drizzle-orm` and `drizzle-kit` ship with void — no extra install needed. Scaffold new tables with `void gen model [columns...]`. +Email: send transactional mail with `import { sendEmail } from "void/email"`. Every project gets a platform sender — `+noreply@mail.void.cloud` — which is used when you omit `from`; a custom sender domain is not supported yet. 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`). Check quota and activity with `void email usage` and `void email logs`; during `void dev` every send is captured by the local inbox at `/__void/inbox` instead of being delivered. + 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 b3a309eb7..86f5eb549 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", @@ -203,6 +204,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", @@ -332,6 +343,7 @@ "ignore": "^7.0.5", "jsonc-parser": "3.3.1", "pg": "8.22.0", + "postal-mime": "^2.7.4", "proper-lockfile": "^4.1.2", "wrangler": "catalog:" }, diff --git a/packages/void/schema.json b/packages/void/schema.json index 705b97685..56fa3bc44 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 diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index 0942e435f..39752a78b 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -9,6 +9,7 @@ import { authSubcommands, parseAuthArgs } from './auth-args.ts'; import { buildSubcommands, parseBuildArgs } from './build-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'; @@ -124,6 +125,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)), @@ -206,6 +211,11 @@ if (helpPath) { hint: 'add | delete | list | status', description: 'Configure custom domains', }, + { + name: 'email', + hint: 'usage | logs | destinations | allow | disallow', + description: 'Inspect email activity and manage verified recipients', + }, { name: 'github', hint: 'install | installations | connect | update | status | disconnect | link | join', @@ -291,6 +301,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 29d0d6e71..16dd0ad66 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -115,6 +115,16 @@ export type ProjectResponse = { export type CreateProjectResponse = { id: string; slug: string; + /** + * Present when the platform auto-added the project owner's own address as + * an outbound email destination. `verified: false` means Cloudflare has + * mailed them a verification link that must be clicked before any send to + * that address is accepted. + */ + owner_email_destination?: { + address: string; + verified: boolean; + }; }; export type DeleteProjectResponse = { @@ -195,6 +205,38 @@ 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; +}; + export type DomainResponse = { id: string; project_id: string; @@ -1197,4 +1239,86 @@ 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); + } + + return res.json(); + } + + 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/create-project.ts b/packages/void/src/cli/create-project.ts index 6bd083286..2aaa6d5fb 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, config); + if (project.owner_email_destination) { + const { address, verified } = project.owner_email_destination; + if (verified) { + log.info(`${address} is already verified as a sender for this project.`); + } else { + log.info( + `Added ${address} as a sender for this project — check your inbox for a Cloudflare verification link.`, + ); + } + } return config; } diff --git a/packages/void/src/cli/email-args.ts b/packages/void/src/cli/email-args.ts new file mode 100644 index 000000000..5705eaed1 --- /dev/null +++ b/packages/void/src/cli/email-args.ts @@ -0,0 +1,63 @@ +import type { SubcommandDef } from './subcommand-prompt.ts'; + +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' }, +]; + +export interface EmailArgs { + subcommand: 'usage' | 'logs' | 'destinations' | 'allow' | 'disallow'; + projectSlug?: string; + limit?: number; + address?: string; +} + +export function parseEmailArgs(args: Array): EmailArgs { + const subcommand = args[0] as EmailArgs['subcommand']; + if (!subcommand || !['usage', 'logs', 'destinations', 'allow', 'disallow'].includes(subcommand)) { + throw new Error( + 'email: Usage: void email [args] [--project ] [--limit ].', + ); + } + + let projectSlug: string | undefined; + let limit: number | undefined; + const positional: Array = []; + + const rest = args.slice(1); + for (let i = 0; i < rest.length; i++) { + if (rest[i] === '--project' && rest[i + 1]) { + projectSlug = rest[++i]; + } else if (rest[i] === '--limit' && rest[i + 1]) { + const raw = rest[++i]; + 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; + } else { + positional.push(rest[i]); + } + } + + // `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 ].`); + } + + return { subcommand, projectSlug, limit, address }; +} diff --git a/packages/void/src/cli/email.ts b/packages/void/src/cli/email.ts new file mode 100644 index 000000000..b88310d7a --- /dev/null +++ b/packages/void/src/cli/email.ts @@ -0,0 +1,137 @@ +import { log, note } from '@clack/prompts'; +import { getToken } from './auth.ts'; +import { PlatformClient } from './client.ts'; +import type { EmailArgs } from './email-args.ts'; +import { pc } from './output.ts'; +import { readProjectConfig } from './project.ts'; + +export async function runEmailCommand(root: string, args: EmailArgs): Promise { + 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'); +} + +async function emailAllow( + client: PlatformClient, + projectId: string, + address: string, +): Promise { + const dest = await client.addEmailDestination(projectId, address); + if (dest.status === 'verified') { + log.success(`${dest.address} is already verified.`); + } else { + 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.', + ); + } +} + +async function emailDisallow( + client: PlatformClient, + 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}. Outbound to this address will be rejected on next deploy.`); +} + +async function emailLogs(client: PlatformClient, 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.'); + log.info('Real-time activity is visible in `wrangler tail` output for the proxy worker.'); + 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'); +} diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index c6575c060..335a06bab 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -233,6 +233,36 @@ 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' }, + ], + children: { + usage: withProjectOption( + 'Show monthly outbound/inbound counts and quota.', + 'void email usage [options]', + ), + logs: withProjectOption('Show recent email delivery activity.', 'void email logs [options]'), + 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]', + ), + }, + }, github: { summary: 'Connect a GitHub repository for automatic deploys.', usage: ['void github '], @@ -630,6 +660,8 @@ export function printGlobalHelp(): void { '', ...globalCommandGroup('domain'), '', + ...globalCommandGroup('email'), + '', ...globalCommandGroup('github'), '', ...globalCommandGroup('build'), diff --git a/packages/void/src/config.ts b/packages/void/src/config.ts index 945e330f3..616ec3c15 100644 --- a/packages/void/src/config.ts +++ b/packages/void/src/config.ts @@ -64,6 +64,7 @@ export type InferenceConfig = { kv?: boolean | string; storage?: boolean | string; ai?: boolean; + email?: boolean; }; scanDirs?: ReadonlyArray; build?: string; @@ -757,12 +758,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.`, diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 1af8731a5..f61ca8551 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -41,6 +41,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'; @@ -1070,6 +1071,11 @@ export function voidPlugin(options?: { result.vars = vars; } + // Email — no 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. + // Queues — add producer + consumer bindings for local dev if (queues.length > 0) { (result as Record).queues = { @@ -1502,6 +1508,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)'); @@ -1648,6 +1657,7 @@ export function voidPlugin(options?: { createWranglerCompatPersistPlugin(root, config), restartPlugin(), bindingWatchPlugin, + ...(bindings.needsEmail ? [emailDevInboxPlugin()] : []), envPlugin, envSchemaPlugin(root, { onDefaults: applySchemaDefaultsToWorkerVars }), envClientGuardPlugin(root), @@ -1819,6 +1829,7 @@ export function voidPlugin(options?: { createWranglerCompatPersistPlugin(root, config), restartPlugin(), bindingWatchPlugin, + ...(bindings.needsEmail ? [emailDevInboxPlugin()] : []), envPlugin, envSchemaPlugin(root, { onDefaults: applySchemaDefaultsToWorkerVars }), envClientGuardPlugin(root), diff --git a/packages/void/src/inference/walk.ts b/packages/void/src/inference/walk.ts index 2a54c6a56..c088b3de4 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,9 @@ export function inferBindingsFromSource(code: string, filename: string): Inferre case 'void/sandbox': result.needsSandbox = true; break; + case 'void/email': + result.needsEmail = true; + break; case 'void/live': if (!isTypeOnlyImportStatement(code.slice(imp.ss, imp.se + 1))) { result.needsLive = true; @@ -109,6 +118,9 @@ export function inferBindingsFromSource(code: string, filename: string): Inferre if (/\bfrom\s+["']void\/sandbox["']/.test(code)) { result.needsSandbox = true; } + if (/\bfrom\s+["']void\/email["']/.test(code)) { + result.needsEmail = true; + } if (hasRuntimeImportFrom(code, 'void/live')) { result.needsLive = true; } @@ -120,7 +132,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 +266,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..36130e9d0 --- /dev/null +++ b/packages/void/src/plugin-email-dev-inbox.ts @@ -0,0 +1,221 @@ +// Dev-only Vite plugin: registers an in-memory email sink and serves +// `/__void/inbox` routes for browsing captured messages. Activated when +// binding inference detected `void/email` usage. + +import type { Plugin } from 'vite'; +import { + clearDevBuffer, + findDevMessage, + listDevMessages, + pushToDevBuffer, + registerEmailSink, + type CapturedMessage, +} from './runtime/email/dev-inbox.ts'; + +const INBOX_PREFIX = '/__void/inbox'; + +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 function emailDevInboxPlugin(): Plugin { + let devMode = false; + let unregister: (() => void) | undefined; + + 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; + } + + 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 (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())); + 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)); + }); + }, + }; +} + +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): 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): 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-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 253e8366d..2cf989416 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -106,6 +106,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))); @@ -114,6 +115,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( @@ -122,6 +124,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 + '/'); } @@ -199,6 +206,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'), @@ -429,23 +438,20 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } else { writeFileIfChanged( join(outDir, 'entry.ts'), - compileWorkerEntry( - false, + compileWorkerEntry({ remote, - undefined, - undefined, - 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, - undefined, - options.live, - ), + live: options.live, + }), ); } @@ -567,25 +573,27 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const isr = emitIsr ? { runtimePath: resolveFile('#self/runtime/isr-cache') } : undefined; writeFileIfChanged( join(outDir, 'entry.ts'), - compileWorkerEntry( - scan!.queues.length > 0, + compileWorkerEntry({ + hasQueues: scan!.queues.length > 0, remote, - resolvedConfig?.build.assetsDir, - (scan!.websockets ?? []).map((route) => route.className), - compileOptions.runtimeEnvPath, - getEnvSchemaModule(), - options.hasSandbox + assetsDir: resolvedConfig?.build.assetsDir, + webSocketClasses: (scan!.websockets ?? []).map((route) => route.className), + 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(), - ), + hasEmail: hasEmailHandlers(), + serveSpaFallback: shouldServeSpaFallback(), + live: options.live, + isr: isr, + notFound: currentNotFoundHandling(), + }), ); } }, @@ -661,6 +669,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); @@ -704,25 +713,27 @@ 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, - (scan!.websockets ?? []).map((route) => route.className), - compileOptions.runtimeEnvPath, - getEnvSchemaModule(), - options.hasSandbox + assetsDir: resolvedConfig?.build.assetsDir, + webSocketClasses: (scan!.websockets ?? []).map((route) => route.className), + 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(), - ), + hasEmail: hasEmailHandlers(), + serveSpaFallback: shouldServeSpaFallback(), + live: options.live, + isr: undefined, + notFound: currentNotFoundHandling(), + }), ); } 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 d2de8be04..98a968a11 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 */ @@ -1538,6 +1547,112 @@ window.__vite_plugin_react_preamble_installed__ = true; lines.push(''); } + // Inbound email — generate __emailHandler that dispatches by recipient + // local-part. Patterns are tried in specificity order (static-tag > static + // > dynamic-tag > dynamic); on match, 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). The dispatcher + // therefore strips everything up to and including the first `+` from the + // local-part before running it through the pattern table. If the recipient + // arrived without a `+` (e.g. local-dev edge cases or future custom-domain + // routing), the stripped value is empty, no anchored pattern matches, and + // we fall through to `_default`. + 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) {'); + // 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 __to = String((message && (message.__voidPlatformTo ?? message.to)) || "");', + ); + lines.push(' const __localFull = __to.split("@")[0] || "";'); + // Strip the platform-injected `+` prefix. The shared sender + // rewrites every recipient to `+`; user-defined + // handlers match on the `` only. If there is no `+` (recipient came + // in without sub-addressing), tag is empty — falls through to `_default`. + lines.push(' const __plus = __localFull.indexOf("+");'); + lines.push(' const __local = __plus === -1 ? "" : __localFull.slice(__plus + 1);'); + 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; @@ -2459,37 +2574,79 @@ ${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 }, + isr?: { runtimePath: string }; /** * 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', -): string { + notFound?: NotFoundHandling; +} + +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', + } = options; const remotePath = remote?.remotePath; const remoteBindingNames = JSON.stringify(remote?.bindingNames); const createRemoteEnvCall = `createRemoteEnv(env${ @@ -2672,12 +2829,65 @@ ${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. + const emailImport = hasEmail ? ', __emailHandler' : ''; + const emailRuntimePath = runtimeEmailPath ?? deriveEmailRuntimePath(runtimeEnvPath); + const emailProxyImport = hasEmail + ? `import { createMessageProxy as __createMessageProxy } 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. The + // generated `__emailHandler` routes on the wire address instead, which + // `createMessageProxy` preserves as `__voidPlatformTo`. + 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))); + return getActions(); +}` + : `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))); + return getActions(); +}` + : ''; + + // 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'; -${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'; +${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(), @@ -2701,16 +2911,18 @@ ${envValidateCall}${remotePath ? ` if (env.__VOID_REMOTE === "1") env = ${creat ${webSocketClasses.map((name) => `export { ${name} };`).join('\n')} ${liveExport} ${sandboxExport} -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'; -${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'; +${runtimeEnvImport}${emailProxyImport}${envSchemaImport}${remoteImport}${isrImport}${filterBindingsHelper}${assetHelper} ${internalRouteHelper} +${fetchDeclaration} ${scheduledHandler} +${emailHandler} ${webSocketClasses.map((name) => `export { ${name} };`).join('\n')} ${liveExport} ${sandboxExport} -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..3ea659f5c --- /dev/null +++ b/packages/void/src/router/email.ts @@ -0,0 +1,247 @@ +/** + * 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 + * + * 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), targeting the lowercased local-part. */ + 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, + }; +} + +const SPECIFICITY: Record = { + static: 0, + 'static-tag': 1, + dynamic: 2, + 'dynamic-tag': 3, +}; + +/** + * Final dispatch order: most specific first. Within a specificity bucket, + * static-tag beats static (more constraints), dynamic-tag beats dynamic. + * The order below matches the precedence list in the file header. + */ +const DISPATCH_ORDER: Record = { + 'static-tag': 0, + static: 1, + 'dynamic-tag': 2, + dynamic: 3, +}; + +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(/\.[^.]+$/, ''); + if (basename === '_default') { + defaultHandler = filePath; + continue; + } + if (basename.startsWith('_')) { + continue; + } + const pattern = compileEmailPattern(basename); + if (!pattern) { + continue; + } + addresses.push({ + address: basename.toLowerCase(), + filePath, + pattern, + }); + } + + addresses.sort((a, b) => { + const da = DISPATCH_ORDER[a.pattern.kind]; + const db = DISPATCH_ORDER[b.pattern.kind]; + if (da !== db) { + return da - db; + } + // Same kind — keep deterministic alphabetical order. + return a.address.localeCompare(b.address); + }); + + // 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); +} + +// SPECIFICITY is exported for tests/tooling that want to inspect ordering. +export { SPECIFICITY }; 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..aab113dd8 --- /dev/null +++ b/packages/void/src/runtime/email.ts @@ -0,0 +1,467 @@ +// void/email — outbound email via the platform proxy. +// +// 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). +// +// In `void dev` (no `__VOID_PROXY`) sends are captured by the in-memory +// dev inbox; pass `sendInDev: true` for a single call to bypass. + +import { captureMessage, dispatchToSinks, hasActiveSink } 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, parseEmail, replyEmail } from './email/inbound.ts'; +export type { + EmailAction, + EmailEnvelope, + EmailHandler, + EmailHandlerInfo, + MessageProxy, + ParsedEmail, + ReplyEmailOptions, +} from './email/inbound.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; +} + +interface ProxyEnv { + __VOID_PROXY?: { fetch(req: Request | string, init?: RequestInit): Promise }; + __VOID_PROXY_TOKEN?: string; + __PROJECT_ID?: string; +} + +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; + } + 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'; +} + +// 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; +} + +async function readResponseAsResult(res: Response, 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)) { + return err( + 'UPSTREAM_ERROR', + `email: proxy returned malformed response (status ${res.status})`, + body, + ); + } + if (body.ok === true) { + // `ids` is `unknown[]` from the typeguard's perspective — validate + // each entry shape before surfacing it. + const ids: Array = []; + for (const d of body.ids as Array) { + if (typeof d !== 'object' || d === null) { + continue; + } + const entry = d as { recipient?: unknown; messageId?: unknown }; + const recipient = typeof entry.recipient === 'string' ? entry.recipient : ''; + if (!recipient) { + continue; + } + const mid = typeof entry.messageId === 'string' ? entry.messageId : ''; + ids.push({ recipient, messageId: mid || fallbackId }); + } + return { ok: true, ids }; + } + // 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 — surface per-recipient outcomes verbatim, normalizing + // unknown error codes to UPSTREAM_ERROR. Each delivery entry is + // independently validated so a single malformed entry can't crash + // the whole map. + const deliveries: Array = []; + for (const d of failure.deliveries as Array) { + if (typeof d !== 'object' || d === null) { + continue; + } + const entry = d as { + recipient?: unknown; + ok?: unknown; + messageId?: unknown; + error?: { code?: unknown; message?: unknown }; + }; + const recipient = typeof entry.recipient === 'string' ? entry.recipient : ''; + if (!recipient) { + continue; + } + if (entry.ok === true) { + const mid = typeof entry.messageId === 'string' ? entry.messageId : ''; + deliveries.push({ recipient, ok: true, messageId: mid || fallbackId }); + } else { + deliveries.push({ + recipient, + ok: false, + error: makeError( + coerceErrorCode(entry.error?.code), + typeof entry.error?.message === 'string' + ? entry.error.message + : 'email: upstream send failed', + ), + }); + } + } + return { ok: false, deliveries }; + } 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, + ); + } +} + +/** + * Send an email via the Void platform proxy. + * + * During `void dev` (when an email sink is registered) the message is + * captured locally and never reaches the proxy. 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); + } + + // Each sink owns its storage; sendEmail must NOT also push to the dev + // buffer or every dev send ends up captured twice. + if (!opts.sendInDev && hasActiveSink()) { + const captured = captureMessage(normalized, raw, messageId); + dispatchToSinks(captured); + const recipients = [...normalized.to, ...normalized.cc, ...normalized.bcc]; + if (recipients.length === 0) { + return err('INVALID_TO', "email: 'to' must include at least one recipient."); + } + const ids: Array = recipients.map((r) => ({ + recipient: r.email, + messageId: captured.id, + })); + return { ok: true, ids }; + } + + const env = tryGetRawRuntimeEnv() 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: __VOID_PROXY is not available. Deploy via Void so the platform proxy is bound, or run `void dev` for the local dev inbox.', + ); + } + + // 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). + 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); + } + if (recipients.length === 0) { + return err('INVALID_TO', "email: 'to' must include at least one recipient."); + } + + 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, 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..71f8206de --- /dev/null +++ b/packages/void/src/runtime/email/dev-inbox.ts @@ -0,0 +1,164 @@ +/** + * In-memory capture sink for emails during local dev / tests. + * + * Two consumers: + * 1. The Vite dev-server middleware (`/__void/inbox`) — registers a sink at + * plugin start so `sendEmail` calls during `void dev` go to a buffer + * browsable in the dashboard route handlers. + * 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. + * + * Sinks live on globalThis so they survive bundling boundaries (the Vite + * dev-server plugin runs in Node; user code runs in workerd; both share the + * same JS runtime in `vite dev`'s in-process worker mode). + */ + +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; + +const SINK_KEY = '__voidEmailSinks__'; +const BUFFER_KEY = '__voidEmailDevBuffer__'; +const DEFAULT_BUFFER_LIMIT = 100; + +interface DevBuffer { + limit: 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, items: [] }; + g[BUFFER_KEY] = b; + } + return b; +} + +export function pushToDevBuffer(message: CapturedMessage): void { + const b = getDevBuffer(); + b.items.push(message); + while (b.items.length > b.limit) { + b.items.shift(); + } +} + +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 { + getDevBuffer().items.length = 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..1d47d4b7f --- /dev/null +++ b/packages/void/src/runtime/email/inbound.ts @@ -0,0 +1,596 @@ +// `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. + +import type { Email as PostalMimeEmail } from 'postal-mime'; +import { getRawRuntimeEnv } from '../env-raw.ts'; +import { buildMime, MAX_MESSAGE_BYTES, normalizeEmail } 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])?$/; + +function tryReadProjectSlug(): string | undefined { + let raw: unknown; + try { + raw = getRawRuntimeEnv()?.__VOID_PROJECT_SLUG; + } catch { + return undefined; + } + if (typeof raw !== 'string' || raw.length === 0) { + return undefined; + } + return 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) { + super(message); + 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); + const source = cached ? await cached.read() : message.raw; + return await PostalMime.parse(source); +} + +/** + * 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; +} + +async function readStreamToBytes(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const chunks: Array = []; + let total = 0; + for (;;) { + const { value, done } = await reader.read(); + if (done) { + break; + } + chunks.push(value); + total += value.byteLength; + } + 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 either a CF `EmailMessage`-shaped object (for native + * compatibility) or any duck-typed `{ from, to, raw }`. 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; + + 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; + // 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 }); + }, + 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; + } + // 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) { + headersObj = { ...fwdHeaders }; + } + 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; + } + const rawBytes = + reply.raw instanceof Uint8Array ? reply.raw : await readStreamToBytes(reply.raw); + 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] }; +} + +export interface ReplyEmailOptions { + /** Defaults to the original `message.to` envelope address. */ + 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). */ + subject?: string; + text?: string; + html?: string; + /** Custom headers; threading headers (`In-Reply-To`, `References`) are added automatically. */ + headers?: Record; + attachments?: Array; +} + +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. + const decoded = decodeRfc2047(original).trim(); + if (!decoded) { + return 'Re:'; + } + if (/^re:/i.test(decoded)) { + return decoded; + } + return `Re: ${decoded}`; +} + +/** + * 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 { + return value.replace( + /=\?([\w-]+)\?([BbQq])\?([^?]*)\?=(?:\s+(?==\?))?/g, + (_match, charset: string, enc: string, payload: string) => { + 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 }); + } + return decoder.decode(bytes); + }, + ); + } 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. + const bytes: Array = []; + for (let i = 0; i < qp.length; i++) { + const ch = qp.charCodeAt(i); + if (ch === 0x5f /* _ */) { + bytes.push(0x20); + } else if (ch === 0x3d /* = */ && i + 2 < qp.length) { + const hex = qp.slice(i + 1, i + 3); + const v = parseInt(hex, 16); + if (Number.isFinite(v)) { + bytes.push(v); + i += 2; + } else { + bytes.push(ch); + } + } else { + bytes.push(ch); + } + } + return new Uint8Array(bytes); +} + +/** + * 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. 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: build `+noreply@` from + // the platform-injected slug and mail domain. Falling back to + // `message.to` 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 either + // binding is missing (local tests without env, etc.) 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. + let fromAddr: Address; + if (opts.from !== undefined) { + fromAddr = opts.from; + } else { + const slug = tryReadProjectSlug(); + const domain = tryReadEmailDomain(); + if (!slug || !domain) { + throw new Error( + "replyEmail: 'from' is required when __VOID_PROJECT_SLUG and " + + '__VOID_EMAIL_DOMAIN are not set. 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}`; + } + 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. + const threading: { inReplyTo?: string; references?: string } = {}; + if (originalMessageId) { + threading.inReplyTo = originalMessageId; + threading.references = originalReferences + ? `${originalReferences} ${originalMessageId}`.trim() + : originalMessageId; + } else if (originalReferences) { + threading.references = originalReferences; + } + + 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. + */ +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 }); +} diff --git a/packages/void/src/runtime/email/mime.ts b/packages/void/src/runtime/email/mime.ts new file mode 100644 index 000000000..b904846b8 --- /dev/null +++ b/packages/void/src/runtime/email/mime.ts @@ -0,0 +1,625 @@ +/** + * 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 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; + +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.`); + } + } +} + +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'; +} + +function validateAddressParts(field: string, email: string, name?: string): void { + rejectCRLF(`${field} address`, email); + // Address must be a single token with an '@' and no whitespace, no + // quoting, and no list-separators. Without this, a `to: "a@x,b@y"` + // would slip through the bare-string path and split into two + // recipients in the rendered headers. + if (!email.includes('@') || /\s/.test(email) || /[,;<>"()[\]\\]/.test(email)) { + throw new MimeError( + fieldErrorCode(field), + `${field}: '${email}' is not a valid email address.`, + ); + } + if (name !== undefined) { + rejectCRLF(`${field} display name`, name); + // `@` 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).`, + ); + } + } +} + +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. + const angle = + value.match(/^\s*"((?:[^"\\]|\\.)*)"\s*<\s*([^>\s]+)\s*>\s*$/) ?? + value.match(/^\s*([^"<]+?)?\s*<\s*([^>\s]+)\s*>\s*$/); + if (angle) { + const name = 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)); +} + +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); + if (att.contentType !== undefined) { + rejectCRLF('attachment contentType', att.contentType); + } + if (att.contentId !== undefined) { + rejectCRLF('attachment contentId', att.contentId); + } + 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.`, + ); + } + rejectCRLF(`header '${name}'`, value); + } + } + 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; + const attachments = opts.attachments + ? await Promise.all(opts.attachments.map(normalizeAttachment)) + : []; + // 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 encoded-word for non-ASCII text in headers (UTF-8, base64). */ +function encodeWord(text: string): string { + if (ASCII_PRINTABLE.test(text)) { + return text; + } + const b64 = base64(new TextEncoder().encode(text)); + return `=?UTF-8?B?${b64}?=`; +} + +// RFC 5322 specials — names containing any of these MUST be quoted as a +// phrase. The earlier code path put this check inside the ATOM_SAFE +// branch, but ATOM_SAFE already excluded these specials, so the check was +// unreachable for `,` `(` `)` `<` `>` etc. — the fall-through hit +// encodeWord which short-circuits on ASCII and emitted unquoted, letting +// a comma in a display name split the address list. +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 long header lines at address-list boundaries (`, `) outside + * any double-quoted string. A naive `split(/, /)` would split inside + * `"Smith, John"`, corrupting quoted display names. + */ +function splitAddressList(value: string): Array { + const parts: Array = []; + let current = ''; + let inQuotes = false; + let escape = false; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (escape) { + current += ch; + escape = false; + continue; + } + if (ch === '\\' && inQuotes) { + current += ch; + escape = true; + continue; + } + if (ch === '"') { + inQuotes = !inQuotes; + current += ch; + continue; + } + if (!inQuotes && ch === ',' && value[i + 1] === ' ') { + parts.push(current); + current = ''; + i++; // skip the space + continue; + } + current += ch; + } + parts.push(current); + return parts; +} + +function foldHeader(name: string, value: string): string { + const line = `${name}: ${value}`; + if (line.length <= 78) { + return line; + } + const parts = splitAddressList(value); + if (parts.length > 1) { + let out = `${name}: ${parts[0]}`; + let lineLen = out.length; + for (let i = 1; i < parts.length; i++) { + const next = `, ${parts[i]}`; + if (lineLen + next.length > 78) { + out += ',\r\n ' + parts[i]; + lineLen = parts[i].length + 1; + } else { + out += next; + lineLen += next.length; + } + } + return out; + } + // No address-list separators to fold at. Some headers (notably + // `References:` with many message IDs) accumulate whitespace-separated + // tokens that can blow past RFC 5322's 998-octet hard limit on a + // single physical line. Fall back to whitespace folding: split on + // runs of whitespace and emit at most ~76 octets per physical line, + // each continuation prefixed with CRLF + a space (the structured + // folding rule). Tokens that individually exceed 76 chars are emitted + // unbroken on their own line — RFC 5322 §3.2.2 lets folding only + // occur at WSP, never inside a token. + const whitespaceTokens = value.split(/\s+/).filter((t) => t.length > 0); + if (whitespaceTokens.length <= 1) { + return line; + } + let out = `${name}: ${whitespaceTokens[0]}`; + // First emitted line includes the header name; subsequent emitted + // lines are continuations and start with a single leading WSP. + let lineLen = out.length; + const MAX_LINE = 76; + for (let i = 1; i < whitespaceTokens.length; i++) { + const tok = whitespaceTokens[i]; + if (lineLen + 1 + tok.length > MAX_LINE) { + out += '\r\n ' + tok; + lineLen = 1 + tok.length; + } else { + out += ' ' + tok; + lineLen += 1 + tok.length; + } + } + return out; +} + +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); + let out = ''; + let lineLen = 0; + const flush = (chunk: string) => { + if (lineLen + chunk.length > 75) { + out += '=' + CRLF; + lineLen = 0; + } + out += chunk; + lineLen += chunk.length; + }; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i]; + if (b === 0x0a) { + out += CRLF; + 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; + } + out += CRLF; + 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) { + flush(b === 0x20 ? '=20' : '=09'); + } else { + flush(String.fromCharCode(b)); + } + continue; + } + if (b === 0x3d || b < 0x20 || b > 0x7e) { + flush('=' + b.toString(16).toUpperCase().padStart(2, '0')); + continue; + } + flush(String.fromCharCode(b)); + } + return out; +} + +function needsQuotedPrintable(text: string): boolean { + // Encode any non-ASCII or any line longer than 998 bytes (RFC 5322 limit). + if (!ASCII_PRINTABLE.test(text.replace(/\r?\n/g, ''))) { + return true; + } + return text.split(/\r?\n/).some((line) => line.length > 990); +} + +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) : body.replace(/\r?\n/g, CRLF), + }; +} + +function buildAttachmentPart(att: NormalizedAttachment): MimePart { + const headers: Array<[string, string]> = [ + ['Content-Type', `${att.contentType}; name="${att.filename.replace(/"/g, '')}"`], + ['Content-Transfer-Encoding', 'base64'], + ['Content-Disposition', `${att.disposition}; filename="${att.filename.replace(/"/g, '')}"`], + ]; + 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()), +); + +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 } { + 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); + + 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..07449dded --- /dev/null +++ b/packages/void/src/runtime/email/testing.ts @@ -0,0 +1,417 @@ +/** + * 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", + * to: "support+abc-1@app.dev", + * subject: "help", + * text: "thanks", + * }); + * expect(result.handler).toBe("support+[ticket]"); + * expect(result.params).toEqual({ ticket: "abc-1" }); + */ + +import { compileEmailPattern, type CompiledEmailPattern } from '../../router/email.ts'; +import { registerEmailSink, type CapturedMessage } from './dev-inbox.ts'; +import type { EmailHandler, EmailHandlerInfo } 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. + */ + slug?: string; +} + +interface CompiledRoute { + key: string; + pattern: CompiledEmailPattern; + handler: EmailHandler; +} + +const DISPATCH_PRIORITY = { + 'static-tag': 0, + static: 1, + 'dynamic-tag': 2, + dynamic: 3, +} as const; + +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({ key, pattern, handler }); + } + compiled.sort((a, b) => { + const pa = DISPATCH_PRIORITY[a.pattern.kind]; + const pb = DISPATCH_PRIORITY[b.pattern.kind]; + if (pa !== pb) { + return pa - pb; + } + return a.key.localeCompare(b.key); + }); + 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; +} + +function stripSlugForHarness(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); + const prefix = `${slug.toLowerCase()}+`; + if (localPart.toLowerCase().startsWith(prefix)) { + return localPart.slice(prefix.length) + domain; + } + return address; +} + +function makeReadableStream(bytes: Uint8Array): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +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); + const headers = new Headers(input.headers ?? {}); + if (input.subject && !headers.has('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's setReject-is-terminal contract from + // createMessageProxy: once setReject fires, subsequent forward/reply + // calls are warned and dropped. Without this flag the harness would + // record forwards/replies that the real CF runtime silently discards + // — passing the user's tests for buggy handler code that fails in + // production. + // 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 inferredSlug = (() => { + if (opts.slug) { + return opts.slug; + } + const local = input.to.split('@')[0] ?? ''; + const plus = local.indexOf('+'); + return plus === -1 ? undefined : local.slice(0, plus); + })(); + const userVisibleTo = stripSlugForHarness(input.to, inferredSlug); + + // Per-delivery records used to walk back forward/reply on setReject — + // CF treats setReject as terminal, dropping anything queued before it + // in the same dispatch. The harness mirrors that: prior forward/reply + // entries from THIS delivery are popped off the public arrays so the + // audit trail matches what the real router would actually enact. + const localForwards: Array = []; + const localReplies: Array = []; + + let rejected = false; + const message: ForwardableEmailMessage = { + from: input.from, + to: userVisibleTo, + headers, + raw: makeReadableStream(raw), + rawSize: raw.byteLength, + setReject(reason: string) { + if (rejected) { + return; // already rejected; ignore (matches production) + } + rejected = true; + // Drop any forward/reply this handler queued earlier — they never + // would have run in production. Pop from the public arrays in + // reverse so indices remain valid as we splice. + const dropped = localForwards.length + localReplies.length; + for (let i = localForwards.length - 1; i >= 0; i--) { + const idx = forwards.lastIndexOf(localForwards[i]); + if (idx >= 0) { + forwards.splice(idx, 1); + } + } + for (let i = localReplies.length - 1; i >= 0; i--) { + const idx = replies.lastIndexOf(localReplies[i]); + if (idx >= 0) { + replies.splice(idx, 1); + } + } + localForwards.length = 0; + localReplies.length = 0; + 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.', + ); + } + rejects.push(reason); + }, + async forward(forwardTo: string, forwardHeaders?: Headers) { + if (rejected) { + console.warn( + '[void/email] forward() called after setReject() — ignored. ' + + 'CF rejects are terminal: nothing else runs once setReject fires.', + ); + return; + } + const entry = { to: forwardTo, headers: forwardHeaders }; + forwards.push(entry); + localForwards.push(entry); + }, + async reply(reply: { from: string; to: string; raw?: unknown }) { + if (rejected) { + console.warn( + '[void/email] reply() called after setReject() — ignored. ' + + 'CF rejects are terminal: nothing else runs once setReject fires.', + ); + return; + } + // The reply EmailMessage carries .from/.to publicly; its raw body + // isn't part of the public type but we read it best-effort so tests + // can assert on the threading headers / subject. + let rawBytes = new Uint8Array(0); + const r = (reply as { raw?: unknown }).raw; + if (r instanceof Uint8Array) { + // Copy into a fresh Uint8Array to match the field type + // — instanceof keeps the original ArrayBufferLike variance. + rawBytes = new Uint8Array(r); + } else if (typeof r === 'string') { + rawBytes = new TextEncoder().encode(r); + } + const entry = { from: reply.from, to: reply.to, raw: rawBytes }; + replies.push(entry); + localReplies.push(entry); + }, + } as unknown as ForwardableEmailMessage; + + const ctx = { + waitUntil() { + // no-op + }, + passThroughOnException() { + // no-op + }, + } as unknown as ExecutionContext; + + // Mirror the production dispatcher's slug-strip: in deployed code, the + // recipient is `+@mail.void.cloud` and the platform + // router 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. + const fullLocal = (input.to.split('@')[0] ?? '').toLowerCase(); + const plus = fullLocal.indexOf('+'); + const local = plus === -1 ? '' : fullLocal.slice(plus + 1); + // Note: 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. + + for (const route of compiled) { + const re = new RegExp(route.pattern.regexSource); + 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); + const result: InboundDeliveryResult = { handler: route.key, params }; + deliveries.push(result); + return result; + } + if (defaultHandler) { + const info: EmailHandlerInfo = { params: {} }; + await defaultHandler(message, env, ctx, info); + const result: InboundDeliveryResult = { handler: '_default', params: {} }; + deliveries.push(result); + return result; + } + const result: InboundDeliveryResult = { handler: undefined, params: {} }; + deliveries.push(result); + return result; + } + + 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..da451cd0f --- /dev/null +++ b/packages/void/src/runtime/email/types.ts @@ -0,0 +1,140 @@ +/** + * 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 and sends through the proxy as if running + * in production. Useful for one-off "test the real send pipeline" + * checks without restarting the dev server. + * + * 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/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index 6971ee3f0..3a4d87795 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -2235,13 +2235,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'); @@ -2250,17 +2261,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 };'); @@ -2268,20 +2277,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 };', @@ -2290,7 +2297,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 @@ -2304,10 +2317,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( @@ -2321,8 +2340,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( @@ -2340,12 +2365,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()'); @@ -2354,7 +2379,7 @@ describe('compileWorkerEntry', () => { }); it('omits queue() export when hasQueues=false', () => { - const code = compileWorkerEntry(false); + const code = compileWorkerEntry({ hasQueues: false }); expect(code).not.toContain('queue'); }); @@ -2410,7 +2435,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/"'); @@ -2426,16 +2451,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 @@ -2454,19 +2477,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. @@ -2485,19 +2504,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. @@ -2505,16 +2520,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 @@ -2523,7 +2536,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 @@ -2544,7 +2557,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)'); @@ -2557,9 +2570,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"'); @@ -2571,13 +2590,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"'); @@ -2589,17 +2608,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"})', @@ -2905,18 +2924,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' }, - ); + const code = compileWorkerEntry({ + hasQueues: false, + assetsDir: 'assets', + webSocketClasses: [], + runtimeEnvPath: 'void/_env', + serveSpaFallback: false, + isr: { runtimePath: 'void/isr' }, + }); // Baked import #1: handleIsr from the supplied runtime path. expect(code).toContain('import { handleIsr } from "void/isr"'); @@ -2948,18 +2963,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( @@ -2969,7 +2980,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);', ); @@ -2978,7 +2989,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( @@ -2986,7 +2997,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/email-args.test.ts b/packages/void/test/unit/email-args.test.ts new file mode 100644 index 000000000..9b3abfa3b --- /dev/null +++ b/packages/void/test/unit/email-args.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +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/, + ); + }); +}); 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..34f9fb2c2 --- /dev/null +++ b/packages/void/test/unit/email-inbound.test.ts @@ -0,0 +1,1151 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'pathslash'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +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 { + buildReplyMessage, + createMessageProxy, + defineEmail, + parseEmail, + replyEmail, + type EmailEnvelope, +} from '../../src/runtime/email/inbound.ts'; +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('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']); + }); +}); + +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) => 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, {}, {}); + + 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, {}, {}); + + expect(seen).toEqual([{ ticket: 'T-42' }]); + }); + + it('falls through when the recipient carries no + prefix', async () => { + // The platform router resolves the project FROM the slug prefix, so a bare + // `support@` is never delivered to a worker. The dispatcher therefore + // treats "no `+`" as "no tag" and falls through rather than guessing. + 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.void.cloud', + headers: {}, + raw: new Uint8Array(), + rawSize: 0, + }); + + await dispatch(message, {}, {}); + + expect(hits).toEqual(['default']); + }); + + 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, {}, {}); + + expect(rejections).toHaveLength(1); + expect(rejections[0]).toContain('no handler matched recipient'); + }); +}); + +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('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('lowercases the recipient before matching', 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'); + }); +}); + +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('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'); + }); +}); + +// --------------------------------------------------------------------------- +// 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 errors when __VOID_PROJECT_SLUG is unavailable', async () => { + // After F2/F3 the default `from = message.to` path is gone — the + // user-visible `message.to` is `@` (bare local-part) + // which the platform's slug-pin would reject. Without an explicit + // `from` and no __VOID_PROJECT_SLUG to default from, the call must + // fail loudly rather than silently constructing an invalid envelope. + await expect( + envContext.run({ __VOID_EMAIL_DOMAIN: 'mail.staging.void.cloud' }, () => + replyEmail(replyTestMessage(), { text: 'ack' }), + ), + ).rejects.toThrow(/__VOID_PROJECT_SLUG/); + }); + + 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('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..0baf2aae7 --- /dev/null +++ b/packages/void/test/unit/email-mime.test.ts @@ -0,0 +1,442 @@ +import { describe, expect, it } from 'vitest'; +import { buildMime, MimeError, normalizeEmail } from '../../src/runtime/email/mime.ts'; + +function decode(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +async function build(opts: Parameters[0]) { + const email = await normalizeEmail(opts); + const { raw, messageId } = buildMime(email); + return { raw, messageId, text: decode(raw) }; +} + +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/); + }); + + 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 whitespace inside an address', async () => { + await expect( + normalizeEmail({ from: 'a b@x.dev', to: 'b@y.dev', subject: 's', text: 't' }), + ).rejects.toThrow(/from/); + }); +}); + +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'); + }); + + 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('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" '); + }); +}); 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..3ec5ea10d --- /dev/null +++ b/packages/void/test/unit/email-send.test.ts @@ -0,0 +1,640 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { sendEmail } from '../../src/runtime/email.ts'; +import { clearDevBuffer, 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(); +}); + +/** + * 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(); + } + }); + + 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'); + } + }); +}); + +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('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(); + } + }); +}); + +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'); + } + }); + + it('drops malformed delivery entries inside an otherwise-valid mid-batch response', async () => { + const { proxy } = fakeProxy(() => ({ + status: 200, + body: { + ok: false, + deliveries: [ + { recipient: 'a@y.dev', ok: true, messageId: 'cf-a' }, + null, // garbage entry: must not crash the map + { recipient: 'b@y.dev', ok: false, error: null }, // missing error.message + 'string-not-object', + { ok: true, messageId: 'cf-x' }, // missing recipient + ], + }, + })); + const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ + from: 'a@x.dev', + to: ['a@y.dev', 'b@y.dev'], + subject: 's', + text: 't', + sendInDev: true, + }), + ); + expect(result.ok).toBe(false); + if (!result.ok && 'deliveries' in result) { + // Two valid entries (a@y.dev ok, b@y.dev failed) survive; garbage is dropped. + expect(result.deliveries).toHaveLength(2); + const a = result.deliveries.find((d) => d.recipient === 'a@y.dev'); + expect(a?.ok).toBe(true); + 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('UPSTREAM_ERROR'); + } + } + }); + + 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); + } + }); +}); + +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(); + } + }); +}); diff --git a/packages/void/test/unit/inference.test.ts b/packages/void/test/unit/inference.test.ts index 52c70a950..08d3e285c 100644 --- a/packages/void/test/unit/inference.test.ts +++ b/packages/void/test/unit/inference.test.ts @@ -312,6 +312,32 @@ 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); + }); + it('does not detect needsAI from unrelated identifiers', () => { const code = ` const AI = someLibrary; diff --git a/packages/void/test/unit/plugin-inference.test.ts b/packages/void/test/unit/plugin-inference.test.ts index 1b833e581..7498d1a4b 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, }); }); diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 27c4964eb..0b9d9ab25 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -381,6 +381,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 }); diff --git a/packages/void/vite.config.ts b/packages/void/vite.config.ts index c1631d879..1bce02e77 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; @@ -113,6 +115,8 @@ export default defineConfig({ 'src/runtime/isr-cache.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', @@ -147,6 +151,7 @@ export default defineConfig({ '@cloudflare/vite-plugin', 'better-sqlite3', 'cloudflare:workers', + 'cloudflare:email', '@void/vue', '@void/react', '@void/svelte', diff --git a/playground/kitchen-sink/email/_default.ts b/playground/kitchen-sink/email/_default.ts new file mode 100644 index 000000000..f44d75e92 --- /dev/null +++ b/playground/kitchen-sink/email/_default.ts @@ -0,0 +1,11 @@ +import { defineEmail } from 'void/email'; + +/** + * Fallback for any inbound message that doesn't match a per-address handler. + * Quietly rejects so unknown recipients get a non-delivery notice rather + * than silently disappearing. + */ +export default defineEmail(async (message) => { + console.log(`[email] unhandled to=${message.to} from=${message.from}`); + message.setReject(`No handler for ${message.to}`); +}); diff --git a/playground/kitchen-sink/email/support+[ticket].ts b/playground/kitchen-sink/email/support+[ticket].ts new file mode 100644 index 000000000..06097dc8f --- /dev/null +++ b/playground/kitchen-sink/email/support+[ticket].ts @@ -0,0 +1,26 @@ +import { defineEmail, parseEmail, replyEmail } from 'void/email'; + +/** + * Inbound handler for `support+@`. + * + * Demonstrates the full inbound lifecycle: + * - dynamic param capture (info.params.ticket) + * - parseEmail for the parsed body / subject + * - replyEmail with threading-aware reply MIME (In-Reply-To / References / Re:) + * + * For local dev: deliver a synthetic message via the test harness in + * `test/email-handler.test.ts` (or the inbox round-trip on `/__void/inbox` + * when that lands). + */ +export default defineEmail(async (message, _env, _ctx, info) => { + const ticket = info?.params.ticket ?? 'unknown'; + const parsed = await parseEmail(message); + + console.log(`[email] ticket=${ticket} from=${message.from} subject=${parsed.subject ?? ''}`); + + await replyEmail(message, { + text: + `Hi! Your message on ticket ${ticket} was received.\n\n` + + `We logged the subject: "${parsed.subject ?? ''}"`, + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19fa62a3e..5ff129a78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,9 @@ importers: pg: specifier: 8.22.0 version: 8.22.0 + postal-mime: + specifier: ^2.7.4 + version: 2.7.6 proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -1925,11 +1928,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} @@ -8045,6 +8048,9 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} + postal-mime@2.7.6: + resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==} + postcss-calc@10.1.1: resolution: {integrity: sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==} engines: {node: ^18.12 || ^20.9 || >=22.0} @@ -16403,6 +16409,8 @@ snapshots: pngjs@7.0.0: {} + postal-mime@2.7.6: {} + postcss-calc@10.1.1(postcss@8.5.16): dependencies: postcss: 8.5.16 From f8d8b1caa275afe18bd2c9d2f6e5dde5a2a0ce22 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 31 Aug 2026 12:44:05 +0800 Subject: [PATCH 2/8] fix(email): dev inbox capture, harness casing, and reply-sender docs Addresses four review findings, each reproduced before being fixed. The dev inbox never captured anything. The sink was registered on the Vite plugin's Node globalThis while user code runs in a separate workerd process, so hasActiveSink() was always false in the worker and every send fell through to BINDING_MISSING - whose message told the user to run void dev, which is what was already running. The comment in dev-inbox.ts claiming both sides share one runtime was simply wrong. sendEmail now hands captured messages to the dev server over the worker's assets binding, which the Cloudflare vite plugin already backs with the Vite middleware stack. Baking the dev-server origin as a var was rejected: mergeBindings runs before httpServer.listen, so it can only bake the configured port, and Vite increments on collision. The injection is gated on routeEnvironment === 'dev', which excludes vite preview, and carries a binding name rather than a credential; a production build emits vars: {}. A 200 is only trusted with the ack header, so a plain asset response can never look like a capture. An unreachable inbox returns UPSTREAM_ERROR under a 5s bound rather than claiming success or hanging the request. The test harness still lowercased the local part and matched without the i flag, so a handler under test saw ticket t-42 where production hands it T-42. The production dispatcher was fixed for exactly this reason and the shared pattern compiler documents the contract the harness was violating. A green suite would have certified behavior the deployed code does not have. Both documented harness examples used an address production never delivers: deliver() takes the wire form +@ and reads support+abc-123@acme.dev as slug support. The first example routed to _default; the second passed only because _default was its sole route. The same broken example was in the harness JSDoc. The reply docs claimed from defaults to message.to. It does not, and that address is the one the platform drops: the proxy pins a reply sender to the receiving slug, while message.to is the stripped @. A handler following the docs got a reply that vanished with no error. The guide and the ReplyEmailOptions JSDoc now document the pin, the throw, and the asymmetry with sendEmail, which returns errors instead. Also removed a promise of a local inbound round-trip; no inbound dev trigger exists. Class A frameworks declare no assets binding and so have no loopback; they still return BINDING_MISSING, which the guide now states. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW --- docs/guide/email.md | 41 +- packages/void/src/index.ts | 55 ++- packages/void/src/plugin-email-dev-inbox.ts | 89 ++++- packages/void/src/router/email.ts | 2 +- packages/void/src/runtime/email.ts | 152 +++++++- packages/void/src/runtime/email/dev-inbox.ts | 171 +++++++- packages/void/src/runtime/email/inbound.ts | 11 +- packages/void/src/runtime/email/testing.ts | 14 +- .../void/test/unit/email-dev-inbox.test.ts | 369 ++++++++++++++++++ packages/void/test/unit/email-inbound.test.ts | 29 +- 10 files changed, 893 insertions(+), 40 deletions(-) create mode 100644 packages/void/test/unit/email-dev-inbox.test.ts diff --git a/docs/guide/email.md b/docs/guide/email.md index 9f45244ae..af068e3dd 100644 --- a/docs/guide/email.md +++ b/docs/guide/email.md @@ -176,6 +176,8 @@ The inbox shows a list view with subject, sender, recipient, and timestamp. Clic 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. If you drive `void/email` from a meta-framework whose wrangler config declares no `assets` binding, there is no loopback: those sends return `BINDING_MISSING` instead of reaching the inbox. When the inbox is configured but unreachable, `sendEmail` returns `UPSTREAM_ERROR` — nothing is captured and nothing is sent. + To bypass the inbox for a single send during dev (e.g. to verify real delivery against a verified address), pass `sendInDev: true`: ```ts @@ -287,15 +289,44 @@ export default defineEmail(async (message, env, ctx, info) => { }); ``` -`replyEmail` builds the reply MIME with `Subject: Re: ` (no double-prefix), `In-Reply-To: `, and a continued `References` chain, then dispatches via the message's native `reply()`. Defaults: `from = message.to`, `to = message.from`, `subject = "Re: "`. Override any of them in opts. +`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: "`. + +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 those are absent and you omitted `from`, rather than +guessing a sender. + +::: 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. +::: + +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 delivery requires a Cloudflare Email Routing rule that points your domain at your worker. **Void manages this for you** — there's nothing to configure in the Cloudflare dashboard. (Provisioning lands in the platform's Phase 3; until then, deployed handlers sit dormant. Locally, use the dev inbox round-trip described below.) +In production, inbound delivery requires a Cloudflare Email Routing rule that points your domain at your worker. **Void manages this for you** — there's nothing to configure in the Cloudflare dashboard. (Provisioning lands in the platform's Phase 3; until then, deployed handlers sit dormant.) 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: +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, 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`. ```ts import { describe, it, expect } from 'vitest'; @@ -310,7 +341,7 @@ describe('email handlers', () => { }); const result = await harness.deliver({ from: 'user@example.com', - to: 'support+abc-123@acme.dev', + to: 'acme+support+abc-123@acme.dev', subject: 'Help', text: 'I have a problem', }); @@ -322,7 +353,7 @@ describe('email handlers', () => { const harness = createInboundTestHarness({ routes: { _default: defaultHandler } }); await harness.deliver({ from: 'user@example.com', - to: 'unknown@acme.dev', + to: 'acme+unknown@acme.dev', subject: 'STOP', text: 'bye', }); diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index f61ca8551..bbde14843 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -71,6 +71,7 @@ import { compileAssetFallbackHelper } from './router/compile.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 } from './runtime/email/dev-inbox.ts'; import { SANDBOX_MIGRATION_TAG, isSandboxEnabled, resolveSandboxConfig } from './sandbox/config.ts'; import { filterLoadedEnv, resolveRemoteMode, stripInternalEnvKeys } from './shared/env.ts'; import { DEFAULT_PROXY_URL, STAGING_PROXY_URL } from './shared/proxy.ts'; @@ -385,6 +386,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$/; @@ -1071,10 +1106,22 @@ export function voidPlugin(options?: { result.vars = vars; } - // Email — no 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. + // 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. + 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; + result.vars = vars; + } // Queues — add producer + consumer bindings for local dev if (queues.length > 0) { diff --git a/packages/void/src/plugin-email-dev-inbox.ts b/packages/void/src/plugin-email-dev-inbox.ts index 36130e9d0..c088391e6 100644 --- a/packages/void/src/plugin-email-dev-inbox.ts +++ b/packages/void/src/plugin-email-dev-inbox.ts @@ -1,18 +1,34 @@ -// Dev-only Vite plugin: registers an in-memory email sink and serves +// 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. import type { Plugin } from 'vite'; import { clearDevBuffer, + DEV_INBOX_ACK_HEADER, + DEV_INBOX_ACK_VALUE, + DEV_INBOX_CAPTURE_PATH, + DEV_INBOX_MAX_CAPTURE_BYTES, + DEV_INBOX_PREFIX, findDevMessage, listDevMessages, + parseCapturedMessage, pushToDevBuffer, registerEmailSink, type CapturedMessage, } from './runtime/email/dev-inbox.ts'; -const INBOX_PREFIX = '/__void/inbox'; +const INBOX_PREFIX = DEV_INBOX_PREFIX; const SECURITY_HEADERS = { 'Cache-Control': 'no-store', @@ -61,6 +77,10 @@ export function emailDevInboxPlugin(): Plugin { const url = new URL(req.url ?? '/', 'http://localhost'); const path = url.pathname; + 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); @@ -111,6 +131,71 @@ export function emailDevInboxPlugin(): Plugin { }; } +// 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, '&') diff --git a/packages/void/src/router/email.ts b/packages/void/src/router/email.ts index 3ea659f5c..48bfce58f 100644 --- a/packages/void/src/router/email.ts +++ b/packages/void/src/router/email.ts @@ -35,7 +35,7 @@ export type EmailPatternKind = 'static' | 'static-tag' | 'dynamic' | 'dynamic-ta export interface CompiledEmailPattern { kind: EmailPatternKind; - /** Regex source (anchored), targeting the lowercased local-part. */ + /** Regex source (anchored). Apply with the `i` flag; see the file header. */ regexSource: string; /** Capture-group names in order; empty for `static`. */ paramNames: Array; diff --git a/packages/void/src/runtime/email.ts b/packages/void/src/runtime/email.ts index aab113dd8..f3a1dae13 100644 --- a/packages/void/src/runtime/email.ts +++ b/packages/void/src/runtime/email.ts @@ -7,10 +7,23 @@ // destinations allowlist (mirrored from `email_destinations` into KV by // the API on every CRUD). // -// In `void dev` (no `__VOID_PROXY`) sends are captured by the in-memory -// dev inbox; pass `sendInDev: true` for a single call to bypass. +// 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, dispatchToSinks, hasActiveSink } from './email/dev-inbox.ts'; +import { + captureMessage, + DEV_INBOX_ACK_HEADER, + DEV_INBOX_ACK_VALUE, + DEV_INBOX_CAPTURE_URL, + DEV_INBOX_ENV_VAR, + dispatchToSinks, + hasActiveSink, + serializeCapturedMessage, + type CapturedMessage, +} from './email/dev-inbox.ts'; import { buildMime, MimeError, normalizeEmail } from './email/mime.ts'; import type { NormalizedEmail, @@ -325,12 +338,105 @@ async function readResponseAsResult(res: Response, fallbackId: string): Promise< } } +interface DevInboxFetcher { + fetch(req: Request | string, init?: RequestInit): Promise; +} + +/** + * 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): DevInboxFetcher | 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; + } + return typeof (binding as { fetch?: unknown }).fetch === 'function' + ? (binding as DevInboxFetcher) + : 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; + +async function postToDevInbox( + fetcher: DevInboxFetcher, + captured: CapturedMessage, +): Promise<{ ok: true } | { ok: false; reason: string }> { + let timer: ReturnType | undefined; + try { + const attempt = Promise.resolve( + fetcher.fetch(`http://localhost${DEV_INBOX_CAPTURE_URL}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + 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) { + return { ok: false, reason: `dev server responded ${res.status}` }; + } + // 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). */ +function capturedResult(normalized: NormalizedEmail, captureId: string): SendEmailResult { + const recipients = [...normalized.to, ...normalized.cc, ...normalized.bcc]; + if (recipients.length === 0) { + return err('INVALID_TO', "email: 'to' must include at least one recipient."); + } + return { + ok: true, + ids: recipients.map((r) => ({ recipient: r.email, messageId: captureId })), + }; +} + /** * Send an email via the Void platform proxy. * - * During `void dev` (when an email sink is registered) the message is - * captured locally and never reaches the proxy. Pass `sendInDev: true` - * to override for a single call. + * 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); @@ -370,30 +476,42 @@ export async function sendEmail(opts: SendEmailOptions): Promise = recipients.map((r) => ({ - recipient: r.email, - messageId: captured.id, - })); - return { ok: true, ids }; + 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 env = tryGetRawRuntimeEnv() as ProxyEnv | undefined; + 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: __VOID_PROXY is not available. Deploy via Void so the platform proxy is bound, or run `void dev` for the local dev inbox.', + 'email: __VOID_PROXY is not available, so nothing was sent. Deploy via Void to bind the platform proxy. ' + + '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`) or unavailable (the worker has no assets binding to reach the dev server through).', ); } diff --git a/packages/void/src/runtime/email/dev-inbox.ts b/packages/void/src/runtime/email/dev-inbox.ts index 71f8206de..4bd8516f8 100644 --- a/packages/void/src/runtime/email/dev-inbox.ts +++ b/packages/void/src/runtime/email/dev-inbox.ts @@ -2,16 +2,24 @@ * In-memory capture sink for emails during local dev / tests. * * Two consumers: - * 1. The Vite dev-server middleware (`/__void/inbox`) — registers a sink at - * plugin start so `sendEmail` calls during `void dev` go to a buffer - * browsable in the dashboard route handlers. + * 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. * - * Sinks live on globalThis so they survive bundling boundaries (the Vite - * dev-server plugin runs in Node; user code runs in workerd; both share the - * same JS runtime in `vite dev`'s in-process worker mode). + * 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 type { NormalizedEmail, NormalizedAttachment } from './types.ts'; @@ -41,6 +49,157 @@ export interface CapturedMessage { 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'; + +/** + * 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. */ +export const DEV_INBOX_MAX_CAPTURE_BYTES: number = 32 * 1024 * 1024; + +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; diff --git a/packages/void/src/runtime/email/inbound.ts b/packages/void/src/runtime/email/inbound.ts index 1d47d4b7f..f4c9f116f 100644 --- a/packages/void/src/runtime/email/inbound.ts +++ b/packages/void/src/runtime/email/inbound.ts @@ -393,7 +393,16 @@ export function createMessageProxy( } export interface ReplyEmailOptions { - /** Defaults to the original `message.to` envelope address. */ + /** + * Defaults to `<__VOID_PROJECT_SLUG>+noreply@<__VOID_EMAIL_DOMAIN>`, and + * THROWS when those bindings are absent — a reply sender is never guessed. + * + * 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. + */ from?: Address; /** * Defaults to the original `message.from` envelope address. A reply is a diff --git a/packages/void/src/runtime/email/testing.ts b/packages/void/src/runtime/email/testing.ts index 07449dded..b40ca5d5e 100644 --- a/packages/void/src/runtime/email/testing.ts +++ b/packages/void/src/runtime/email/testing.ts @@ -18,7 +18,10 @@ * }); * const result = await inbound.deliver({ * from: "user@example.com", - * to: "support+abc-1@app.dev", + * // 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", * }); @@ -358,7 +361,12 @@ export function createInboundTestHarness(opts: InboundTestHarnessOptions): Inbou // router 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. - const fullLocal = (input.to.split('@')[0] ?? '').toLowerCase(); + // 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 = plus === -1 ? '' : fullLocal.slice(plus + 1); // Note: a recipient with no `+` (e.g. `slug@mail.void.cloud`) has no @@ -366,7 +374,7 @@ export function createInboundTestHarness(opts: InboundTestHarnessOptions): Inbou // no-op. We mirror that here. for (const route of compiled) { - const re = new RegExp(route.pattern.regexSource); + const re = new RegExp(route.pattern.regexSource, 'i'); const m = re.exec(local); if (!m) { continue; 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..2efbdb354 --- /dev/null +++ b/packages/void/test/unit/email-dev-inbox.test.ts @@ -0,0 +1,369 @@ +/** + * 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 { Readable } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { shouldInjectEmailDevInboxVar } from '../../src/index.ts'; +import { emailDevInboxPlugin } from '../../src/plugin-email-dev-inbox.ts'; +import { sendEmail } from '../../src/runtime/email.ts'; +import { + clearDevBuffer, + DEV_INBOX_ACK_HEADER, + DEV_INBOX_ACK_VALUE, + DEV_INBOX_CAPTURE_URL, + DEV_INBOX_ENV_VAR, + hasActiveSink, + listDevMessages, + parseCapturedMessage, + serializeCapturedMessage, + type CapturedMessage, +} from '../../src/runtime/email/dev-inbox.ts'; +import { withRuntimeEnv } from '../../src/runtime/env.ts'; + +// --------------------------------------------------------------------------- +// 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 }, + 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(); + 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)); + } + resolve(new Response(Buffer.concat(chunks), { 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]); +} + +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(); + }); +}); + +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({ [DEV_INBOX_ENV_VAR]: 'ASSETS', ASSETS: 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(new Request('http://localhost/__void/inbox')); + expect(subjectsInListHtml(await listed.text())).toContain('Cross-process hello'); + }); + + it('serves the captured .eml back from the raw route', async () => { + const server = createFakeViteServer(); + const assets = fakeAssetsBinding(server); + await withRuntimeEnv({ [DEV_INBOX_ENV_VAR]: 'ASSETS', ASSETS: 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(new Request(`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( + new Request(`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({ [DEV_INBOX_ENV_VAR]: 'ASSETS', ASSETS: 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({ [DEV_INBOX_ENV_VAR]: 'ASSETS', ASSETS: 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({ [DEV_INBOX_ENV_VAR]: 'ASSETS', ASSETS: 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({ [DEV_INBOX_ENV_VAR]: 'STATIC_FILES', STATIC_FILES: assets }, () => + 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'); + }); +}); + +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); + }); +}); diff --git a/packages/void/test/unit/email-inbound.test.ts b/packages/void/test/unit/email-inbound.test.ts index 34f9fb2c2..66e1610b4 100644 --- a/packages/void/test/unit/email-inbound.test.ts +++ b/packages/void/test/unit/email-inbound.test.ts @@ -731,12 +731,39 @@ describe('createInboundTestHarness', () => { ); }); - it('lowercases the recipient before matching', async () => { + 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('buildReplyMessage', () => { From 4ccb016c2b08bc56632ba5b26dcdf6e0711f39d5 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 31 Aug 2026 13:39:45 +0800 Subject: [PATCH 3/8] fix(email): RFC 2047 word splitting, harness reject parity, PARSE_FAILED Four more review findings, each verified against the real code before being touched. One was only partly true and was fixed narrower than reported; one turned out to be a documentation error of mine. encodeWord emitted one unbounded RFC 2047 encoded-word, and foldHeader could never repair it: its fallback splits on whitespace and base64 has none, so it always saw a single token and returned the line unchanged. Measured, a 16-character CJK subject produced a 76-character word against the 75-character limit in RFC 2047 section 2, and 246 CJK characters produced a 1005-octet line against the 998 that RFC 5322 section 2.1.1 makes a MUST. Nothing downstream repairs it; the proxy forwards our bytes verbatim. The file already tested that same 998-octet limit for the References header and cited it in two comments, so the encoded-word path was bypassing a guard the authors knew they needed. encodeWord now chunks by code point, not by byte and not by UTF-16 unit, and base64s each chunk independently. RFC 2047 section 5 forbids splitting a multi-octet character across words, so slicing one base64 string would not do. A UTF-16 chunker or a slice-after-encoding both corrupt an astral character; tests pin that. foldHeader's two mutually exclusive branches became one pass, which also closes a gap the address-list branch left open: it never re-folded inside a part, so one recipient with a long encoded display name still produced a 1058-octet line. Verified through Python's stdlib parser: exact round-trip, no U+FFFD, zero defects, every line at most 73 octets. The test harness recorded no rejection when nothing matched and there was no _default, while the generated dispatcher calls setReject and the sender gets a non-delivery report. A test for catch-all or anti-spam behavior could therefore observe an accepted message that deployment bounces. The harness now rejects with the dispatcher's reason byte for byte, built from the wire recipient with its case preserved, and routed through setReject so the terminal-reject semantics stay identical. PARSE_FAILED was declared in the ParseEmailError code union and never constructed. postal-mime is lenient enough that binary garbage, truncated parts and bad header bytes all parse; what it does reject is a nesting bomb, which escaped as a bare Error carrying parser internals. It is now wrapped, with the original kept as cause. The reviewer also claimed handlers could not distinguish oversize from parse failure. They cannot distinguish anything: ParseEmailError is not exported from void/email at all. Exporting it is a scope decision, not a bug fix, so the class stays internal. The Class B/C finding was right about the mechanism and my own documentation was wrong about the cause. I had written that those frameworks lack an assets binding. In fact Void never installs the Cloudflare Vite plugin for them, so both the dev-inbox plugin and mergeBindings are out of reach, and declaring an assets binding would not help: SvelteKit runs in Node and Nuxt and Analog run in worker_threads, a different JS realm, so there is no workerd-behind-Vite loopback to bind to. Only Astro could work. The guide now states the real boundary and points those users at the in-process harness. Supporting them needs three different mechanisms for four frameworks and is left as a design task. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW --- docs/guide/email.md | 4 +- packages/void/src/runtime/email/inbound.ts | 19 +- packages/void/src/runtime/email/mime.ts | 169 ++++++++++---- packages/void/src/runtime/email/testing.ts | 14 ++ packages/void/test/unit/email-inbound.test.ts | 98 ++++++++ packages/void/test/unit/email-mime.test.ts | 212 ++++++++++++++++++ 6 files changed, 473 insertions(+), 43 deletions(-) diff --git a/docs/guide/email.md b/docs/guide/email.md index af068e3dd..2556a171b 100644 --- a/docs/guide/email.md +++ b/docs/guide/email.md @@ -176,7 +176,9 @@ The inbox shows a list view with subject, sender, recipient, and timestamp. Clic 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. If you drive `void/email` from a meta-framework whose wrangler config declares no `assets` binding, there is no loopback: those sends return `BINDING_MISSING` instead of reaching the inbox. When the inbox is configured but unreachable, `sendEmail` returns `UPSTREAM_ERROR` — nothing is captured and nothing is sent. +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. To bypass the inbox for a single send during dev (e.g. to verify real delivery against a verified address), pass `sendInDev: true`: diff --git a/packages/void/src/runtime/email/inbound.ts b/packages/void/src/runtime/email/inbound.ts index f4c9f116f..e769451b3 100644 --- a/packages/void/src/runtime/email/inbound.ts +++ b/packages/void/src/runtime/email/inbound.ts @@ -75,8 +75,8 @@ export type ParseEmailErrorCode = 'INBOUND_TOO_LARGE' | 'PARSE_FAILED'; */ export class ParseEmailError extends Error { readonly code: ParseEmailErrorCode; - constructor(code: ParseEmailErrorCode, message: string) { - super(message); + constructor(code: ParseEmailErrorCode, message: string, options?: { cause?: unknown }) { + super(message, options); this.name = 'ParseEmailError'; this.code = code; } @@ -101,7 +101,20 @@ export async function parseEmail(message: ForwardableEmailMessage): Promise256 + // 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 }, + ); + } } /** diff --git a/packages/void/src/runtime/email/mime.ts b/packages/void/src/runtime/email/mime.ts index b904846b8..52140cd7c 100644 --- a/packages/void/src/runtime/email/mime.ts +++ b/packages/void/src/runtime/email/mime.ts @@ -239,13 +239,60 @@ export async function normalizeEmail(opts: { }; } -/** RFC 2047 encoded-word for non-ASCII text in headers (UTF-8, base64). */ +/** + * 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 b64 = base64(new TextEncoder().encode(text)); - return `=?UTF-8?B?${b64}?=`; + 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 specials — names containing any of these MUST be quoted as a @@ -317,53 +364,97 @@ function splitAddressList(value: string): Array { return parts; } +/** + * Split one address-list part at whitespace runs outside any + * double-quoted string. RFC 5322 §3.2.2 permits folding only at WSP, + * and keeping quoted strings whole means a fold never lands inside a + * display name like `"Smith, John"`. + */ +function splitFoldPoints(part: string): Array { + const tokens: Array = []; + let current = ''; + let inQuotes = false; + let escape = false; + for (let i = 0; i < part.length; i++) { + const ch = part[i]; + if (escape) { + current += ch; + escape = false; + continue; + } + if (ch === '\\' && inQuotes) { + current += ch; + escape = true; + continue; + } + if (ch === '"') { + inQuotes = !inQuotes; + current += ch; + continue; + } + if (!inQuotes && (ch === ' ' || ch === '\t')) { + if (current.length > 0) { + tokens.push(current); + current = ''; + } + continue; + } + current += ch; + } + if (current.length > 0) { + tokens.push(current); + } + return tokens; +} + function foldHeader(name: string, value: string): string { const line = `${name}: ${value}`; if (line.length <= 78) { return line; } + // Fold points, strongest first: address-list separators (a `, ` + // outside any quoted string), then any remaining unquoted whitespace + // inside a part. 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. + const chunks: Array<{ text: string; comma: boolean }> = []; const parts = splitAddressList(value); - if (parts.length > 1) { - let out = `${name}: ${parts[0]}`; - let lineLen = out.length; - for (let i = 1; i < parts.length; i++) { - const next = `, ${parts[i]}`; - if (lineLen + next.length > 78) { - out += ',\r\n ' + parts[i]; - lineLen = parts[i].length + 1; - } else { - out += next; - lineLen += next.length; - } + for (let i = 0; i < parts.length; i++) { + const isLast = i === parts.length - 1; + const tokens = splitFoldPoints(parts[i]); + if (tokens.length === 0) { + // An empty part (`a, , b`) still owes its separator; without a + // placeholder chunk the rendered value would lose a comma. + chunks.push({ text: '', comma: !isLast }); + continue; + } + for (let j = 0; j < tokens.length; j++) { + chunks.push({ text: tokens[j], comma: !isLast && j === tokens.length - 1 }); } - return out; - } - // No address-list separators to fold at. Some headers (notably - // `References:` with many message IDs) accumulate whitespace-separated - // tokens that can blow past RFC 5322's 998-octet hard limit on a - // single physical line. Fall back to whitespace folding: split on - // runs of whitespace and emit at most ~76 octets per physical line, - // each continuation prefixed with CRLF + a space (the structured - // folding rule). Tokens that individually exceed 76 chars are emitted - // unbroken on their own line — RFC 5322 §3.2.2 lets folding only - // occur at WSP, never inside a token. - const whitespaceTokens = value.split(/\s+/).filter((t) => t.length > 0); - if (whitespaceTokens.length <= 1) { + } + // One unbreakable token — a single long Message-ID, say. RFC 5322 + // §3.2.2 only allows folding at WSP, so there is nowhere to fold and + // `Name:\r\n ` would shorten nothing. + if (chunks.length <= 1) { return line; } - let out = `${name}: ${whitespaceTokens[0]}`; - // First emitted line includes the header name; subsequent emitted - // lines are continuations and start with a single leading WSP. - let lineLen = out.length; + // 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 chunk longer than the budget still gets its own + // line, which is the best a WSP-only fold rule allows. const MAX_LINE = 76; - for (let i = 1; i < whitespaceTokens.length; i++) { - const tok = whitespaceTokens[i]; - if (lineLen + 1 + tok.length > MAX_LINE) { - out += '\r\n ' + tok; - lineLen = 1 + tok.length; + let out = `${name}:`; + let lineLen = out.length; + for (const chunk of chunks) { + const piece = chunk.comma ? `${chunk.text},` : chunk.text; + if (piece.length > 0 && lineLen + 1 + piece.length > MAX_LINE) { + out += `${CRLF} ${piece}`; + lineLen = 1 + piece.length; } else { - out += ' ' + tok; - lineLen += 1 + tok.length; + out += ` ${piece}`; + lineLen += 1 + piece.length; } } return out; diff --git a/packages/void/src/runtime/email/testing.ts b/packages/void/src/runtime/email/testing.ts index b40ca5d5e..1ef09bb88 100644 --- a/packages/void/src/runtime/email/testing.ts +++ b/packages/void/src/runtime/email/testing.ts @@ -396,6 +396,20 @@ export function createInboundTestHarness(opts: InboundTestHarnessOptions): Inbou deliveries.push(result); return result; } + // 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`, + ); const result: InboundDeliveryResult = { handler: undefined, params: {} }; deliveries.push(result); return result; diff --git a/packages/void/test/unit/email-inbound.test.ts b/packages/void/test/unit/email-inbound.test.ts index 66e1610b4..492246f88 100644 --- a/packages/void/test/unit/email-inbound.test.ts +++ b/packages/void/test/unit/email-inbound.test.ts @@ -14,6 +14,7 @@ import { createMessageProxy, defineEmail, parseEmail, + ParseEmailError, replyEmail, type EmailEnvelope, } from '../../src/runtime/email/inbound.ts'; @@ -766,6 +767,103 @@ describe('createInboundTestHarness', () => { }); }); +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"'); + }); +}); + +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 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('buildReplyMessage', () => { function makeMessage(headers: Record): ForwardableEmailMessage { return { diff --git a/packages/void/test/unit/email-mime.test.ts b/packages/void/test/unit/email-mime.test.ts index 0baf2aae7..70599ab65 100644 --- a/packages/void/test/unit/email-mime.test.ts +++ b/packages/void/test/unit/email-mime.test.ts @@ -11,6 +11,70 @@ async function build(opts: Parameters[0]) { 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({ @@ -439,4 +503,152 @@ describe('buildMime', () => { 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); + }); }); From 6c5da083e87f0a0fd7548eca662f41d663118f46 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 1 Sep 2026 22:51:03 +0800 Subject: [PATCH 4/8] fix(email): address preflight, docs result narrowing, and the logs --limit flag Three more review findings. A fourth, about recipient local-part casing, was verified and declined; see below. The address shape check only required an '@' to be present anywhere, so user@, @example.com and a@b@c passed the preflight. Measured, sendEmail returned ok:true for all three and the dev inbox showed a capture, while the platform then refused them - as INVALID_FROM for a sender, but for a recipient as a mid-batch UNVERIFIED_DESTINATION rather than the advertised INVALID_TO. Nothing malformed could ever reach Cloudflare, because the api already gates every allowlist entry with this rule, so the defect is a dev-prod divergence rather than a delivery or injection one. Header injection was already closed: comma, semicolon, angle, quote, whitespace and CRLF all threw. The check now requires exactly one separator with a non-empty local part and domain. Deliberately not the api's isValidEmail, which also requires a dot in the domain and would newly reject user@localhost and x@y, both legal RFC 5321 and accepted today; both are pinned by tests. Quoted local parts were already rejected by the existing specials class, so the stricter separator rule breaks nothing that worked. The introductory docs example read result.error after only narrowing on result.ok. SendEmailResult has two failure members and error exists on just one of them, so the example threw a TypeError rather than printing the error - and it failed to typecheck, which is how a reader would first notice. It sits on the most common failure path: UNVERIFIED_DESTINATION is reachable only through the per-recipient branch, so the first send to an unverified recipient hits exactly this. The example now narrows on 'error' in result and handles both branches, and the error-code table says which branch carries which code. 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. It is the only such mismatch across the five email subcommands: the parser accepts --limit everywhere but only logs forwards it. cli-help.test.ts previously asserted path resolution only and never rendered content, so nothing could have caught this; it now renders the pages and asserts the flags. Declined: the envelope recipient array is lowercased before the POST, which RFC 5321 section 2.3.11 says an intermediate should not do to a local part. Fixing it in the framework alone changes zero bytes on the wire, because the proxy lowercases again on receipt and hands that same string to EmailMessage - proven by running the real proxy worker. The genuine fix spans both repos and is gated on unverified Cloudflare behaviour: the api registers the lowercased address as the verified destination, so transmitting mixed case risks tripping Cloudflare's own verification check. That is a decision about the canonical address form, not a bug fix, and the codebase already made the opposite choice deliberately for the sender, which is transmitted verbatim under a named test. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW --- docs/guide/email.md | 21 +++++++++- packages/void/src/cli/help.ts | 15 ++++++- packages/void/src/runtime/email/mime.ts | 21 +++++++--- packages/void/test/unit/cli-help.test.ts | 49 +++++++++++++++++++++- packages/void/test/unit/email-mime.test.ts | 24 +++++++++++ 5 files changed, 121 insertions(+), 9 deletions(-) diff --git a/docs/guide/email.md b/docs/guide/email.md index 2556a171b..73ba5bb3f 100644 --- a/docs/guide/email.md +++ b/docs/guide/email.md @@ -18,10 +18,22 @@ const result = await sendEmail({ }); if (!result.ok) { - console.error(result.error.code, result.error.message); + 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. Every Void project ships with: @@ -164,6 +176,13 @@ Error codes: | `QUOTA_EXCEEDED` | Platform quota for outbound email reached. Retry next billing period. | | `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. Browse them at: diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 335a06bab..2503dcc9e 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -248,7 +248,20 @@ const HELP_TREE: HelpTree = { 'Show monthly outbound/inbound counts and quota.', 'void email usage [options]', ), - logs: withProjectOption('Show recent email delivery activity.', 'void email logs [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]', diff --git a/packages/void/src/runtime/email/mime.ts b/packages/void/src/runtime/email/mime.ts index 52140cd7c..f98519150 100644 --- a/packages/void/src/runtime/email/mime.ts +++ b/packages/void/src/runtime/email/mime.ts @@ -73,11 +73,22 @@ function fieldErrorCode(field: string): MimeErrorCode { function validateAddressParts(field: string, email: string, name?: string): void { rejectCRLF(`${field} address`, email); - // Address must be a single token with an '@' and no whitespace, no - // quoting, and no list-separators. Without this, a `to: "a@x,b@y"` - // would slip through the bare-string path and split into two - // recipients in the rendered headers. - if (!email.includes('@') || /\s/.test(email) || /[,;<>"()[\]\\]/.test(email)) { + // Address must be a single token with EXACTLY ONE '@', a non-empty local + // part and a non-empty domain, and no whitespace, quoting, or + // list-separators. Without the separator rule, a `to: "a@x,b@y"` would + // slip through the bare-string path and split into two recipients in the + // rendered headers; without the exactly-one/non-empty rule, `user@`, + // `@example.com` and `a@b@c` passed the preflight and were reported as a + // successful capture in the dev inbox, only to fail once they reached the + // platform — and as a different error code than the one advertised here. + // + // Deliberately NOT the platform's `isValidEmail`, which also requires a + // dot in the domain: `user@localhost` and `x@y` are legal RFC 5321 and + // are accepted today. Quoted local parts (`"a@b"@example.com`) stay + // rejected by the `"` in the specials class below, as they already were. + const at = email.indexOf('@'); + const singleAt = at > 0 && at === email.lastIndexOf('@') && at < email.length - 1; + if (!singleAt || /\s/.test(email) || /[,;<>"()[\]\\]/.test(email)) { throw new MimeError( fieldErrorCode(field), `${field}: '${email}' is not a valid email address.`, diff --git a/packages/void/test/unit/cli-help.test.ts b/packages/void/test/unit/cli-help.test.ts index f67d68906..3aca425df 100644 --- a/packages/void/test/unit/cli-help.test.ts +++ b/packages/void/test/unit/cli-help.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vite-plus/test'; -import { getHelpPath, hasHelpPage } from '../../src/cli/help.ts'; +import { 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'; describe('CLI help resolver', () => { it('resolves top-level help requests', () => { @@ -70,3 +71,47 @@ describe('CLI help resolver', () => { expect(hasHelpPage(['github', 'missing'])).toBe(false); }); }); + +describe('rendered help advertises the flags the parser honours', () => { + /** The page as printed, ANSI stripped, so an assertion reads like the terminal. */ + function render(path: Array): string { + const lines: Array = []; + // The page renders through clack's `note`, which writes to stdout, not + // through console.log. + const spy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => { + lines.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + try { + printHelpPage(path); + } finally { + spy.mockRestore(); + } + // 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'); + } + }); +}); diff --git a/packages/void/test/unit/email-mime.test.ts b/packages/void/test/unit/email-mime.test.ts index 70599ab65..e0de2463c 100644 --- a/packages/void/test/unit/email-mime.test.ts +++ b/packages/void/test/unit/email-mime.test.ts @@ -129,6 +129,30 @@ describe('normalizeEmail', () => { ).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/); + }, + ); + + // Deliberately NOT the platform's own `isValidEmail`, which requires a dot + // in the domain. These two are legal RFC 5321 and accepted today; adopting + // that stricter rule here would newly reject them. + it.each(['user@localhost', 'x@y'])('still accepts %s (no dot in the domain)', async (ok) => { + const email = await normalizeEmail({ from: ok, to: ok, subject: 's', text: 't' }); + expect(email.from.email).toBe(ok); + }); + it('rejects CRLF in subject', async () => { await expect( normalizeEmail({ From 9cfd3ba8791188f6d8be708ab0a95d7bf4077309 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 3 Sep 2026 00:17:29 +0800 Subject: [PATCH 5/8] fix(email): client stub, header line limit, filename escaping, prompt claim Four review findings, all verified against running code. Two of them are holes left by my own earlier fixes in this branch. void/email was never added to SERVER_ONLY_STUBS, so a shared module reached from a client graph pulled in runtime/env-raw.ts, whose node:async_hooks import and module-scope AsyncLocalStorage have no browser equivalent. The build does not fail; it succeeds with an externalization warning and ships new (emptyStub).AsyncLocalStorage, so the page white-screens on load. Proven by ablation on a real playground build: without the stub the warning appears and AsyncLocalStorage lands in the client chunk, with it neither does. void/email/testing needs its own entry because the resolve filter is anchored, and for a different reason - it drags node:path, node:fs and node:module in through router/email.ts. Both stubs throw rather than returning an empty object, following void/sandbox, and their export lists were checked against the real modules programmatically. 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 - so a 1100-character token produced a 1109-octet line against RFC 5322 section 2.1.1, which is a MUST. Nine header paths were affected, including the header name and the filename/name/Content-ID parameters that are built outside foldHeader. One post-render guard covers them all. Encoding instead of rejecting is not a general remedy: RFC 2047 section 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. Truncating corrupts silently - a clipped References breaks threading with no signal. That guard needed a companion. buildReplyMessage threads the inbound Message-ID and References verbatim, and a remote sender controls both, so the guard would have let anyone make an app's reply throw. Threading is best-effort, so a value that cannot be represented is dropped and the reply still sends. Attachment filenames were emitted into quoted-string parameters with quotes deleted and backslashes left raw. qtext excludes both, so each may appear only as a quoted-pair: a trailing backslash escaped the closing quote and left the parameter unterminated, and every embedded backslash was silently eaten, so a Windows path C:\Users\bob\report.pdf arrived as C:Usersbobreport.pdf. This file's own folder already tracks quoted-pairs, so the emitter was contradicting it. All five cases now round-trip exactly through an independent parser with zero defects. Not a security issue - rejectCRLF already closes header injection and the quote strip closed parameter injection. The shipped agent prompt still said every void dev send is captured by the local inbox, which the guide stopped claiming last round. That prompt seeds agents, so it would have propagated the wrong guidance into user projects. Handed back, not fixed here: attachment filenames are still emitted as raw 8-bit UTF-8 rather than RFC 2231, which is inconsistent with the RFC 2047 treatment every other header string in the file gets. Modern parsers recover them correctly; a naive one produces mojibake. Separate defect, separate compatibility trade-off. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW --- packages/void/getting-started-prompt.txt | 2 +- packages/void/src/index.ts | 32 +++++++ packages/void/src/runtime/email/inbound.ts | 37 ++++++-- packages/void/src/runtime/email/mime.ts | 78 ++++++++++++++++- packages/void/test/unit/email-inbound.test.ts | 59 +++++++++++++ packages/void/test/unit/email-mime.test.ts | 87 +++++++++++++++++++ 6 files changed, 286 insertions(+), 9 deletions(-) diff --git a/packages/void/getting-started-prompt.txt b/packages/void/getting-started-prompt.txt index 881c4598f..b98264454 100644 --- a/packages/void/getting-started-prompt.txt +++ b/packages/void/getting-started-prompt.txt @@ -19,7 +19,7 @@ To deploy to your OWN Cloudflare account instead of the Void platform, use `void Database workflow: define Drizzle tables in `db/schema.ts`, import `db` from `void/db` and tables from `@schema`. Use `void db push` to prototype (applies schema directly, no migration files). Use `void db generate` when ready to create production migration files in `db/migrations/`. `drizzle-orm` and `drizzle-kit` ship with void — no extra install needed. Scaffold new tables with `void gen model [columns...]`. -Email: send transactional mail with `import { sendEmail } from "void/email"`. Every project gets a platform sender — `+noreply@mail.void.cloud` — which is used when you omit `from`; a custom sender domain is not supported yet. 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`). Check quota and activity with `void email usage` and `void email logs`; during `void dev` every send is captured by the local inbox at `/__void/inbox` instead of being delivered. +Email: send transactional mail with `import { sendEmail } from "void/email"`. Every project gets a platform sender — `+noreply@mail.void.cloud` — which is used when you omit `from`; a custom sender domain is not supported yet. 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`). 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`. diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index bbde14843..49ce5e80b 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -759,6 +759,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': '', }; const STUB_RESOLVE_FILTER = new RegExp( diff --git a/packages/void/src/runtime/email/inbound.ts b/packages/void/src/runtime/email/inbound.ts index e769451b3..3f9c71165 100644 --- a/packages/void/src/runtime/email/inbound.ts +++ b/packages/void/src/runtime/email/inbound.ts @@ -405,6 +405,22 @@ export function createMessageProxy( 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; + } + const encoder = new TextEncoder(); + const widest = Math.max(...value.split(/\s+/).map((token) => encoder.encode(token).length)); + return name.length + 2 + widest <= 998; +} + export interface ReplyEmailOptions { /** * Defaults to `<__VOID_PROJECT_SLUG>+noreply@<__VOID_EMAIL_DOMAIN>`, and @@ -570,13 +586,22 @@ export async function buildReplyMessage( // 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. const threading: { inReplyTo?: string; references?: string } = {}; - if (originalMessageId) { - threading.inReplyTo = originalMessageId; - threading.references = originalReferences - ? `${originalReferences} ${originalMessageId}`.trim() - : originalMessageId; - } else if (originalReferences) { + const messageId = fitsHeaderLine('In-Reply-To', originalMessageId) ? originalMessageId : ''; + if (messageId) { + threading.inReplyTo = messageId; + const chain = originalReferences ? `${originalReferences} ${messageId}`.trim() : messageId; + // A `References` chain folds at its spaces, so a long one is usually + // fine; it is a single oversized id in the chain that is not. + threading.references = fitsHeaderLine('References', chain) ? chain : messageId; + } else if (originalReferences && fitsHeaderLine('References', originalReferences)) { threading.references = originalReferences; } diff --git a/packages/void/src/runtime/email/mime.ts b/packages/void/src/runtime/email/mime.ts index f98519150..60e6d1de3 100644 --- a/packages/void/src/runtime/email/mime.ts +++ b/packages/void/src/runtime/email/mime.ts @@ -471,6 +471,54 @@ function foldHeader(name: string, value: string): string { return out; } +/** RFC 5322 §2.1.1: each line MUST be at most 998 octets, excluding CRLF. */ +const MAX_LINE_OCTETS = 998; + +/** + * 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. + */ +function findOverlongLine(text: string): { index: number; octets: number } | undefined { + const lines = text.split(CRLF); + const encoder = new TextEncoder(); + for (let i = 0; i < lines.length; i++) { + const octets = encoder.encode(lines[i]).length; + if (octets > MAX_LINE_OCTETS) { + return { index: i, octets }; + } + } + return 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 `splitAddressList` and `splitFoldPoints` already + * track 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 = ''; @@ -569,9 +617,9 @@ function buildBodyPart(contentType: string, body: string): MimePart { function buildAttachmentPart(att: NormalizedAttachment): MimePart { const headers: Array<[string, string]> = [ - ['Content-Type', `${att.contentType}; name="${att.filename.replace(/"/g, '')}"`], + ['Content-Type', `${att.contentType}; name="${quoteParam(att.filename)}"`], ['Content-Transfer-Encoding', 'base64'], - ['Content-Disposition', `${att.disposition}; filename="${att.filename.replace(/"/g, '')}"`], + ['Content-Disposition', `${att.disposition}; filename="${quoteParam(att.filename)}"`], ]; if (att.contentId) { const cid = att.contentId.startsWith('<') ? att.contentId : `<${att.contentId}>`; @@ -716,6 +764,32 @@ export function buildMime(email: NormalizedEmail): { raw: Uint8Array; messageId: 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', diff --git a/packages/void/test/unit/email-inbound.test.ts b/packages/void/test/unit/email-inbound.test.ts index 492246f88..a6d2a4b71 100644 --- a/packages/void/test/unit/email-inbound.test.ts +++ b/packages/void/test/unit/email-inbound.test.ts @@ -818,6 +818,65 @@ describe('harness rejects an unmatched delivery, like production', () => { }); }); +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('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); + }); + + 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('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 diff --git a/packages/void/test/unit/email-mime.test.ts b/packages/void/test/unit/email-mime.test.ts index e0de2463c..6bd21c67b 100644 --- a/packages/void/test/unit/email-mime.test.ts +++ b/packages/void/test/unit/email-mime.test.ts @@ -153,6 +153,93 @@ describe('normalizeEmail', () => { expect(email.from.email).toBe(ok); }); + // 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); + }); + + // 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 89b9d4745ec0bd229d2dd3b6089da28dfafc8d12 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 10 Sep 2026 17:19:17 +0800 Subject: [PATCH 6/8] fix(email): treat inbound handlers as a backend, and pin the framework boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on the email re-land. 1. `email/` was invisible to every deploy decision that asks "does this project need a worker". An email-only project took the static-site path, which uploads no worker script at all, so the handlers were dropped and inbound mail bounced. Fixed at all three sites: - `findBackendSources` / `hasBackendFiles` (preset.ts) now count `paths.emailDir`, so `detectPreset` stops returning a static preset for an email-only project and the `--backend cloudflare` reject message lists `email/`. - the fully-static optimization in `runFullDeploy` now derives `hasEmail` from `routeScan.email`. The predicate is extracted as the exported `hasNonPageBackend`, following `hasPrerenderSources` 25 lines above it, because the inline expression had no test seam. The manifest `ssr` flag is deliberately NOT touched. Dispatch uses it for request-type metering (`'ssr'` vs `'static'`), so threading email into it would mis-meter every request on a page-less project. 2. The harness rebuilt `ForwardableEmailMessage.headers` from `input` only, so `buildMime`'s generated `Message-ID` never reached the handler and `replyEmail` produced an unthreaded reply that production would have threaded. Headers are now parsed back out of the wire bytes, which also fixes the `raw:` override path. 3. Inbound is Void-app-only. `routingPlugin` compiles the `email()` export and no framework branch installs it — the framework wrapper wires crons and queues only. An `email/` directory under TanStack Start, React Router, SvelteKit, Nuxt, Analog or Astro is now a build error next to the existing auth / sandbox / live guards, instead of silently dead code. Gated on the handler directory, not on `bindings.needsEmail`: outbound `sendEmail` is a binding and keeps working everywhere. The guide is corrected to match. It also claimed inbound provisioning was pending "the platform's Phase 3" and that handlers sit dormant. That is stale — `meta/operations.md` documents one shared mail facility with a single zone catch-all and no per-project DNS work, so handlers are live as soon as the deploy lands. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW --- docs/guide/email.md | 8 +- packages/void/src/cli/deploy.ts | 51 +++++++++- packages/void/src/cli/preset.ts | 4 + packages/void/src/index.ts | 15 +++ packages/void/src/runtime/email/testing.ts | 68 ++++++++++++- .../void/test/unit/deploy-static-only.test.ts | 35 +++++++ packages/void/test/unit/email-inbound.test.ts | 96 +++++++++++++++++++ .../void/test/unit/plugin-inference.test.ts | 53 ++++++++++ packages/void/test/unit/preset.test.ts | 11 ++- 9 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 packages/void/test/unit/deploy-static-only.test.ts diff --git a/docs/guide/email.md b/docs/guide/email.md index 73ba5bb3f..33f5bdce4 100644 --- a/docs/guide/email.md +++ b/docs/guide/email.md @@ -242,7 +242,9 @@ The harness intercepts every `sendEmail` call until `dispose()` is called. `clea ## Inbound -Receive inbound mail 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. +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. ```ts // email/_default.ts — fallback for any unmatched recipient @@ -335,7 +337,9 @@ never throws, while `replyEmail` throws when it cannot determine a sender. ### Configuring inbound delivery -In production, inbound delivery requires a Cloudflare Email Routing rule that points your domain at your worker. **Void manages this for you** — there's nothing to configure in the Cloudflare dashboard. (Provisioning lands in the platform's Phase 3; until then, deployed handlers sit dormant.) There is no local inbound trigger yet — `void dev` serves the outbound dev inbox only, so test inbound handlers with `createInboundTestHarness` below. +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. + +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 diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 28e4e6e11..98d779dad 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -56,6 +56,7 @@ import { resolveProjectPaths, } from '../project-paths.ts'; import { scanQueuesSync } from '../queues/scan.ts'; +import { hasEmailHandlers } from '../router/email.ts'; import { scanRoutes } from '../router/scan.ts'; import { scanWebSocketRoutesSync } from '../router/websocket.ts'; import { @@ -3565,7 +3566,7 @@ export async function runCloudflareDeploy( [ 'deploy: --backend 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'), ); return exit(1); @@ -4990,6 +4991,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, @@ -6168,9 +6205,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/preset.ts b/packages/void/src/cli/preset.ts index 6ce32a75e..a4a7e7620 100644 --- a/packages/void/src/cli/preset.ts +++ b/packages/void/src/cli/preset.ts @@ -543,6 +543,7 @@ export function findBackendSources(root: string): Array { paths.middlewareDir, paths.cronsDir, paths.queuesDir, + paths.emailDir, ]) { if (existsSync(dir)) { found.push(`${relative(root, dir)}/`); @@ -575,6 +576,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/index.ts b/packages/void/src/index.ts index 49ce5e80b..ebeae2edf 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -68,6 +68,7 @@ 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'; @@ -729,6 +730,20 @@ 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. + if (detected && hasEmailHandlers(scanEmailHandlersSync(paths.sourceRoot))) { + 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. diff --git a/packages/void/src/runtime/email/testing.ts b/packages/void/src/runtime/email/testing.ts index 1ef09bb88..872431886 100644 --- a/packages/void/src/runtime/email/testing.ts +++ b/packages/void/src/runtime/email/testing.ts @@ -191,6 +191,45 @@ async function buildSyntheticRaw(opts: InboundDeliverOptions): Promise { + const colon = current.indexOf(':'); + if (colon > 0) { + try { + headers.set(current.slice(0, colon).trim(), current.slice(colon + 1).trim()); + } catch { + // Not a name/value a Headers object accepts — skip it. + } + } + current = ''; + }; + for (const line of block.split('\r\n')) { + if (line === '') { + break; + } + if (/^[ \t]/.test(line) && current !== '') { + current += ` ${line.trim()}`; + continue; + } + commit(); + current = line; + } + commit(); + return headers; +} + function stripSlugForHarness(address: string, slug: string | undefined): string { if (!slug) { return address; @@ -228,8 +267,33 @@ export function createInboundTestHarness(opts: InboundTestHarnessOptions): Inbou async function deliver(input: InboundDeliverOptions): Promise { const raw = await buildSyntheticRaw(input); - const headers = new Headers(input.headers ?? {}); - if (input.subject && !headers.has('Subject')) { + // 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. + // + // Parsed from the local `raw`, never from `message.raw`: that is a + // one-shot stream here, so reading it would steal the handler's read. + // 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. + const headers = parseHeaderBlock(raw); + // The caller's own headers win, so an explicit override stays an escape + // hatch. + 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')) { 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..8e8414a6c --- /dev/null +++ b/packages/void/test/unit/deploy-static-only.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +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-inbound.test.ts b/packages/void/test/unit/email-inbound.test.ts index a6d2a4b71..d1f59f751 100644 --- a/packages/void/test/unit/email-inbound.test.ts +++ b/packages/void/test/unit/email-inbound.test.ts @@ -818,6 +818,102 @@ describe('harness rejects an unmatched delivery, like production', () => { }); }); +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('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 diff --git a/packages/void/test/unit/plugin-inference.test.ts b/packages/void/test/unit/plugin-inference.test.ts index 7498d1a4b..40e962cd8 100644 --- a/packages/void/test/unit/plugin-inference.test.ts +++ b/packages/void/test/unit/plugin-inference.test.ts @@ -618,6 +618,59 @@ 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(); + } + }); + + // 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/preset.test.ts b/packages/void/test/unit/preset.test.ts index fe148d789..e07a419ea 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([]); From af10ad35b8edc1024ecf89d29eedb07256aaddb7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 11 Sep 2026 11:10:20 +0800 Subject: [PATCH 7/8] fix(email): make the default reply subject best-effort, and qualify the shipped prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings acted on, one declined with evidence. 1. `quotedPrintableDecode` accepted any `=XY` pair that merely STARTED with a hex digit: `parseInt('0G', 16)` is 0, not NaN, so `=0G` decoded to a NUL and `normalizeEmail` then threw `MIME_ERROR` on the reply subject. Both characters must now be hex; a malformed escape stays literal, as RFC 2045 §6.7 asks and as postal-mime does. That predicate was one of several doors to the same throw. A well-formed `=00` / `=0A`, a B-encoded NUL, or a 999-octet ASCII subject with no whitespace all reached `rejectCRLF` or the 998-octet guard from the same remote-sender-controlled header — and in production a rejected handler bounces the whole delivery, replies and forwards alike. The default subject is now best-effort, like the threading headers already were: C0 controls become a space, and an ASCII subject no line can hold falls back to a bare `Re:`. A non-ASCII subject is never rejected, since `encodeWord` splits it into foldable encoded-words. Seven tests pin each door and both controls. 2. `AGENT_PROMPT.md`, which `void init` injects into the project's agent instructions, and `getting-started-prompt.txt` both advertised `email/` for inbound without the Void-app-only boundary. The previous round had qualified the dev inbox in the latter, not inbound handlers. Both now say the directory is a build error under a third-party framework and that `sendEmail` keeps working. Declined: gating the `WorkerEntrypoint` entry on `hasEmail` for workers pinned before the 2024-04-03 RPC compatibility date. The `rpc` flag gates the CALLER's Fetcher stub, not the callee's class. Verified in workerd 1.20260701.1: a `WorkerEntrypoint` subclass serves `fetch`, `scheduled`, `queue` and RPC `email()` at compatibility dates back to 2021-11-03 and under an explicit `no_rpc`. The only caller is the platform proxy at 2026-02-12. The premise — Void preserves the user's date with no floor and the conversion is unconditional — is true and now documented in the review thread; the failure it predicts does not exist. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW --- docs/guide/email.md | 2 + packages/void/AGENT_PROMPT.md | 2 +- packages/void/getting-started-prompt.txt | 2 +- packages/void/src/runtime/email/inbound.ts | 42 ++++++++-- packages/void/test/unit/email-inbound.test.ts | 82 +++++++++++++++++++ 5 files changed, 120 insertions(+), 10 deletions(-) diff --git a/docs/guide/email.md b/docs/guide/email.md index 33f5bdce4..b90c25eee 100644 --- a/docs/guide/email.md +++ b/docs/guide/email.md @@ -314,6 +314,8 @@ export default defineEmail(async (message, env, ctx, info) => { `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. Control characters in the subject become a space, and an ASCII subject too long to fold falls back to a bare `Re:`. The reply is always sent; pass `subject` to control it exactly. + 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. diff --git a/packages/void/AGENT_PROMPT.md b/packages/void/AGENT_PROMPT.md index 2259dddf9..707d7e49a 100644 --- a/packages/void/AGENT_PROMPT.md +++ b/packages/void/AGENT_PROMPT.md @@ -1,6 +1,6 @@ ## Void -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, 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 + an `email/` directory for inbound handlers), 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, 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), 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. `drizzle-orm` and `drizzle-kit` ship with void (no extra install). Migrations live in `db/migrations/`. diff --git a/packages/void/getting-started-prompt.txt b/packages/void/getting-started-prompt.txt index b98264454..f758e9b37 100644 --- a/packages/void/getting-started-prompt.txt +++ b/packages/void/getting-started-prompt.txt @@ -19,7 +19,7 @@ To deploy to your OWN Cloudflare account instead of the Void platform, use `void Database workflow: define Drizzle tables in `db/schema.ts`, import `db` from `void/db` and tables from `@schema`. Use `void db push` to prototype (applies schema directly, no migration files). Use `void db generate` when ready to create production migration files in `db/migrations/`. `drizzle-orm` and `drizzle-kit` ship with void — no extra install needed. Scaffold new tables with `void gen model [columns...]`. -Email: send transactional mail with `import { sendEmail } from "void/email"`. Every project gets a platform sender — `+noreply@mail.void.cloud` — which is used when you omit `from`; a custom sender domain is not supported yet. 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`). 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. +Email: send transactional mail with `import { sendEmail } from "void/email"`. Every project gets a platform sender — `+noreply@mail.void.cloud` — which is used when you omit `from`; a custom sender domain is not supported yet. 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`. diff --git a/packages/void/src/runtime/email/inbound.ts b/packages/void/src/runtime/email/inbound.ts index 3f9c71165..886e242ce 100644 --- a/packages/void/src/runtime/email/inbound.ts +++ b/packages/void/src/runtime/email/inbound.ts @@ -438,7 +438,12 @@ export interface ReplyEmailOptions { * single-recipient message — for fan-out, build a fresh `sendEmail()` call. */ to?: Address; - /** Defaults to `Re: ` (no double-prefix). */ + /** + * Defaults to `Re: ` (no double-prefix). The original is + * remote-sender controlled, so the default is best-effort: control + * characters become a space, and an ASCII subject too long to fold falls + * back to a bare `Re:`. Pass `subject` to control it exactly. + */ subject?: string; text?: string; html?: string; @@ -453,14 +458,31 @@ function prefixReplySubject(original: string): string { // 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. - const decoded = decodeRfc2047(original).trim(); + // + // 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. 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 a subject no line can carry falls back to a bare `Re:`. + const decoded = decodeRfc2047(original) + // eslint-disable-next-line no-control-regex -- C0 controls and DEL are the point + .replace(/[\x00-\x1f\x7f]+/g, ' ') + .replace(/ {2,}/g, ' ') + .trim(); if (!decoded) { return 'Re:'; } - if (/^re:/i.test(decoded)) { - return decoded; + const prefixed = /^re:/i.test(decoded) ? decoded : `Re: ${decoded}`; + // 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 `Re: ${decoded}`; + return prefixed; } /** @@ -521,10 +543,14 @@ function quotedPrintableDecode(qp: string): Uint8Array { if (ch === 0x5f /* _ */) { bytes.push(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); - const v = parseInt(hex, 16); - if (Number.isFinite(v)) { - bytes.push(v); + if (/^[0-9A-Fa-f]{2}$/.test(hex)) { + bytes.push(parseInt(hex, 16)); i += 2; } else { bytes.push(ch); diff --git a/packages/void/test/unit/email-inbound.test.ts b/packages/void/test/unit/email-inbound.test.ts index d1f59f751..de4d40c5e 100644 --- a/packages/void/test/unit/email-inbound.test.ts +++ b/packages/void/test/unit/email-inbound.test.ts @@ -973,6 +973,88 @@ describe('reply threading survives a hostile inbound header', () => { }); }); +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'); + }); +}); + 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 From 3b90aeb58137588a6a351b27685978ebbad0dc14 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 11 Sep 2026 17:35:44 +0800 Subject: [PATCH 8/8] docs(email): say plainly that email is platform-only today `void deploy --backend cloudflare` succeeds, but sendEmail returns BINDING_MISSING and email/ handlers are not routed (no rule, no binding, actions not applied). State it in the email guide, the Cloudflare integration scope list, the agent prompt and the getting-started prompt, and widen the BINDING_MISSING row to name that case. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW --- docs/guide/email.md | 22 +++++++++++++--------- docs/integrations/cloudflare.md | 1 + packages/void/AGENT_PROMPT.md | 2 +- packages/void/getting-started-prompt.txt | 2 +- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/guide/email.md b/docs/guide/email.md index b90c25eee..6ba31cf68 100644 --- a/docs/guide/email.md +++ b/docs/guide/email.md @@ -44,6 +44,10 @@ Zero config. Every Void project ships with: That's it. `sendEmail({ to: '...', subject: '...', text: '...' })` works on first deploy. +::: warning Not on `void deploy --backend cloudflare` yet +Email is a platform feature today. On a deploy to your own Cloudflare account, `sendEmail` returns `BINDING_MISSING`, and `email/` handlers are not wired: the deploy succeeds, but no Email Routing rule points at your worker, and a handler's `setReject` / `forward` / `reply` actions are not applied even if you add a rule yourself. Email setup on your own account is a follow-up — planned as one prompt on the first deploy, driven by your `wrangler login` session. Until then, deploy with `void deploy`. +::: + ## Adding recipients Cloudflare's `send_email` binding only delivers to addresses you've registered as recipients. Add them with the CLI: @@ -166,15 +170,15 @@ if (result.ok) { Error codes: -| Code | Meaning | -| ------------------------ | ----------------------------------------------------------------------------------- | -| `BINDING_MISSING` | The platform proxy binding is unavailable. Deploy through Void, or check the proxy. | -| `INVALID_FROM` | `from` was missing, malformed, or not the project sender. | -| `INVALID_TO` | `to` was empty or contained an invalid address. | -| `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. | -| `UPSTREAM_ERROR` | Other binding failure; the original error is attached as `error.cause`. | +| Code | Meaning | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| `BINDING_MISSING` | No email transport: a `--backend cloudflare` deploy, a Class B/C dev server, or the platform proxy is unavailable. | +| `INVALID_FROM` | `from` was missing, malformed, or not the project sender. | +| `INVALID_TO` | `to` was empty or contained an invalid address. | +| `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. | +| `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, diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index a78a3d018..70e5414df 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -258,6 +258,7 @@ Without `--provision`, deploy is a **drift check**: if a binding your source nee - **WebSocket / Durable Object routes (`*.ws.ts`) are not supported here (yet).** A WebSocket route makes the build emit a key-value-backed Durable Object namespace (a `new_classes` migration), which a fresh Cloudflare account -- and every Workers Free account -- refuses to create. Deploy those with `void deploy`. SQLite-backed WebSocket support for this backend is a follow-up. - **Custom `migrations_pattern` is not supported here.** v1 deploys only Void's default migration convention -- `db/migrations/*.sql` with wrangler's default pattern (leave `migrations_pattern` unset). Remove a custom `migrations_pattern` from the D1 binding, or deploy with `void deploy`. Supporting custom patterns is a follow-up. - **Non-Cloudflare targets (`node` / `bun` / `deno`) are not supported here** -- this backend deploys Cloudflare Workers only. + - **Email (`void/email`) is not supported here (yet).** The deploy does not fail, but `sendEmail` returns `BINDING_MISSING` at runtime and `email/` handlers receive nothing -- no Email Routing rule, no `send_email` binding, and a handler's actions are not applied even if you add a rule by hand. Deploy with `void deploy` for email. Own-account email setup (one prompt on the first deploy, through your `wrangler login` session) is a follow-up. - **`--skip-build` is not supported here.** Every check on this path validates the artifact the build emits -- the worker `vars` in `dist/ssr/wrangler.json` and the generated auth schema -- so skipping the build would leave them reading a stale or missing artifact instead of the worker being uploaded. Drop `--skip-build`, or use `void deploy` (the managed platform), which supports it. - **PostgreSQL apps with auth enabled are not supported here.** This backend verifies + applies auth tables through remote D1 (SQLite) migrations only, so it cannot provision or verify the Better Auth schema on Postgres/Hyperdrive. Deploy with `void deploy`, or use D1 for the auth app. - **PostgreSQL apps with checked-in migrations are not supported here.** This backend applies only remote D1 (SQLite) migrations. A PostgreSQL app's `db/migrations/*.sql` are applied over Hyperdrive by the managed platform (`void deploy`) at deploy time; this self-host backend never runs them, so it would deploy against an unmigrated database. Deploy with `void deploy`, or apply the migrations yourself against your Postgres and remove them from `db/migrations/`. (A pg app _without_ auth **and** with an externally-managed schema — no checked-in migrations — still deploys.) diff --git a/packages/void/AGENT_PROMPT.md b/packages/void/AGENT_PROMPT.md index 707d7e49a..b002b8b02 100644 --- a/packages/void/AGENT_PROMPT.md +++ b/packages/void/AGENT_PROMPT.md @@ -1,6 +1,6 @@ ## Void -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, 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), 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, 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; platform deploys only — not on `--backend cloudflare` yet), 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. `drizzle-orm` and `drizzle-kit` ship with void (no extra install). Migrations live in `db/migrations/`. diff --git a/packages/void/getting-started-prompt.txt b/packages/void/getting-started-prompt.txt index f758e9b37..6c7d9193f 100644 --- a/packages/void/getting-started-prompt.txt +++ b/packages/void/getting-started-prompt.txt @@ -19,7 +19,7 @@ To deploy to your OWN Cloudflare account instead of the Void platform, use `void Database workflow: define Drizzle tables in `db/schema.ts`, import `db` from `void/db` and tables from `@schema`. Use `void db push` to prototype (applies schema directly, no migration files). Use `void db generate` when ready to create production migration files in `db/migrations/`. `drizzle-orm` and `drizzle-kit` ship with void — no extra install needed. Scaffold new tables with `void gen model [columns...]`. -Email: send transactional mail with `import { sendEmail } from "void/email"`. Every project gets a platform sender — `+noreply@mail.void.cloud` — which is used when you omit `from`; a custom sender domain is not supported yet. 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. +Email: send transactional mail with `import { sendEmail } from "void/email"`. Platform deploys only for now — on `void deploy --backend cloudflare`, `sendEmail` returns `BINDING_MISSING` and `email/` handlers are not routed. Every project gets a platform sender — `+noreply@mail.void.cloud` — which is used when you omit `from`; a custom sender domain is not supported yet. 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`.