From 3724728f01b63dc1978fda1d2642d8ffcd1c2958 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 30 Aug 2026 23:27:29 +0800 Subject: [PATCH 001/119] 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 334516940f6e50c48b58766f5055945909eb9728 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 30 Aug 2026 23:27:35 +0800 Subject: [PATCH 002/119] feat(email): cf-native outbound and inbound email Re-lands the abandoned feat/email branch onto current main. Re-applied file by file rather than rebased. Outbound never touches the user worker: the account-level send_email binding lives only on proxy, which enforces the per-project verified recipient allowlist, both suspension switches, and burst plus monthly quotas. Inbound arrives at +@, resolves through the same ROUTING KV the HTTP dispatcher uses, and dispatches into the tenant worker under that project's CPU limits. A recipient another project already verified with Cloudflare does not inherit that consent. The row inserts pending and we send our own HMAC-signed confirmation link, unless the address is the project owner's own. verification_source records which path it took. Two decisions changed during the re-land: - The mail zone is EMAIL_SENDER_DOMAIN, declared on api and proxy and injected into user workers as __VOID_EMAIL_DOMAIN. The original hard-coded mail.void.cloud in three places including a sender regex, which made staging impossible. Every reader fails closed when absent. - One slug binding. main already injected __VOID_PROJECT_SLUG for sandbox projects, so provisionBindings now injects it for every project. Drift the re-land had to absorb: the branch migrations sort before main's head so the migrator would have skipped them (regenerated as 0033); the branch reserved-binding list dropped three names main reserves; cleanupProjectResources no longer exists, so destination cleanup is re-homed into teardownProject before deleteProjectCascade or the CF destination leaks with no row left to find it from; send_email was missing from main's new env.production scope; noreply was not reserved. Security fixes from an adversarial review, each re-verified against the control flow first: - An inbound reply action could name any sender on the platform zone. The reply sender is pinned to the slug that received the message, with the domain taken from the received recipient rather than from config, and the pin runs before quota so a dropped reply costs nothing. - Both revoke paths deleted the row and then best-effort re-synced the KV allowlist, so a KV failure stranded a revoked address in the allowlist with no row left to re-derive it from. The mirror is now tightened first, excluding the row being revoked, and a KV failure returns 503 with the row intact. - A malformed routing blob read as not suspended. Outbound fails closed with a 502; inbound rejects. - Destination POST and DELETE return 409 once the project is deleting. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW --- framework | 2 +- meta/design-docs/0047-email.md | 10 +- meta/design-docs/0078-email-cf-native.md | 922 +++++++ meta/lessons.md | 16 +- meta/operations.md | 92 +- meta/progress.md | 3 +- packages/api/README.md | 53 +- packages/api/container/build-agent.mjs | 2 +- .../api/drizzle/0033_outgoing_changeling.sql | 21 + packages/api/drizzle/meta/0033_snapshot.json | 2248 +++++++++++++++++ packages/api/drizzle/meta/_journal.json | 7 + packages/api/src/admin/middleware/auth.ts | 25 + packages/api/src/admin/routes/email.tsx | 405 +++ packages/api/src/admin/routes/projects.tsx | 6 + packages/api/src/auth/confirm-token.ts | 186 ++ packages/api/src/build-manager.ts | 2 +- packages/api/src/db.ts | 337 ++- packages/api/src/email.ts | 1 + packages/api/src/email/send-verification.ts | 265 ++ packages/api/src/env.ts | 9 + packages/api/src/index.ts | 25 + packages/api/src/provisioning/cleanup.ts | 74 + .../api/src/provisioning/email-allowlist.ts | 60 + .../api/src/provisioning/email-routing.ts | 134 + packages/api/src/provisioning/provision.ts | 44 +- packages/api/src/reserved-bindings.ts | 47 + packages/api/src/routes/deploy.ts | 51 +- packages/api/src/routes/email-confirm.ts | 119 + packages/api/src/routes/email.ts | 577 +++++ packages/api/src/routes/projects.ts | 166 +- packages/api/src/routes/secrets.ts | 8 + packages/api/src/schema.ts | 92 + packages/api/src/types.ts | 2 + .../api/test/__mocks__/cloudflare-email.ts | 13 + .../test/integration/admin-dashboard.test.ts | 13 + .../api/test/integration/admin-email.test.ts | 508 ++++ .../api/test/integration/api-delete.test.ts | 146 +- .../api/test/integration/api-email.test.ts | 1716 +++++++++++++ .../api/test/integration/api-projects.test.ts | 207 +- .../api/test/integration/api-secrets.test.ts | 44 + packages/api/test/integration/helpers.ts | 50 + .../test/unit/deploy-durable-objects.test.ts | 23 +- .../test/unit/email-platform-token.test.ts | 287 +++ packages/api/test/unit/provision.test.ts | 114 +- .../api/test/unit/reserved-bindings.test.ts | 150 ++ packages/api/test/unit/reserved-slugs.test.ts | 11 + packages/api/wrangler.jsonc | 4 + packages/proxy/README.md | 70 +- packages/proxy/src/body.ts | 4 + packages/proxy/src/email-router.ts | 946 +++++++ packages/proxy/src/email.ts | 879 +++++++ packages/proxy/src/index.ts | 123 +- packages/proxy/src/types.ts | 22 + .../test/unit/email-platform-token.test.ts | 199 ++ packages/proxy/test/unit/email-router.test.ts | 1842 ++++++++++++++ packages/proxy/test/unit/email.test.ts | 1737 +++++++++++++ packages/proxy/wrangler.jsonc | 16 + vite.config.ts | 5 + 58 files changed, 15070 insertions(+), 70 deletions(-) create mode 100644 meta/design-docs/0078-email-cf-native.md create mode 100644 packages/api/drizzle/0033_outgoing_changeling.sql create mode 100644 packages/api/drizzle/meta/0033_snapshot.json create mode 100644 packages/api/src/admin/routes/email.tsx create mode 100644 packages/api/src/auth/confirm-token.ts create mode 100644 packages/api/src/email/send-verification.ts create mode 100644 packages/api/src/provisioning/email-allowlist.ts create mode 100644 packages/api/src/provisioning/email-routing.ts create mode 100644 packages/api/src/reserved-bindings.ts create mode 100644 packages/api/src/routes/email-confirm.ts create mode 100644 packages/api/src/routes/email.ts create mode 100644 packages/api/test/__mocks__/cloudflare-email.ts create mode 100644 packages/api/test/integration/admin-email.test.ts create mode 100644 packages/api/test/integration/api-email.test.ts create mode 100644 packages/api/test/unit/email-platform-token.test.ts create mode 100644 packages/api/test/unit/reserved-bindings.test.ts create mode 100644 packages/proxy/src/email-router.ts create mode 100644 packages/proxy/src/email.ts create mode 100644 packages/proxy/test/unit/email-platform-token.test.ts create mode 100644 packages/proxy/test/unit/email-router.test.ts create mode 100644 packages/proxy/test/unit/email.test.ts diff --git a/framework b/framework index 984fffe39..3724728f0 160000 --- a/framework +++ b/framework @@ -1 +1 @@ -Subproject commit 984fffe3914e83bd5c74b9d3ed58ef457380a0b6 +Subproject commit 3724728f01b63dc1978fda1d2642d8ffcd1c2958 diff --git a/meta/design-docs/0047-email.md b/meta/design-docs/0047-email.md index 1ee6f1c5c..aff912934 100644 --- a/meta/design-docs/0047-email.md +++ b/meta/design-docs/0047-email.md @@ -1,9 +1,17 @@ --- title: Email — Send & Receive via Resend -status: deferred +status: superseded created: 2026-03-03 +superseded_by: 0078-email-cf-native.md --- +> **Superseded on 2026-08-30 by `0078-email-cf-native.md`.** The cost argument +> below was about Resend. Email was later built on Cloudflare Email Routing +> instead, which removes the $90/mo floor and the third-party dependency, so +> the deferral no longer applies. The inbound reasoning held up: routing to the +> correct user worker on Workers for Platforms was the hard part, and it is +> what `0078` spends most of its design on. + # Email — Send & Receive via Resend ## Status: Deferred diff --git a/meta/design-docs/0078-email-cf-native.md b/meta/design-docs/0078-email-cf-native.md new file mode 100644 index 000000000..ec8dd6262 --- /dev/null +++ b/meta/design-docs/0078-email-cf-native.md @@ -0,0 +1,922 @@ +--- +title: Email — CF-Native (outbound, inbound, shared sender) +status: draft +created: 2026-04-24 +updated: 2026-08-30 +supersedes: 0047-email.md +--- + +> **2026-08-30 re-land.** This document was written as `0067-email.md` on the +> abandoned `feat/email` branch. `0067` was taken by +> `0067-cross-script-do-host-architecture.md` while that branch sat, so the +> design is renumbered here. Three decisions changed during the re-land and +> the body below has NOT been rewritten around them — read these first: +> +> 1. **The platform mail domain is configured per environment.** The original +> hard-coded `mail.void.cloud` in the framework runtime, the proxy's sender +> regex and the api's verification sender. That made a staging environment +> impossible, because a `mail.staging.void.cloud` sender was rejected by our +> own regex. It is now one var, `EMAIL_SENDER_DOMAIN`, declared on both the +> api and the proxy worker and injected into user workers as +> `__VOID_EMAIL_DOMAIN`. Every reader fails closed when it is absent. +> 2. **There is one project-slug binding, `__VOID_PROJECT_SLUG`.** The original +> added a second, bare `VOID_PROJECT_SLUG`. `main` already injected the +> underscored name for sandbox projects, so `provisionBindings` now injects +> it for every project and there is exactly one producer. +> 3. **Inbound routes on the wire recipient.** `createMessageProxy` strips the +> `+` prefix so handler authors see the address they wrote; the +> generated dispatcher used to strip it a second time, leaving an empty tag +> that no pattern could match. The dispatcher now reads the preserved +> `__voidPlatformTo`. + +> **2026-04-29 update — outbound moved off the user worker.** A multi-agent +> review found that with `SEND_EMAIL` emitted as a per-project worker +> binding, any user handler could call `env.SEND_EMAIL.send(...)` and +> bypass the proxy's quota, suspend kill-switch, and metering. Sealing +> this gap by hiding the binding name didn't change the architectural +> fact that a shared platform resource (the `mail.void.cloud` sender, +> with pooled reputation) was sitting on every user worker. +> +> **Resolution:** outbound now follows the same shape as AI / revalidate. +> The user worker holds only `__VOID_PROXY` (+ token + project id). The +> framework's `sendEmail()` POSTs MIME bytes + recipients to +> `__VOID_PROXY/email/send`; the **proxy** worker holds the +> account-level `send_email` binding and enforces: +> +> - **Per-project verified-destinations allowlist** (mirrored from the +> `email_destinations` table into `email-allowlist:` ROUTING +> KV by every CRUD op + project delete; ~60s convergence per colo). +> - **Suspend kill-switch** (now also gates inbound). +> - **Burst + monthly quotas.** +> +> User-facing API (`sendEmail`, result types, error codes, dev inbox, +> test harness) is unchanged. The relevant sections below have been +> updated; the original on-worker-binding design is preserved in the git +> history of this doc. + +> **2026-04-29 follow-up — per-project destination consent.** A review +> finding (I-1) called out that when project A adds an address that +> another project already verified via CF Email Routing, the previous +> code trusted CF's `verified` flag and instantly marked the row +> verified for project A — bypassing the per-project consent the privacy +> story promises (the recipient consented to project B, not A). +> +> **Resolution: Option C + Option B.** A new `verification_source` +> column distinguishes how a row reached `verified`: +> +> - **Option C — owner-relaxed CF reuse** (`verification_source='cf'`). +> When the address matches the project owner's email and CF already +> has it verified, we trust CF: same human, same consent. Covers the +> "I made a second project" workflow. +> - **Option B — platform-side reverify** (`verification_source='platform'`). +> When CF reports the address verified by anyone _other_ than the +> project owner, we DO NOT inherit consent. The row inserts as +> `pending` and we issue our own HMAC-signed confirmation link to the +> recipient. The row only flips to `verified` when they click; CF's +> verified flag is irrelevant for the gate. +> +> Implementation pieces: +> +> - Migration `0013_email_verification_source.sql` adds +> `verification_source TEXT NOT NULL DEFAULT 'cf'` with a check +> constraint of `('cf' | 'platform')`. Existing rows default to `'cf'`. +> - Token helper `packages/api/src/auth/confirm-token.ts` — +> `base64url(JSON) + '.' + base64url(HMAC-SHA256)` carrying +> `(v, pid, did, addr, iat, exp)` with 24h TTL. Single-use is enforced +> by DB state (clicking flips status; re-clicks find an +> already-verified row and return idempotent success). +> - Platform email helper `packages/api/src/email/send-verification.ts` +> POSTs to the proxy's new `/email/internal/send` endpoint, which is +> gated by HMAC of the sentinel string +> `void-platform-internal-email-v1` under `JWT_SECRET`. Only the api +> and proxy hold `JWT_SECRET`, so user workers cannot reach the +> internal endpoint. +> - Confirm endpoint `GET /email/destinations/confirm?token=...` — +> public, mounted before the auth middleware, returns a small HTML +> confirmation page on success. +> - GET `/destinations` refresh now skips +> `verification_source='platform'` rows so a CF-side `verified=true` +> doesn't override the platform gate. +> - Per-project rate-limit on `POST /destinations`: 5/minute and +> 50/day in `email-add-rate::*` KV counters, with the +> same NaN-poison defense as the proxy's send counter. Blocks Mallory +> from spamming reverify mail. +> - `autoAddOwnerDestination` is unchanged in behavior — by definition +> the address IS the owner's email, so it threads +> `verification_source='cf'` explicitly (Option C every time). +> +> Tests cover each path: Option C trust, Option B platform-pending + +> confirmation email dispatch, refresh-skip, confirm-endpoint happy +> path, expired/tampered/unknown-id failure modes, idempotent re-click, +> rate-limit, and the migration default. + +# Email — CF-Native (outbound, inbound, shared sender) + +## Context + +[`0047-email.md`](./0047-email.md) designed a full Resend-based integration and was **deferred** because the outbound value over a plain Resend SDK install was too thin, the inbound-via-webhooks path was real platform work but couldn't justify the build on its own, and the Scale plan carried a $90/mo floor regardless of usage. + +Since then we've re-scoped around **Cloudflare's native Email Service** (Email Routing + Email Workers + the `send_email` binding): + +- **No third-party dependency, no flat fee.** Outbound rides the existing `send_email` binding; cost is whatever Cloudflare charges (currently free within generous limits). +- **Inbound is native.** Email Workers deliver messages straight into the user worker via an `email(message, env, ctx)` handler — no webhook-relay plumbing through `api.void.cloud` → dispatch → `*.void.app`. +- **Already on the stack.** Users are on Workers; this is one more binding, not a new vendor. +- **DX delta is much larger.** Raw CF email requires hand-rolling MIME, editing wrangler config, has no local-dev story, and needs platform plumbing for the multi-tenant case. Void closes all four gaps. + +The earlier "just use the SDK" argument that sank 0047 doesn't apply here: the alternative isn't a three-line SDK, it's hand-constructing RFC 5322 messages, editing `wrangler.jsonc`, and (for any multi-tenant deployment) building cross-worker email dispatch from scratch. + +## Scope + +Single rollout covering outbound, inbound, and the platform infrastructure that makes both work end-to-end for every Void project. + +**Framework (`packages/void`):** + +- `void/email` outbound: `sendEmail(opts)` with a structured result, MIME construction, attachments, multi-recipient fan-out +- `email/` top-level directory convention for inbound: `defineEmail`, `parseEmail`, `replyEmail`, file-based routing with subaddressing + dynamic params +- Local dev inbox at `/__void/inbox` for outbound; round-trip dispatch back to local handlers (pending) for inbound +- Test harnesses: `createEmailTestHarness` (outbound), `createInboundTestHarness` (inbound) +- `WorkerEntrypoint` codegen so non-fetch handlers are RPC-callable from the platform router +- `from` defaulting to `+noreply@mail.void.cloud` when omitted +- Pre-flight quota check + async delivery log via `__VOID_PROXY` (fail-open when proxy is unbound) +- Binding inference (drives `manifest.bindings.email` for the dev-inbox plugin and analytics; no on-worker `send_email` binding) +- `void email usage|logs` CLI + +**Platform (`packages/proxy`, `packages/api`):** + +- `mail.void.cloud` shared sender zone (one-time CF Email Routing setup) +- Inbound router worker (`packages/proxy/src/email-router.ts`) — receives all mail at `mail.void.cloud`, parses `+`, dispatches into the user worker via WfP RPC +- `/email/check-quota`, `/email/log-send`, `/email/log-recv` proxy endpoints with KV-backed monthly counters, per-minute burst cap, and suspend kill-switch +- `VOID_PROJECT_SLUG` env injection at deploy +- **Verified destinations** — `email_destinations` table per project; `POST /projects/:id/email/destinations` triggers the CF verification email; per-project allowlist injected as `allowed_destination_addresses` on the worker's `send_email` binding at deploy time. Project owner's email is auto-added on project create. +- Admin UI page (`packages/api/src/admin/routes/email.tsx`): per-project usage, suspend toggle, monthly + burst limit overrides, counter reset, destinations list with add/remove +- API routes: `GET /projects/:id/email/{usage,logs}`, `GET|POST|DELETE /projects/:id/email/destinations[/:id]` + +## Non-Goals + +- **Custom-domain email** — see [Why custom domains aren't included](#why-custom-domains-arent-included). +- **Provider abstraction (Resend / SES / Postmark) wrapper.** Users can call those SDKs directly today; the wrapper would not earn its keep without concrete demand. See [Future work](#future-work) for the trigger conditions. +- **Templating** (React Email / Vue components / MJML). +- **Typed verified-sender codegen.** +- **User-facing dashboard pages.** Dashboard work is currently deferred per `CLAUDE.md`; the admin UI covers operational observability. User-facing pages land when dashboard work resumes. +- **Bounce handling.** CF doesn't deliver bounces to Email Workers; needs VERP or provider-side webhooks. +- **Cost-passthrough billing for outbound.** CF email is free at our volumes. +- **Programmatic Email Routing _rule_ creation.** The shared `mail.void.cloud` catch-all is a one-time setup; per-project routing rules aren't needed. + +## Cloudflare's Constraints (Be Honest) + +The `send_email` binding is **not a general transactional-email sender**: + +- **From-domain:** must be a zone the user owns, with Email Routing enabled. DKIM/SPF/DMARC are set up automatically when Email Routing is enabled. +- **To-address restrictions:** + - `destination_address: "x@example.com"` — can only send to that one verified address + - `allowed_destination_addresses: [...]` — can only send to those verified addresses + - **Neither set** — can send to any address that has been verified as a destination in the sender's Email Routing account, or to addresses on domains that accept mail via CF Email Routing. You cannot yet spray arbitrary transactional mail to cold recipients the way Resend/SES allows. +- **Practical implication:** CF-native outbound fits "app-internal" use cases well (notifications to the account owner, ops alerts, forms piped to a verified inbox, reply-by-email flows on top of inbound). It does **not** yet fit "SaaS sending receipts to every end-user" without additional work — that's the future-work provider abstraction. + +## DX Papercuts → What Void Fixes + +| Papercut in raw CF Email Workers | Void fix | +| ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Hand-rolled RFC 5322 MIME (headers, boundaries, encodings, attachments) required for every send | `sendEmail({ from, to, subject, text, html, attachments, ... })` — MIME built for you | +| Must manually add `[[send_email]]` to `wrangler.jsonc` | No on-worker binding — the platform proxy holds it; `sendEmail()` POSTs to `__VOID_PROXY/email/send` | +| `wrangler dev` doesn't simulate email — real sends during local dev, or hand-rolled mocks | Local dev inbox: captured in-memory, browsable at `/__void/inbox`, HTML + raw `.eml` | +| No unified error shape — `send_email` throws raw Worker errors | `sendEmail` returns a 3-case discriminated union; typed error codes; per-recipient deliveries | +| Reply-to / CC / BCC / custom headers require manual header construction | First-class options fields; Void handles header encoding (RFC 2047 for non-ASCII names) | +| Attachments require base64 + multipart/mixed MIME by hand | `attachments: [{ filename, content, contentType }]`; `content` accepts `string`/`Uint8Array`/`Blob` | +| Hand-write `email(message, env, ctx)` export, manually parse multipart with `postal-mime` | Drop a file in `email/`, default-export a `defineEmail()` handler — Void generates the export | +| All inbound flows through a single function — branching by recipient is hand-rolled | File-based sub-routing: `email/[user].ts`, `email/support+[ticket].ts`, etc. | +| `EmailMessage.reply()` requires constructing a fully-formed reply with threading headers | `replyEmail(message, { text, html })` builds threading-aware reply MIME and dispatches | +| Subaddressing (`+tag` local-part suffix) requires manual parsing in every handler | Built into the router; `params.` populated automatically | +| `postal-mime` import + stream consumption boilerplate in every handler | `parseEmail(message)` wraps it; `ParsedEmail` re-exported as the canonical type | +| No test fixtures for inbound — users build `EmailMessage` mocks themselves | `createInboundTestHarness().deliver({ from, to, subject, text })` invokes the real dispatcher | +| Multi-tenant inbound dispatch from a shared zone is unsolved | `mail.void.cloud` shared sender + inbound router worker dispatches by `+` | + +## Design + +### Outbound: `void/email` + +#### API surface — `sendEmail` + +```ts +import { sendEmail } from 'void/email'; + +const result = await sendEmail({ + from: 'Acme ', // string | { email, name } — optional, defaults to +noreply@mail.void.cloud + to: 'user@example.com', // string | Address | Array + subject: 'Welcome', + text: 'Hi there', // at least one of text/html required + html: '

Hi there

', + replyTo: 'support@acme.dev', + cc: [...], + bcc: [...], + headers: { 'X-Campaign': 'welcome' }, + attachments: [ + { filename: 'receipt.pdf', content: pdfBytes, contentType: 'application/pdf' }, + ], +}); + +if (!result.ok) { + if ('error' in result) { + // pre-flight failure — no envelopes attempted + console.error(result.error.code, result.error.message); + } else { + // mid-batch failure — some recipients may have succeeded + for (const d of result.deliveries) { + if (!d.ok) console.error(`failed to ${d.recipient}:`, d.error); + } + } +} +``` + +#### Types (exported) + +```ts +export type Address = string | { email: string; name?: string }; + +export interface Attachment { + filename: string; + content: string | Uint8Array | ArrayBuffer | Blob; + contentType?: string; // inferred from filename if omitted + contentId?: string; // for inline images (cid:) + disposition?: 'attachment' | 'inline'; +} + +export interface SendEmailOptions { + from: Address; + to: Address | Address[]; + subject: string; + text?: string; + html?: string; + replyTo?: Address; + cc?: Address | Address[]; + bcc?: Address | Address[]; + headers?: Record; + attachments?: Attachment[]; + ctx?: { waitUntil(p: Promise): void }; // for the async log call +} + +export interface SendEmailDelivery { + recipient: string; // the envelope `To` for this CF send + messageId: string; // CF's returned messageId +} + +export type SendEmailRecipientResult = + | { recipient: string; ok: true; messageId: string } + | { recipient: string; ok: false; error: SendEmailError }; + +export type SendEmailResult = + | { ok: true; ids: SendEmailDelivery[] } + | { ok: false; error: SendEmailError } // pre-flight only + | { ok: false; deliveries: SendEmailRecipientResult[] }; // mid-batch failures + +export interface SendEmailError { + code: + | 'BINDING_MISSING' + | 'INVALID_FROM' + | 'INVALID_TO' + | 'UNVERIFIED_DESTINATION' + | 'MIME_ERROR' + | 'QUOTA_EXCEEDED' + | 'UPSTREAM_ERROR'; + message: string; + cause?: unknown; +} +``` + +#### Multi-recipient fan-out + failure semantics + +The CF `EmailMessage` envelope is single-recipient, so `sendEmail` iterates `to` ∪ `cc` ∪ `bcc` and calls `binding.send()` once per recipient with the same raw MIME (Cc/Bcc are encoded into the MIME headers for display; the envelope alone drives delivery). + +Three discriminated result cases: + +- **Pre-flight failure** — validation / MIME / missing binding / quota denied. No envelopes attempted. Result: `{ ok: false, error }`. +- **All-success** — every recipient succeeded. Result: `{ ok: true, ids }`. +- **Mid-batch failure** — sends were attempted; at least one failed. Result: `{ ok: false, deliveries }`. Runtime continues through every recipient regardless of failures and collects per-recipient outcomes. **No** aggregate top-level error — distinct recipients can fail with distinct errors and synthesizing one would mislead callers. + +Retry rule: on `deliveries`, retry only recipients with `ok: false`. On pre-flight `error`, retry the whole call. Never retry on the absence of `ok: true` alone — re-sending to recipients with `ok: true` will deliver duplicates. + +#### Default sender resolution + +When `from` is omitted, the framework reads `env.VOID_PROJECT_SLUG` (injected at deploy by the platform) and substitutes `+noreply@mail.void.cloud`. Explicit `from` values pass through unchanged. If the slug isn't injected (local dev without the platform, tests), the call returns `INVALID_FROM` with a clear message. + +#### Binding inference + +Two-tier detection in `packages/void/src/inference/walk.ts`: + +1. **Import-source match** — `case "void/email": result.needsEmail = true;` +2. **Regex fallback** for JSX/TSX paths that skip the lexer + +The third tier (`env.SEND_EMAIL` AST detection) was removed when outbound +moved to the proxy: the user worker no longer has a `SEND_EMAIL` binding, +so reading `env.SEND_EMAIL` returns undefined regardless of inference. + +`needsEmail` drives `manifest.bindings.email` (used by analytics and the +local dev-inbox plugin) and the `email/` directory inclusion in the +default scan dirs. Opt-out via `bindings: { email: false }` in plugin +options. Wrangler sync **does not emit** `[[send_email]]` on user +projects — the proxy worker carries the platform's account-level +binding (see `packages/proxy/wrangler.jsonc`). + +#### MIME construction + +Uses [`mimetext`](https://www.npmjs.com/package/mimetext) — small, edge-friendly, popular in the CF Email Workers ecosystem. Wrapped internally in `runtime/email/mime.ts` so we can swap later without breaking the public API. Attachment totals are pre-checked against CF's ~10MB base64-inflated cap; oversize payloads surface as `MIME_ERROR` with measured-vs-allowed bytes rather than a generic upstream failure. + +Unicode display-name fixture exercises RFC 2047 encoded-words (e.g. `"Jürgen Müller" `, Japanese, emoji) — locks behavior in and guards against regressions if the MIME backend is ever swapped. + +### Inbound: top-level `email/` directory + +#### Convention + +``` +crons/ # cron handlers → scheduled() export +queues/ # queue handlers → queue() export +email/ # email handlers → email() export +├── _default.ts # fallback when no per-address match +├── support.ts # static: support@ +├── support+[ticket].ts # subaddressing: support+ticket-123@... → params.ticket +├── [user].ts # dynamic local-part: anything@... → params.user +└── [user]+[tag].ts # dynamic + subaddressing +routes/ # HTTP only → fetch() export +``` + +`email/` sits at the top level alongside `crons/` and `queues/` — non-HTTP handlers all live at the project root since each is wired into a separate worker export. `routes/` is reserved for HTTP routing. + +#### Match precedence (most specific wins) + +1. Static + tagged: `support+[ticket].ts` for `support+ticket-123@...` +2. Static: `support.ts` for `support@...` +3. Dynamic + tagged: `[user]+[tag].ts` +4. Dynamic: `[user].ts` +5. `_default.ts` — fallback + +If no handler matches (and there's no `_default.ts`), the dispatcher does nothing and returns control to CF, which delivers a non-delivery report to the sender. Nested folders under `email/` are intentionally ignored. + +Local-part-only matching — handlers match on the part before `@`, regardless of domain. Multi-domain projects that need per-domain branching can read `message.to` inside the handler. Domain-aware routing is deferred until usage demands it. + +#### Handler signature + +```ts +import { defineEmail, parseEmail, replyEmail } from 'void/email'; + +export default defineEmail(async (message, env, ctx, info) => { + // message: ForwardableEmailMessage-shaped proxy (see "envelope/action contract" below) + // message.from, message.to, message.headers, message.raw, message.rawSize + // message.setReject(reason), message.forward(to, headers?), message.reply(emailMessage) + // + // info?: { params: Record } — populated for dynamic segments + // + const parsed = await parseEmail(message); + console.log(parsed.subject, info?.params.ticket); + + await replyEmail(message, { text: 'Got it, ticket created.' }); +}); +``` + +`defineEmail()` is an identity function at runtime — it exists for IDE completion on the handler signature without forcing users to import `EmailHandler` separately. Matches Void's `defineX` family. A bare `export default async (message, env, ctx) => { ... }` works too. + +**Why explicit `parseEmail` (vs auto-parsed wrapper).** Inbound parse is destructive: `message.raw` is a `ReadableStream` that can only be consumed once. Auto-parsing on every dispatch wastes work for handlers that only need `setReject` based on the recipient and forces a synchronous-buffer model that fights CF's streaming primitive. + +#### Threading-aware replies + +`replyEmail(message, opts)`: + +1. Reads the original `Message-ID` and `References` from the message headers +2. Builds reply MIME via the outbound MIME builder, with `In-Reply-To` / `References` spliced in and `Subject: Re: ` (only adds `Re:` if not already prefixed) +3. Records a reply intent on the message proxy; the platform router enacts it on the real `ForwardableEmailMessage` + +Defaults: `from = message.to`, `to = message.from`, `subject = "Re: "`. Override any in opts. + +#### Codegen + +`packages/void/src/router/email.ts` scans `email/*.{ts,mts,js,mjs}` (top-level) and produces an `EmailHandlersScan`: + +```ts +interface EmailHandlersScan { + defaultHandler?: string; // "_default.ts" + addresses: Array<{ + address: string; // "support" | "support+[ticket]" | "[user]" + filePath: string; // "support+[ticket].ts" + pattern: { + kind: 'static' | 'static-tag' | 'dynamic' | 'dynamic-tag'; + regexSource: string; // anchored, targets the lowercased local-part + paramNames: string[]; + }; + }>; +} +``` + +`router/compile.ts` emits an `__emailHandler` virtual export inside `virtual:void-routes`. Patterns are baked as a regex table tried in specificity order: + +```ts +// generated by compileRouteTable +const __emailDefault = () => import('/email/_default.ts'); +const __emailAddr0 = () => import('/email/support+[ticket].ts'); +const __emailAddr1 = () => import('/email/support.ts'); +const __emailAddr2 = () => import('/email/[user].ts'); +const __emailPatterns = [ + { re: /^support\+([^+@]+)$/, params: ['ticket'], load: __emailAddr0 }, + { re: /^support$/, params: [], load: __emailAddr1 }, + { re: /^([^+@]+)$/, params: ['user'], load: __emailAddr2 }, +]; + +export async function __emailHandler(message, env, ctx) { + const local = + String(message?.to ?? '') + .toLowerCase() + .split('@')[0] ?? ''; + for (const p of __emailPatterns) { + const m = p.re.exec(local); + if (!m) continue; + const params = {}; + for (let i = 0; i < p.params.length; i++) params[p.params[i]] = m[i + 1]; + const mod = await p.load(); + if (typeof mod.default === 'function') return mod.default(message, env, ctx, { params }); + return; + } + const dmod = await __emailDefault(); + if (typeof dmod.default === 'function') return dmod.default(message, env, ctx, { params: {} }); +} +``` + +The worker entry (`compileWorkerEntry`) imports `__emailHandler`, builds the message proxy, runs the dispatcher, and returns the action queue — see [Cross-worker dispatch](#cross-worker-dispatch). + +### Cross-worker dispatch + +#### Why the user worker must be a `WorkerEntrypoint` + +WfP dispatch namespace stubs are typed as `Fetcher` — `.fetch()` and `.connect()` only. A plain default-export object like `export default { fetch, email, queue }` exposes **only `.fetch()`** through the namespace stub. The user worker extends `WorkerEntrypoint` from `cloudflare:workers` so its class methods become `Rpc.Provider` entries on `Fetcher`, making `.email()` / `.queue()` / `.scheduled()` callable from the router. + +`compileWorkerEntry` emits this shape for every WfP-deployed worker, not just email-handling ones. Cron and queue handlers also become RPC-callable from the platform side as a bonus, useful for future platform features. + +#### Why the user worker can't receive a real `ForwardableEmailMessage` + +Even with `WorkerEntrypoint`, the platform router cannot call `dispatch.get(name).email(realMessage)` and expect it to work. `ForwardableEmailMessage` is **not in workerd's RPC `BaseType` allowlist**, and `EmailMessage` is not `JSG_SERIALIZABLE`. RPC marshaling fails with `DataCloneError` before any bytes are sent. The receiver's `email()` method is never invoked. + +Sources verifying this: + +- `BaseType` allowlist: workerd `types/defines/rpc.d.ts` lines 64–83 — eleven types, no email +- Wire `SerializationTag` enum: `src/workerd/io/worker-interface.capnp` lines 424–469 — same 11 tags +- Serialization rejection path: `src/workerd/jsg/ser.c++:241–330` (`Serializer::WriteHostObject`); throws via `throwDataCloneErrorForObject` for any unregistered JSG type +- RPC argument marshaling: `src/workerd/api/worker-rpc.c++:176–188` (`serializeJsValue`) — zero email-specific handling +- Test fixture asserting the failure mode: `src/workerd/api/tests/js-rpc-test.js:852–858` +- `ForwardableEmailMessage` declaration: `types/defines/email.d.ts:28` — plain interface, no `RpcTarget` brand + +The `email?(message: ForwardableEmailMessage)` declaration on `WorkerEntrypoint` (PR #5538, commit `862cb02c0`, type-only diff with no C++ runtime support) is intended for the CF email runtime calling the user worker directly — not for cross-worker RPC dispatch. + +#### Envelope / Action contract + +The router serializes the inbound message into an `EmailEnvelope` (only fields in the RPC `BaseType` allowlist), invokes `dispatch.get(name).email(envelope)`, and awaits an `EmailAction[]` describing what the user handler decided. + +```ts +// from "void/email" +interface EmailEnvelope { + from: string; + to: string; + headers: Record; // plain object, not Headers + raw: ReadableStream | Uint8Array; + rawSize: number; +} + +type EmailAction = + | { type: 'reject'; reason: string } + | { type: 'forward'; to: string; headers?: Record } + | { type: 'reply'; from: string; to: string; raw: Uint8Array }; +``` + +Inside the user worker's `email(envelope)` method, the framework builds a _proxy_ `ForwardableEmailMessage` via `createMessageProxy(envelope)`. The proxy exposes all standard properties (`from`, `to`, `headers`, `raw`, `rawSize`) and methods (`setReject`, `forward`, `reply`), but each method **records an intent into an action queue** instead of acting directly. After the user handler returns, the codegen returns the action queue as the RPC result. The router enacts each action on the real `ForwardableEmailMessage` (with `setReject` honored as terminal per CF's runtime semantics). + +`replyEmail(message, opts)` keeps its existing public API; internally it builds the reply MIME (same threading-aware logic) and calls `proxy.reply({ from, to, raw })`. The router constructs a real `cloudflare:email` `EmailMessage` from the action and calls the original `message.reply(...)` on its side. + +User-handler ergonomics are unchanged from a single-tenant native CF Email Worker — authors still write `defineEmail(async (message, env, ctx, info) => { … })` and call imperatively. The framework hides the RPC round-trip. + +#### Cross-worker pre-buffering + +The router pre-buffers `message.raw` to a `Uint8Array` before serializing the envelope: + +1. **Stream consumer rule:** `parseEmail` reads the stream once and exhausts it. Buffering on the router side makes the body replayable across the RPC boundary if the receiving framework dispatcher needs it more than once. +2. **RPC stream lifetime:** a `ReadableStream` IS in `BaseType` and survives RPC, but it's still a one-shot transferable. Buffering avoids subtle timing failures. + +#### Outbound flow + +``` +user code → sendEmail() + ├─ 1. Build MIME locally (mime.ts, hardened: comma-quoting, foldHeader + │ quote-aware, In-Reply-To/References reserved against user input, + │ bare addr-spec rejects comma/semicolon/quotes) + ├─ 2. Default `from` to +noreply@mail.void.cloud if + │ omitted (slug shape validated; reject @/+/whitespace) + ├─ 3. POST __VOID_PROXY /email/send + │ body: { + │ projectId, aiToken, // HMAC-authenticated by middleware + │ from, recipients, // dedup'd, lowercased + │ mimeBase64, byteSize, + │ requestId, fromDomain + │ } + │ + │ The proxy: + │ a. Decode + size-check (≤ 10 MB) + │ b. Suspend kill-switch (email-suspended:) + │ c. Read email-allowlist: KV; partition recipients + │ into allowed / unverified + │ d. Burst + monthly counter against allowed.length only + │ (denied-by-allowlist sends don't burn quota) + │ e. EmailMessage.send() per allowed recipient via the proxy's + │ account-level send_email binding + │ f. Inline log + return SendEmailResult-shaped JSON + └─ 4. Pass response through unchanged — the framework just maps + unknown error codes to UPSTREAM_ERROR for forward-compat. +``` + +The user worker no longer has a `send_email` binding. Direct +`env.SEND_EMAIL.send()` returns undefined-of-undefined and faults — no +quota bypass surface. + +#### Inbound flow + +``` +external sender → mail.void.cloud MX + → CF Email Routing catch-all rule on mail.void.cloud + → email-router worker (packages/proxy/src/email-router.ts) + ├─ 1. Parse local-part: + + ├─ 2. Look up project by slug (ROUTING KV) + ├─ 3. Pre-buffer raw to Uint8Array; serialize envelope + ├─ 4. await dispatch.get(workerName).email(envelope) → EmailAction[] + ├─ 5. Enact each action on real ForwardableEmailMessage + │ (setReject is terminal; otherwise forward/reply in order) + └─ 6. ctx.waitUntil(log-recv → __VOID_PROXY) +``` + +#### `+` mechanics + +Outbound: framework reads `env.VOID_PROJECT_SLUG` (injected at deploy) and rewrites `from` if omitted. + +Inbound: the router parses the local-part. `` is everything before the first `+`; `` is everything after. The full original `to` address (`+@mail.void.cloud`) is preserved in the envelope, so the framework dispatcher matches against `email/.ts` handlers (or `_default.ts`) — the slug part is invisible to user handlers. + +Trade-off: the user's `email/support.ts` handler is reached via `+support@mail.void.cloud`, which is awkward UX-wise compared to `support@`. Documented as the shared-sender reality; custom domains (future work) give clean apex addresses. + +### Local development + +#### Dev inbox (outbound capture) + +A dev-only Vite plugin (`plugin-email-dev-inbox.ts`) registers `/__void/inbox` routes. When `sendEmail` is called under `void dev`, it doesn't touch the binding — instead: + +1. Builds the MIME as normal (so MIME bugs surface locally) +2. Stores the message in an in-memory ring buffer (cap: 100 messages, FIFO eviction) +3. Returns `{ ok: true, ids: [{ recipient, messageId: "dev_" }, ...] }` + +Routes: + +- `GET /__void/inbox` — HTML list view, newest first +- `GET /__void/inbox/:id` — rendered HTML body in a sandboxed iframe + headers + attachments +- `GET /__void/inbox/:id/raw` — raw `.eml` download +- `DELETE /__void/inbox` — clear the buffer + +The buffer survives HMR but not full dev-server restart. **Production safety:** the middleware is registered behind `import.meta.env.DEV` (tree-shakes out of production) AND handlers re-check a runtime dev flag. + +**Per-call opt-out:** `sendEmail({ ..., __sendInDev: true })` bypasses the inbox and sends for real. Per-call rather than env-var-gated so a single "test the real thing" send doesn't require restarting the dev server. + +**Round-trip dispatch (pending).** The inbox UI will grow two affordances: a "Deliver to local handler" button on each captured message (when the recipient's local-part matches a local `email/...` handler) and a "Paste / upload `.eml`" form that runs raw messages through the dispatcher. + +#### Test harnesses — `void/email/testing` + +```ts +import { createEmailTestHarness, createInboundTestHarness } from 'void/email/testing'; + +// Outbound +const outbound = createEmailTestHarness(); +await sendEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 'hi', text: 'yo' }); +expect(outbound.messages[0].subject).toBe('hi'); + +// Inbound +const inbound = createInboundTestHarness({ + routes: { 'support+[ticket]': supportHandler, _default: defaultHandler }, +}); +const result = await inbound.deliver({ + from: 'user@example.com', + to: 'support+ticket-7@app.dev', + subject: 'help', + text: 'I have a problem', +}); +expect(result.handler).toBe('support+[ticket]'); +expect(result.params).toEqual({ ticket: '7' }); +expect(inbound.replies).toHaveLength(1); +``` + +Both work in Vitest without any worker-runtime mocking. The inbound harness uses the same precedence rules + envelope/action contract as production. + +### Platform infrastructure + +#### Default sender setup (`mail.void.cloud`) + +One-time platform infrastructure (see [`meta/operations.md`](../operations.md#email-mailvoidcloud) for the runbook): + +- `mail.void.cloud` registered as a CF zone in the platform account, Email Routing enabled +- DKIM/SPF/DMARC auto-configured by CF on enable +- Catch-all routing rule on the zone → `void-proxy` worker (the proxy's `email()` export is bound by the `email-router.ts` module) +- Standard `pnpm run deploy` ships the proxy with the new code + +Per-project setup: nothing. Every project deployed with the new framework codegen is automatically reachable. Existing pre-`WorkerEntrypoint` deployments will hit the "no email handler" reject path; they need to redeploy. + +**Reputation pooling.** All `*.void.app` projects share `mail.void.cloud`'s sender reputation. Mitigations: + +- Conservative default outbound quotas +- Burst-rate cap per project (sliding-window in proxy) +- Manual suspend kill-switch in the admin UI + +Per-project DKIM signing isn't possible without per-project zones, so reputation pooling is unavoidable here. Custom-domain email (future work) gives projects with reputation-sensitive sending a clean path off shared. + +#### Inbound router worker (`packages/proxy/src/email-router.ts`) + +Bound as the proxy worker's `email()` export. Receives all inbound mail at `mail.void.cloud`: + +1. Parses `+` from `message.to` +2. Looks up the project's worker name in `ROUTING` KV (same KV the HTTP dispatcher uses) +3. Pre-buffers `message.raw` to bytes, serializes the envelope +4. Calls `dispatch.get(workerName).email(envelope)` — RPC into the user worker +5. Awaits `EmailAction[]`, enacts each on the real `ForwardableEmailMessage` +6. Async logs to `__VOID_PROXY` `/email/log-recv` via `ctx.waitUntil` + +Distinct error shapes for clarity in tail logs: + +- `setReject('mail.void.cloud: missing project slug')` — empty slug +- `setReject('no project found for slug "X"')` — KV miss +- `setReject('project "X" has no worker (static deploy)')` — static-only deploy +- `setReject('project has no email handler')` — pre-`WorkerEntrypoint` deploy needs redeploy +- `setReject('project handler error')` — generic dispatch failure + +#### `__VOID_PROXY` metering + +Reuses the unified service binding pattern the AI proxy + revalidation already use (`__VOID_PROXY` + `__VOID_PROXY_TOKEN` + `__PROJECT_ID`). The same proxy worker also handles `/ai/*`, `/d1/*`, `/kv/*`, `/r2/*`, `/revalidate`, and now `/email/*`. + +Auth pattern: caller sends `{ projectId, aiToken, ... }` JSON body; the proxy's middleware verifies `aiToken` is HMAC of `projectId` with the shared `JWT_SECRET`. Same auth as every other proxy route — no email-specific token plumbing. + +Three endpoints in `packages/proxy/src/email.ts`: + +- `POST /email/check-quota` — pre-flight outbound quota check. Returns 429 if denied; suspend kill-switch denied first +- `POST /email/log-send` — async per-recipient delivery log +- `POST /email/log-recv` — async inbound delivery log (called by router worker, not user code) + +#### Quotas + suspend + +Counters live in `ROUTING` KV under monthly buckets: + +| Key | Purpose | +| ----------------------------------------- | -------------------------------------------------------- | +| `email-sent::` | Monthly outbound counter | +| `email-recv::` | Monthly inbound counter | +| `email-burst::` | Per-minute outbound counter (TTL 5m) | +| `email-limit:` | Per-project monthly outbound override (default 200) | +| `email-burst-limit:` | Per-project per-minute burst override (default 10) | +| `email-suspended:` | Suspend kill-switch — non-null value blocks all outbound | + +Default plan limits (placeholders pending product input): + +| Plan | Outbound / month | Inbound / month | Burst / minute | +| ----- | ---------------- | --------------- | -------------- | +| Free | 200 | 1,000 | 10 | +| Hobby | 10,000 | 50,000 | 60 | +| Pro | 100,000 | 500,000 | 600 | + +The shape is what matters; the numbers will move with real usage data. Inbound is metered separately (users don't trigger it; attackers do; reasonable inbound volumes are higher than outbound) and overage logs but doesn't reject — hard inbound rejection requires CF rule changes that risk silent drops. + +**Both quota counters are best-effort, and the monthly cap is not a backstop for the burst cap.** Every counter here is a KV read-modify-write. Concurrent isolates read the same value and overshoot by `(parallel_isolates × per_request_recipients)`, and the wider monthly window does not make that safer: KV accepts at most one write per second per key, so above ~1 send/second the month counter stops advancing for the rest of that second and every request in that window charges against the same stale read. KV answers the rejected write with a 429, which the proxy's `.catch()` swallows. The overshoot therefore scales with request rate and degrades the monthly cap by the same factor as the burst cap. + +What the caps do buy is a bound on accidental floods and a cheap reject for the ordinary case. The actual abuse control is the suspend kill-switch — `email-suspended:` plus the platform-wide `routing.suspended` marker — which is read uncached on every send so a suspend converges immediately. Making either cap strict means moving the counter into a Durable Object (or fronting it with CF rate limiting); that is a platform architecture decision and is deliberately out of scope for this design. + +CF caps message size at ~25MB raw / ~10MB base64-inflated for `send_email`. The framework enforces that ceiling client-side and reports as `MIME_ERROR` before contacting the proxy. + +#### Verified destinations + +CF's `send_email` only delivers to addresses verified as destinations on the sender account, so the platform manages a per-project allowlist on top of CF's account-wide list: + +- **Schema** — `email_destinations` table (`id`, `project_id`, `address`, `status: 'pending' | 'verified' | 'failed'`, `cf_destination_id`, `verified_at`). +- **CF API client** — `packages/api/src/provisioning/email-routing.ts` wraps `POST/GET/DELETE /accounts/:id/email/routing/addresses`. +- **API routes** — `GET|POST|DELETE /projects/:id/email/destinations[/:id]`. POST triggers CF to email the recipient with a verification link; GET refreshes status from CF for any rows still `pending`. +- **Auto-verify project owner** — `packages/api/src/routes/projects.ts` calls `addDestination(user.email)` when a project is created (via `ctx.waitUntil`, non-blocking). The owner gets the verification email in their GitHub-OAuth-linked inbox; one click and their project can send to itself out of the box. +- **Per-project allowlist enforcement at deploy** — `packages/api/src/routes/deploy.ts` reads `email_destinations` where `status='verified'` and emits `{ type: 'send_email', name: 'SEND_EMAIL', allowed_destination_addresses: [...] }` on the worker upload. CF enforces at the binding level — even though destinations are account-wide on CF's side, projects can only send to their own declared addresses. +- **Cross-project safety on delete** — destinations are CF account-wide; before calling `deleteDestination` we check `countProjectsUsingDestination` and only remove from CF if no other project references the same `cf_destination_id`. + +The platform/team never opens the CF dashboard. The recipient (the person receiving the verification email) is the only human in the loop, and they only click a link in their own inbox. + +#### Admin UI — `packages/api/src/admin/routes/email.tsx` + +Per-project page at `/admin/email/:projectId`: + +- Usage summary (sent / received / limit / burst cap / status) for the current month +- Suspend toggle (`email-suspended:`) +- "Set monthly limit" + "Set burst limit" forms (`email-limit:*` / `email-burst-limit:*` overrides) +- "Reset counter" button for the current month +- Verified-destinations table with add / remove + +Mirrors the existing admin route patterns (e.g. `packages/api/src/admin/routes/deployments.tsx`). Direct KV/DB reads + writes; no API layer between admin and storage. User-facing dashboard pages are deferred to whenever broader dashboard work resumes. + +#### CLI — `void email` + +``` +void email usage # monthly outbound/inbound counts + quota +void email logs [--limit N] # recent email delivery activity +void email destinations # list verified recipients +void email allow
# add recipient + trigger CF verification +void email disallow
# remove recipient +``` + +Hits the API routes above. Output formatted for the terminal — useful for power users debugging "did my mail go out?" or wiring a new recipient without an admin trip. + +## Why custom domains aren't included + +The current `void domain add` flow uses CF for SaaS / Custom Hostnames — CNAME-based, with the user's DNS staying at their existing provider. That model **cannot support Email Routing** because: + +- Email Routing requires MX records on the receiving domain pointing at CF's MTA +- For those MX records to deliver into a CF Email Worker, the domain must be a CF-hosted zone with Email Routing enabled +- The user's domain isn't a CF zone — they only CNAMEd it +- RFC 2181 also forbids CNAME and MX coexisting at the same name + +Adding custom-domain email here would mean building one of: + +- **NS delegation flow** (full or subdomain) — entirely greenfield infrastructure. No NS handling exists in `packages/api` today; only `createCustomHostname` for CF for SaaS. Adds a second `void domain add`-shaped flow with status polling, zone creation, etc. +- **BYO CF account** — user creates a scoped CF API token, pastes it in. Violates the "users never touch CF" premise. +- **Self-hosted SMTP relay** — huge work; reinvents what CF Email Routing already gives us for free. + +For now: users who need apex-domain sending (`noreply@acme.com` not `+noreply@mail.void.cloud`) install a third-party SDK directly in their handler — Resend, SES, Postmark, etc. ~3 lines of code. No platform involvement. + +If usage shows enough demand for a first-class apex-domain story, the cleanest implementation is a thin provider wrapper that signs outbound with DKIM (TXT records on the user's existing DNS, no NS delegation, no CF zone ownership). See [Future work](#future-work). + +## Backwards Compat + +Pre-launch — clean cutover. Notes for context: + +- `sendEmail` user-facing API is unchanged. Result shape, error codes, + multi-recipient fan-out, dev inbox, test harness all behave the same. +- `from` defaulting only applies when omitted; explicit `from` passes through. +- Pre-`WorkerEntrypoint` user deployments: outbound now routes through + `__VOID_PROXY` (which they already have) so it works without a redeploy. + Inbound still hits the router's "no email handler" reject path until + they redeploy. +- User wrangler.jsonc files that previously had `[[send_email]] { name = "SEND_EMAIL" }` + emitted by `void dev` are no longer rewritten. Stale blocks are + harmless (no production code references them) and can be deleted by + the user; the framework neither emits nor strips them. +- `manifest.bindings.email` field was removed from the deploy manifest + shape. The platform doesn't use it anymore. + +## File Layout + +``` +packages/void/ (framework, in ../void) +├── package.json # ./email + ./email/testing exports +├── src/ +│ ├── runtime/ +│ │ ├── email.ts # sendEmail, createEmail, default-from resolution, proxy hooks +│ │ └── email/ +│ │ ├── mime.ts # MIME builder (mimetext wrapper) +│ │ ├── inbound.ts # defineEmail, parseEmail, replyEmail, createMessageProxy +│ │ ├── dev-inbox.ts # in-memory ring buffer + sink registry +│ │ ├── testing.ts # createEmailTestHarness, createInboundTestHarness +│ │ └── types.ts # SendEmailOptions, SendEmailResult, EmailEnvelope, EmailAction +│ ├── router/ +│ │ ├── email.ts # scanEmailHandlers + compileEmailPattern +│ │ └── compile.ts # __emailHandler dispatcher emit; WorkerEntrypoint subclass +│ ├── inference/walk.ts # needsEmail (3-tier import / regex / env detection) +│ ├── plugin-inference.ts # bindings.email opt-out +│ ├── plugin-routing.ts # passes emailDir; watches email/ +│ ├── plugin-email-dev-inbox.ts # /__void/inbox Vite plugin +│ ├── cli/ +│ │ ├── wrangler.ts # no on-worker [[send_email]] emit (proxy holds the binding) +│ │ ├── email.ts # `void email usage|logs` subcommands +│ │ └── email-args.ts # subcommand parser +│ └── index.ts # mergeBindings for send_email +└── test/unit/ + ├── email-mime.test.ts + ├── email-send.test.ts # send + default-from + proxy quota + partial failures + └── email-inbound.test.ts # scanner, compile emit, defineEmail, parseEmail, + # buildReplyMessage, createInboundTestHarness + +packages/api/src/ (platform) +├── schema.ts # MODIFIED — add email_destinations table +├── drizzle/0011_add_email_destinations.sql +├── db.ts # MODIFIED — destination CRUD helpers +├── routes/ +│ ├── email.ts # GET usage, GET logs, GET|POST|DELETE destinations +│ └── projects.ts # MODIFIED — auto-add owner email on project create +├── admin/routes/ +│ └── email.tsx # admin page: usage, suspend, limits, destinations +├── provisioning/ +│ ├── email-routing.ts # NEW — CF Email Routing addresses API client +│ └── provision.ts # MODIFIED — inject VOID_PROJECT_SLUG plain_text binding +└── routes/deploy.ts # MODIFIED — emit send_email with allowed_destination_addresses + +packages/proxy/src/ (platform) +├── email.ts # /email/check-quota, /email/log-send, /email/log-recv +├── email-router.ts # email() export — slug+tag parse, dispatch, enact actions +├── body.ts # MODIFIED — asNumber helper +└── index.ts # MODIFIED — wire /email routes; export email() handler + +User project layout: + +my-app/ +├── crons/ # → scheduled() export +├── queues/ # → queue() export +├── email/ # → email() export (NEW) +└── routes/ # → fetch() export +``` + +## Rollout + +Single ordered sequence. Each step is independently testable; later steps depend on earlier ones. + +1. **Framework: outbound runtime + types + dev inbox + tests.** `sendEmail`, MIME builder, dev-inbox plugin, `createEmailTestHarness`. Standalone — no platform dep. +2. **Framework: inbound runtime + scanner + dispatcher emit + tests.** `defineEmail`, `parseEmail`, `replyEmail`, `email/` scanner, `__emailHandler` codegen, `createInboundTestHarness`. +3. **Framework: `WorkerEntrypoint` codegen migration.** `compileWorkerEntry` emits the class form with `email(envelope)` returning actions. Affects all WfP-deployed workers. +4. **Framework: default-from + `__VOID_PROXY` integration.** `from` defaults to `+noreply@mail.void.cloud`; pre-flight quota + async log via the unified proxy. Fail-open when proxy is unbound. +5. **Framework: `bindings.email` flag in deploy manifest.** `packageBuild` / `packageFrameworkBuild` set `manifest.bindings.email = true` when `needsEmail` is detected, so the platform knows to emit `send_email` on the worker. +6. **Platform: `VOID_PROJECT_SLUG` injection.** `packages/api/src/provisioning/provision.ts` adds the plain-text binding alongside the existing proxy bindings. +7. **Platform: proxy email handlers + router worker.** `/email/check-quota`, `/log-send`, `/log-recv` in `packages/proxy/src/email.ts` (with monthly + per-minute burst caps + suspend kill-switch); `email-router.ts` wired as the proxy's `email()` export. +8. **Platform: destinations schema + CF API client + API routes.** `email_destinations` table; `provisioning/email-routing.ts`; `GET|POST|DELETE /projects/:id/email/destinations[/:id]`. Auto-add project owner's email on project create. +9. **Platform: `send_email` binding emit at deploy.** `routes/deploy.ts` reads verified destinations and emits `{ type: 'send_email', allowed_destination_addresses: [...] }` per project. +10. **Platform: API + admin UI.** `GET /projects/:id/email/{usage,logs}` + `admin/routes/email.tsx` (usage, suspend, limits, destinations). +11. **Framework: `void email usage|logs|destinations|allow|disallow` CLI.** +12. **Ops: `mail.void.cloud` zone setup.** Register zone in CF, enable Email Routing, add catch-all rule pointing at the proxy worker. One-time human action — see [`meta/operations.md`](../operations.md#email-mailvoidcloud). +13. **Deploy.** `pnpm run deploy:staging` first to validate the dispatch path against real WfP runtime; then production. +14. **Tag release.** + +Steps 1–5 + 11 ship in the framework via the normal release. Steps 6–10 are platform changes. Step 12 is the only ops gate before production sending works. + +## Future Work + +Things we deliberately did not build. Listed for context, not as a roadmap commitment — each will only get built if real usage shows the demand. + +### Provider abstraction + custom-domain email + +If apex-domain sending (`noreply@acme.com`) becomes a frequent ask, the cleanest path is a thin wrapper over Resend / SES / Postmark. Providers sign with DKIM, so verification is just TXT records on the user's existing DNS — no NS delegation, no CF zone ownership, no second `void domain add`-shaped flow. Sender stays at the apex. + +Until then: users install the provider's SDK directly in their handler. Three lines. No platform involvement, no wrapper that has to track API changes. + +```ts +// void.config.ts +export default defineConfig({ + email: { + provider: 'resend', // | "ses" | "postmark" | "cloudflare" (default — shared mail.void.cloud) + }, +}); +``` + +Per-domain setup flow: + +1. User runs `void email add-sender noreply@acme.com` +2. Platform calls the provider's API to register the domain; provider returns DKIM/SPF/DMARC TXT records +3. CLI prints the records for the user to add to their DNS +4. `void email status noreply@acme.com` polls for verification +5. Once verified, the address joins the project's `email_senders` table — typed-sender codegen surfaces it + +Runtime: `sendEmail` dispatches to the configured provider; CF native (`mail.void.cloud` shared sender) remains the default. Dev inbox works identically regardless of backend. Phase-3 metering and logs continue to work — the proxy is provider-agnostic. Cost passthrough (e.g. Resend at $20/50k) needs a billing model; product decision when this scopes. + +### Component templating + +- Convention: `emails/.{tsx,vue,svelte,solid}` — adapter-aware, mirrors how pages work +- Build: render at request time to inlined HTML + plain-text via a lightweight HTML-to-text pass; cache rendered output keyed by props hash +- API: `await sendEmail({ template: WelcomeEmail, props: { name } })` — props typed from the template's component signature +- `void email preview` CLI: dev server at `http://localhost:5174/__emails__/` — lists templates, prop-form input, hot reload +- React Email / `@react-email/components` is the leading prior art + +### Typed verified senders + +Codegen queries the platform API for the project's verified senders + Email Routing zones, generates `.void/email.d.ts`: + +```ts +declare module 'void/email' { + type VerifiedSender = 'noreply@acme.dev' | 'support@acme.dev' | '+noreply@mail.void.cloud'; + + interface SendEmailOptions { + from: VerifiedSender | { email: VerifiedSender; name?: string }; + } +} +``` + +Surfaces "you can't send from this address yet" as a TypeScript error at edit time, not a runtime `INVALID_FROM`. + +### Bounce handling + +- Hard bounces from CF aren't delivered to Email Workers today (CF returns to the sending MTA) +- For provider-backed sending, bounces arrive via webhooks — wire them into a `email/_bounce.ts` convention for handler-side suppression list management +- For CF-native sending, bounces remain a gap — documented honestly + +### Other follow-ups + +- Analytics Engine wiring for persistent delivery logs (currently `void email logs` returns `pending: true`; tail-only) +- Auto abuse detection / reputation alerting (currently only manual suspend toggle) +- Dev-inbox round-trip UI (deliver-to-local-handler button, paste-`.eml` form) + +## References + +- [`0047-email.md`](./0047-email.md) — the deferred Resend design, preserved for context +- Cloudflare Email Workers docs: `send_email` binding, Email Routing, `EmailMessage`, `email()` export +- workerd RPC types: `types/defines/rpc.d.ts` (BaseType allowlist), `types/defines/wfp.d.ts` (DispatchNamespace), `types/defines/email.d.ts` (ForwardableEmailMessage) +- workerd RPC implementation: `src/workerd/api/worker-rpc.c++`, `src/workerd/jsg/ser.c++`, `src/workerd/io/worker-interface.capnp` +- `postal-mime` — inbound parser +- `mimetext` — outbound MIME builder +- `react-email` / `@react-email/components` — templating prior art (future work) +- `packages/void/src/runtime/env.ts:70` — `requireRuntimeBinding` shared helper +- `packages/void/src/inference/walk.ts:6-103` — existing binding inference pattern +- `packages/void/src/cli/wrangler.ts:179-244` — existing wrangler-sync pattern +- `packages/proxy/src/*` — AI proxy + unified `__VOID_PROXY` pattern that this design extends +- `packages/api/src/admin/routes/deployments.tsx` — admin page pattern this design follows diff --git a/meta/lessons.md b/meta/lessons.md index 5ca0ce6f8..4f54a7926 100644 --- a/meta/lessons.md +++ b/meta/lessons.md @@ -5,9 +5,23 @@ - Worktrees: create real git worktrees with `git worktree add` under `.worktrees/`. Do not apply worktree changes back to the main tree without explicit user confirmation. - Design/doc work: stay in design mode until the user asks for implementation. If a feature is not live yet, prefer a clean cutover over legacy migration work. - Docs/progress: code is the source of truth, but public docs may intentionally omit paused features. Describe root scripts exactly as implemented; root `dev` and `build` are dashboard-only. Track "implemented", "shipped", and "done" separately unless the user says they are equivalent. -- Commands: avoid HEREDOC commit messages with apostrophes; run `pnpm test ` without `--`; in `framework/`, use `pnpm build:all` for a full build. +- Commands: avoid HEREDOC commit messages with apostrophes; run `pnpm test ` without `--`; in `framework/`, use `pnpm build:core` for the `void` package and `pnpm build` for every package (`build:all` was renamed and no longer exists — an unknown script exits 0 through a `| tail` pipe, so the build silently never ran). - `vp run` argv: unlike `pnpm`, `vp run