Repository navigation
feat(email): inbound routing, sendEmail, dev inbox and test harness - #160
TheAlexLichter wants to merge 8 commits into
Conversation
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 <slug>+ 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
7ac80ed to
3724728
Compare
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3724728f01
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Addresses four review findings, each reproduced before being fixed.
The dev inbox never captured anything. The sink was registered on the
Vite plugin's Node globalThis while user code runs in a separate workerd
process, so hasActiveSink() was always false in the worker and every send
fell through to BINDING_MISSING - whose message told the user to run
void dev, which is what was already running. The comment in dev-inbox.ts
claiming both sides share one runtime was simply wrong.
sendEmail now hands captured messages to the dev server over the worker's
assets binding, which the Cloudflare vite plugin already backs with the
Vite middleware stack. Baking the dev-server origin as a var was rejected:
mergeBindings runs before httpServer.listen, so it can only bake the
configured port, and Vite increments on collision. The injection is gated
on routeEnvironment === 'dev', which excludes vite preview, and carries a
binding name rather than a credential; a production build emits vars: {}.
A 200 is only trusted with the ack header, so a plain asset response can
never look like a capture. An unreachable inbox returns UPSTREAM_ERROR
under a 5s bound rather than claiming success or hanging the request.
The test harness still lowercased the local part and matched without the
i flag, so a handler under test saw ticket t-42 where production hands it
T-42. The production dispatcher was fixed for exactly this reason and the
shared pattern compiler documents the contract the harness was violating.
A green suite would have certified behavior the deployed code does not
have.
Both documented harness examples used an address production never
delivers: deliver() takes the wire form <slug>+<tag>@<domain> and reads
support+abc-123@acme.dev as slug support. The first example routed to
_default; the second passed only because _default was its sole route. The
same broken example was in the harness JSDoc.
The reply docs claimed from defaults to message.to. It does not, and that
address is the one the platform drops: the proxy pins a reply sender to
the receiving slug, while message.to is the stripped <tag>@<domain>. A
handler following the docs got a reply that vanished with no error. The
guide and the ReplyEmailOptions JSDoc now document the pin, the throw,
and the asymmetry with sendEmail, which returns errors instead.
Also removed a promise of a local inbound round-trip; no inbound dev
trigger exists.
Class A frameworks declare no assets binding and so have no loopback;
they still return BINDING_MISSING, which the guide now states.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f8d8b1caa2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Four more review findings, each verified against the real code before being touched. One was only partly true and was fixed narrower than reported; one turned out to be a documentation error of mine. encodeWord emitted one unbounded RFC 2047 encoded-word, and foldHeader could never repair it: its fallback splits on whitespace and base64 has none, so it always saw a single token and returned the line unchanged. Measured, a 16-character CJK subject produced a 76-character word against the 75-character limit in RFC 2047 section 2, and 246 CJK characters produced a 1005-octet line against the 998 that RFC 5322 section 2.1.1 makes a MUST. Nothing downstream repairs it; the proxy forwards our bytes verbatim. The file already tested that same 998-octet limit for the References header and cited it in two comments, so the encoded-word path was bypassing a guard the authors knew they needed. encodeWord now chunks by code point, not by byte and not by UTF-16 unit, and base64s each chunk independently. RFC 2047 section 5 forbids splitting a multi-octet character across words, so slicing one base64 string would not do. A UTF-16 chunker or a slice-after-encoding both corrupt an astral character; tests pin that. foldHeader's two mutually exclusive branches became one pass, which also closes a gap the address-list branch left open: it never re-folded inside a part, so one recipient with a long encoded display name still produced a 1058-octet line. Verified through Python's stdlib parser: exact round-trip, no U+FFFD, zero defects, every line at most 73 octets. The test harness recorded no rejection when nothing matched and there was no _default, while the generated dispatcher calls setReject and the sender gets a non-delivery report. A test for catch-all or anti-spam behavior could therefore observe an accepted message that deployment bounces. The harness now rejects with the dispatcher's reason byte for byte, built from the wire recipient with its case preserved, and routed through setReject so the terminal-reject semantics stay identical. PARSE_FAILED was declared in the ParseEmailError code union and never constructed. postal-mime is lenient enough that binary garbage, truncated parts and bad header bytes all parse; what it does reject is a nesting bomb, which escaped as a bare Error carrying parser internals. It is now wrapped, with the original kept as cause. The reviewer also claimed handlers could not distinguish oversize from parse failure. They cannot distinguish anything: ParseEmailError is not exported from void/email at all. Exporting it is a scope decision, not a bug fix, so the class stays internal. The Class B/C finding was right about the mechanism and my own documentation was wrong about the cause. I had written that those frameworks lack an assets binding. In fact Void never installs the Cloudflare Vite plugin for them, so both the dev-inbox plugin and mergeBindings are out of reach, and declaring an assets binding would not help: SvelteKit runs in Node and Nuxt and Analog run in worker_threads, a different JS realm, so there is no workerd-behind-Vite loopback to bind to. Only Astro could work. The guide now states the real boundary and points those users at the in-process harness. Supporting them needs three different mechanisms for four frameworks and is left as a design task. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4ccb016c2b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…limit flag Three more review findings. A fourth, about recipient local-part casing, was verified and declined; see below. The address shape check only required an '@' to be present anywhere, so user@, @example.com and a@b@c passed the preflight. Measured, sendEmail returned ok:true for all three and the dev inbox showed a capture, while the platform then refused them - as INVALID_FROM for a sender, but for a recipient as a mid-batch UNVERIFIED_DESTINATION rather than the advertised INVALID_TO. Nothing malformed could ever reach Cloudflare, because the api already gates every allowlist entry with this rule, so the defect is a dev-prod divergence rather than a delivery or injection one. Header injection was already closed: comma, semicolon, angle, quote, whitespace and CRLF all threw. The check now requires exactly one separator with a non-empty local part and domain. Deliberately not the api's isValidEmail, which also requires a dot in the domain and would newly reject user@localhost and x@y, both legal RFC 5321 and accepted today; both are pinned by tests. Quoted local parts were already rejected by the existing specials class, so the stricter separator rule breaks nothing that worked. The introductory docs example read result.error after only narrowing on result.ok. SendEmailResult has two failure members and error exists on just one of them, so the example threw a TypeError rather than printing the error - and it failed to typecheck, which is how a reader would first notice. It sits on the most common failure path: UNVERIFIED_DESTINATION is reachable only through the per-recipient branch, so the first send to an unverified recipient hits exactly this. The example now narrows on 'error' in result and handles both branches, and the error-code table says which branch carries which code. void email logs --limit is parsed, honoured and documented in the CLI reference, but the help page was built with the shared project-only helper so the flag was invisible in every help surface. It is the only such mismatch across the five email subcommands: the parser accepts --limit everywhere but only logs forwards it. cli-help.test.ts previously asserted path resolution only and never rendered content, so nothing could have caught this; it now renders the pages and asserts the flags. Declined: the envelope recipient array is lowercased before the POST, which RFC 5321 section 2.3.11 says an intermediate should not do to a local part. Fixing it in the framework alone changes zero bytes on the wire, because the proxy lowercases again on receipt and hands that same string to EmailMessage - proven by running the real proxy worker. The genuine fix spans both repos and is gated on unverified Cloudflare behaviour: the api registers the lowercased address as the verified destination, so transmitting mixed case risks tripping Cloudflare's own verification check. That is a decision about the canonical address form, not a bug fix, and the codebase already made the opposite choice deliberately for the sender, which is transmitted verbatim under a named test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6c5da083e8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… claim Four review findings, all verified against running code. Two of them are holes left by my own earlier fixes in this branch. void/email was never added to SERVER_ONLY_STUBS, so a shared module reached from a client graph pulled in runtime/env-raw.ts, whose node:async_hooks import and module-scope AsyncLocalStorage have no browser equivalent. The build does not fail; it succeeds with an externalization warning and ships new (emptyStub).AsyncLocalStorage, so the page white-screens on load. Proven by ablation on a real playground build: without the stub the warning appears and AsyncLocalStorage lands in the client chunk, with it neither does. void/email/testing needs its own entry because the resolve filter is anchored, and for a different reason - it drags node:path, node:fs and node:module in through router/email.ts. Both stubs throw rather than returning an empty object, following void/sandbox, and their export lists were checked against the real modules programmatically. The RFC 2047 chunking added earlier only covers non-ASCII values. A pure-ASCII token bypasses encodeWord entirely and leaks through two separate paths in foldHeader - the single-chunk early return and the fold loop, which still gives an over-long chunk its own line - so a 1100-character token produced a 1109-octet line against RFC 5322 section 2.1.1, which is a MUST. Nine header paths were affected, including the header name and the filename/name/Content-ID parameters that are built outside foldHeader. One post-render guard covers them all. Encoding instead of rejecting is not a general remedy: RFC 2047 section 5 permits encoded-words only in unstructured fields, comments and phrases, so it is illegal in References, In-Reply-To, Content-ID and parameter values. Truncating corrupts silently - a clipped References breaks threading with no signal. That guard needed a companion. buildReplyMessage threads the inbound Message-ID and References verbatim, and a remote sender controls both, so the guard would have let anyone make an app's reply throw. Threading is best-effort, so a value that cannot be represented is dropped and the reply still sends. Attachment filenames were emitted into quoted-string parameters with quotes deleted and backslashes left raw. qtext excludes both, so each may appear only as a quoted-pair: a trailing backslash escaped the closing quote and left the parameter unterminated, and every embedded backslash was silently eaten, so a Windows path C:\Users\bob\report.pdf arrived as C:Usersbobreport.pdf. This file's own folder already tracks quoted-pairs, so the emitter was contradicting it. All five cases now round-trip exactly through an independent parser with zero defects. Not a security issue - rejectCRLF already closes header injection and the quote strip closed parameter injection. The shipped agent prompt still said every void dev send is captured by the local inbox, which the guide stopped claiming last round. That prompt seeds agents, so it would have propagated the wrong guidance into user projects. Handed back, not fixed here: attachment filenames are still emitted as raw 8-bit UTF-8 rather than RFC 2231, which is inconsistent with the RFC 2047 treatment every other header string in the file gets. Modern parsers recover them correctly; a naive one produces mojibake. Separate defect, separate compatibility trade-off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9cfd3ba879
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…k boundary
Three review findings on the email re-land.
1. `email/` was invisible to every deploy decision that asks "does this
project need a worker". An email-only project took the static-site path,
which uploads no worker script at all, so the handlers were dropped and
inbound mail bounced. Fixed at all three sites:
- `findBackendSources` / `hasBackendFiles` (preset.ts) now count
`paths.emailDir`, so `detectPreset` stops returning a static preset
for an email-only project and the `--backend cloudflare` reject
message lists `email/`.
- the fully-static optimization in `runFullDeploy` now derives
`hasEmail` from `routeScan.email`. The predicate is extracted as
the exported `hasNonPageBackend`, following `hasPrerenderSources`
25 lines above it, because the inline expression had no test seam.
The manifest `ssr` flag is deliberately NOT touched. Dispatch uses it
for request-type metering (`'ssr'` vs `'static'`), so threading email
into it would mis-meter every request on a page-less project.
2. The harness rebuilt `ForwardableEmailMessage.headers` from `input`
only, so `buildMime`'s generated `Message-ID` never reached the
handler and `replyEmail` produced an unthreaded reply that production
would have threaded. Headers are now parsed back out of the wire
bytes, which also fixes the `raw:` override path.
3. Inbound is Void-app-only. `routingPlugin` compiles the `email()`
export and no framework branch installs it — the framework wrapper
wires crons and queues only. An `email/` directory under TanStack
Start, React Router, SvelteKit, Nuxt, Analog or Astro is now a build
error next to the existing auth / sandbox / live guards, instead of
silently dead code. Gated on the handler directory, not on
`bindings.needsEmail`: outbound `sendEmail` is a binding and keeps
working everywhere.
The guide is corrected to match. It also claimed inbound provisioning
was pending "the platform's Phase 3" and that handlers sit dormant.
That is stale — `meta/operations.md` documents one shared mail
facility with a single zone catch-all and no per-project DNS work,
so handlers are live as soon as the deploy lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 89b9d4745e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…he shipped prompts
Two review findings acted on, one declined with evidence.
1. `quotedPrintableDecode` accepted any `=XY` pair that merely STARTED with
a hex digit: `parseInt('0G', 16)` is 0, not NaN, so `=0G` decoded to a
NUL and `normalizeEmail` then threw `MIME_ERROR` on the reply subject.
Both characters must now be hex; a malformed escape stays literal, as
RFC 2045 §6.7 asks and as postal-mime does.
That predicate was one of several doors to the same throw. A
well-formed `=00` / `=0A`, a B-encoded NUL, or a 999-octet ASCII subject
with no whitespace all reached `rejectCRLF` or the 998-octet guard from
the same remote-sender-controlled header — and in production a rejected
handler bounces the whole delivery, replies and forwards alike. The
default subject is now best-effort, like the threading headers already
were: C0 controls become a space, and an ASCII subject no line can hold
falls back to a bare `Re:`. A non-ASCII subject is never rejected, since
`encodeWord` splits it into foldable encoded-words. Seven tests pin
each door and both controls.
2. `AGENT_PROMPT.md`, which `void init` injects into the project's agent
instructions, and `getting-started-prompt.txt` both advertised `email/`
for inbound without the Void-app-only boundary. The previous round had
qualified the dev inbox in the latter, not inbound handlers. Both now
say the directory is a build error under a third-party framework and
that `sendEmail` keeps working.
Declined: gating the `WorkerEntrypoint` entry on `hasEmail` for workers
pinned before the 2024-04-03 RPC compatibility date. The `rpc` flag gates
the CALLER's Fetcher stub, not the callee's class. Verified in workerd
1.20260701.1: a `WorkerEntrypoint` subclass serves `fetch`, `scheduled`,
`queue` and RPC `email()` at compatibility dates back to 2021-11-03 and
under an explicit `no_rpc`. The only caller is the platform proxy at
2026-02-12. The premise — Void preserves the user's date with no floor and
the conversion is unconditional — is true and now documented in the
review thread; the failure it predicts does not exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: af10ad35b8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return false; | ||
| } | ||
| const encoder = new TextEncoder(); | ||
| const widest = Math.max(...value.split(/\s+/).map((token) => encoder.encode(token).length)); |
There was a problem hiding this comment.
Avoid spreading sender-controlled header tokens
When an inbound References header contains roughly 125,000 whitespace-separated tokens, Math.max(...tokens) exceeds V8's argument limit and throws RangeError; such a folded header remains well below the 10 MB message limit. Because replyEmail() passes the remote-controlled header here, a sender can make an otherwise valid automatic reply abort instead of taking the intended best-effort fallback. Compute the maximum iteratively, preferably stopping once it exceeds the line budget.
Useful? React with 👍 / 👎.
| params[route.pattern.paramNames[i]] = m[i + 1]; | ||
| } | ||
| const info: EmailHandlerInfo = { params }; | ||
| await route.handler(message, env, ctx, info); |
There was a problem hiding this comment.
Install runtime env while invoking inbound test handlers
When a production-style handler calls replyEmail(message, { text: ... }) without an explicit from, this harness invocation throws because replyEmail reads __VOID_PROJECT_SLUG and __VOID_EMAIL_DOMAIN through getRawRuntimeEnv(), while the harness only passes env as the handler's second argument and never establishes the runtime context. Even supplying those keys through opts.env has no effect, so the documented threaded-reply handler cannot be tested through this harness without changing its production behavior.
Useful? React with 👍 / 👎.
| 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.`, |
There was a problem hiding this comment.
Label the owner address as a recipient, not a sender
When project creation returns owner_email_destination, both branches describe that address as a verified sender, but it is an outbound recipient allowlist entry; senders remain pinned to the project's <slug>...@mail.void.cloud address. This message can lead users to put their owner email in from, which the platform rejects, so call it a recipient or destination instead.
Useful? React with 👍 / 👎.
`void deploy --backend cloudflare` succeeds, but sendEmail returns BINDING_MISSING and email/ handlers are not routed (no rule, no binding, actions not applied). State it in the email guide, the Cloudflare integration scope list, the agent prompt and the getting-started prompt, and widen the BINDING_MISSING row to name that case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3b90aeb581
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| // `void dev`: the inbox buffer lives in the Vite dev server's Node process, | ||
| // which this isolate has no shared memory with. Hand the message over. | ||
| const devInbox = opts.sendInDev ? undefined : resolveDevInbox(rawEnv); |
There was a problem hiding this comment.
Provide a production transport for sendInDev
When sendInDev: true is used under a normal void dev, this line disables the only configured transport and immediately falls through to the __VOID_PROXY check. The serve configuration installs the inbox loopback but deliberately does not bind __VOID_PROXY or its production identity values, so the documented “real send during dev” example always returns BINDING_MISSING instead of sending. Add a credentialed development proxy path or stop advertising this option as a delivery bypass.
Useful? React with 👍 / 👎.
| 'deploy: --backend cloudflare found no Void backend to deploy.', | ||
| 'A full Void app has at least one of: routes/, pages/, middleware, crons/, queues/,', | ||
| 'or an SSR entry. Add your server code, or use `void deploy` for a static site.', | ||
| 'email/, or an SSR entry. Add your server code, or use `void deploy` for a static site.', |
There was a problem hiding this comment.
Reject inbound email in direct Cloudflare deploys
In runCloudflareDeploy's positive backend gate, treating email/ as sufficient lets an email-only project pass void deploy --backend cloudflare and report a successful deployment. The generated email(envelope) method only records EmailAction[] for the managed platform router to enact; a direct deployment has no such router, so its handlers remain unusable (and native Email Routing would pass a different message shape and never enact the returned actions). Fail closed for projects containing inbound handlers on this backend rather than counting them as supported backend code.
Useful? React with 👍 / 👎.
| // Gated on the handler DIRECTORY, not on `bindings.needsEmail`. Outbound | ||
| // `sendEmail` is a binding rather than a compiled export, so it keeps | ||
| // working in every framework and must not be caught here. | ||
| if (detected && hasEmailHandlers(scanEmailHandlersSync(paths.sourceRoot))) { |
There was a problem hiding this comment.
Reject inbound handlers on non-Cloudflare targets
When void.json selects target: "node", "bun", or "deno", detected is normally undefined, so this new guard does not reject an email/ directory. The routing scan still generates __emailHandler, but compileNodeApp imports only the Hono app and prerender helper and the generated server exposes HTTP fetch only, leaving every inbound handler unreachable after a clean build. Extend the guard to non-CF targets or provide an inbound transport for them.
AGENTS.md reference: packages/void/AGENTS.md:L17-L17
Useful? React with 👍 / 👎.
…14.1 (#160) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
…account setup (#316) `void/email` — the framework half, the platform half, and own-account setup — in one PR, now that the platform lives under `platform/`. Supersedes #160 (framework, 22 review threads, all resolved) and voidzero-dev/void-platform#117 (platform, 6 rounds). Both are closed with a pointer here; their review history stays readable there. The platform commits were carried over with `git merge -X subtree=platform/`, so their history is intact under `platform/`. ## What it adds ``` developer app Void platform (Mode A) own Cloudflare account (Mode B) ───────────── ────────────────────── ────────────────────────────── email/support+[ticket].ts ──┐ proxy: /email/send wrangler.jsonc: email/_default.ts ──┤ allowlist · quotas · suspension send_email: [{ name: "SEND_EMAIL" }] scan + compile ────────┤ account send_email binding addresses: ["support@mail.acme.com"] export default class ───────┘ api: destinations, HMAC confirm, vars.__VOID_EMAIL_FROM extends WorkerEntrypoint admin /admin/email/:projectId runtime: env.SEND_EMAIL direct async email(msg) {…} dispatch: inbound → email-router → worker inbound: rules → this worker sendEmail() from void/email one Enter on the first deploy ``` 1. **Framework** (`packages/void`): file-based inbound handlers, `sendEmail`, dev inbox at `/__void/email`, `void email` CLI, `void/email/testing` harness, the `WorkerEntrypoint` class entry. Unchanged from #160 — see its body for the scope table, the class conversion, and the double-strip bug the re-land found. 2. **Platform** (`platform/packages/{proxy,api,dispatch}`): outbound through the proxy with allowlist, burst + monthly quota and two kill switches; inbound router with slug lookup, suspension and the reply-sender pin; per-project destination consent; `EMAIL_SENDER_DOMAIN` as configuration (fails closed); migration `0033`; admin page. Unchanged from #117. 3. **Own Cloudflare account — new in this PR.** `void deploy --platform cloudflare` detects email in use, runs a read-only preflight through the developer's `wrangler login` session, prints a checklist, and asks once: ``` ◆ Set up email on mail.acme.com? ● Yes / ○ No ⏎ ``` Then it enables Email Routing (apex through `wrangler email routing enable`; a subdomain through one REST call with the session bearer from `wrangler auth token --json`), turns on the zone's subaddressing setting, onboards Email Sending, writes `send_email` + `addresses` + `vars.__VOID_EMAIL_FROM` into the root `wrangler.jsonc`, and lets `wrangler deploy` (≥ 4.113) create the routing rules and catch-all from `addresses`. Second deploy: zero keystrokes. CI: `void email setup --platform cloudflare` once, commit, `--require-email` to fail instead of degrade. Runtime: a direct `env.SEND_EMAIL` transport, `email.from` in `void.json` as the default sender, no-prefix inbound dispatch, and handler actions enacted on the native message. 4. **wrangler 4.107 → 4.131, `@cloudflare/vite-plugin` 1.43 → 1.54.7** (default catalog only; `catalogs.platform` untouched). Needed for `addresses` reconciliation on deploy. Miniflare 5 changed the prerender options — `void:prerender` now routes through `convertV4MiniflareOptions`. `@cloudflare/workers-types` stays on 4.x (5.x declares `Buffer`/`process` globally and collides with `@types/node`; comment in `pnpm-workspace.yaml`). A `wrangler-pin` test asserts ≥ 4.113 and the two capabilities the feature relies on. ## What Void never does - Never stores, refreshes or forwards a Cloudflare token. The session bearer is borrowed for two REST calls inside one closure, with `WRANGLER_WRITE_LOGS=false` on the `auth token` child (wrangler otherwise writes the token into `~/.wrangler/logs`). - Never enables routing over live mail: an apex with foreign MX is refused; the subdomain path never touches the apex. - Never lets a build redirect inbound mail: `addresses` is pinned whole-object in the deploy verifier. - Never makes wrangler's plan destructive: addresses routed elsewhere are pruned and reported. ## Design `platform/meta/design-docs/0078-email-cf-native.md` — architecture, the three hosting modes and their lifecycle, the one-keystroke flow with every branch, custom domains on Cloudflare (designed, not built), open decisions and the probes that gate them. ## Before merge — operator and owner items - **Two Cloudflare writes are unexecuted** (0078 "Still unverified" 1–2): `POST /zones/{z}/email/routing/dns { name }` for a subdomain and `PATCH /zones/{z}/email/routing { support_subaddress: true }` with the OAuth session. The code treats any refusal as "print the dashboard step, withhold `addresses`". One probe on a throwaway subdomain of a staging zone settles both. - **Product calls handed back by the review round** (each has a recommended option in 0078): Workers Free never reaches "ready" because the sending row stays pending (persist the not-entitled outcome, or stop gating on it); whether `--require-email` should pass when the worker already ships the binding; whether a deleted handler's stale `addresses` entry counts as foreign (decision 13) or Void-managed. - **Mode A zone setup** is still unprovisioned (`platform/meta/operations.md` "Email"): `mail.void.cloud` / `mail.staging.void.cloud` routing subdomain, Email Sending onboarding, the catch-all rule. - The inbound test harness still strips a platform slug before the first `+`; an own-account address needs the `<anything>+` prefix in tests (documented; a `slug: false` option is a follow-up). ## Docs `docs/guide/email.md` ("Your own Cloudflare account"), `docs/integrations/cloudflare.md`, `docs/reference/{cli,config}.md`, `packages/void/AGENT_PROMPT.md`, `getting-started-prompt.txt`; platform runbook in `platform/meta/operations.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SStP281NuirMSouULEE2pW <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Changes Cloudflare Email Routing/DNS setup, deploy trigger snapshots, and outbound/inbound mail paths—mistakes in `addresses` handling or setup flows can break routing or delete rules on rollback. > > **Overview** > Adds **`void/email`** as a documented platform capability: a new **Email** guide (`docs/guide/email.md`), sidebar entry, and updates to Cloudflare, Node/Bun/Deno, CLI, and `void.json` config references. > > **Own-account Cloudflare deploy** gains an email preflight (checklist + one-time prompt), `void email setup|status|usage|logs|destinations|allow|disallow`, deploy flag **`--require-email`**, and `email.from` / `inference.email` configuration. **`@void/deploy-cloudflare`** now snapshots and replays Wrangler **`addresses`** in version trigger config so Email Routing plans sync on deploy/rollback without treating “missing” like **`[]`** (which would delete rules). > > **`packages/void`** exposes `void/email` and `void/email/testing`, adds CLI wiring and platform API client methods for destinations/usage/logs, bumps **wrangler** / **@cloudflare/vite-plugin**, and documents dev-inbox token security and selective `send_email` stripping in **`AGENTS.md`**. Project creation can surface owner recipient registration; Cloudflare backend detection now treats **`email/`** as backend surface area. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8a243a4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Framework half of
void/email. Pairs with voidzero-dev/void-platform#117 — merge this one first, then the platform PR re-points the submodule at the merge SHA.This branch is a re-land, not a rebase. The original work sat for four months. It was re-applied file by file onto current
main, so the diff is against today's code.What it adds
email/support+[ticket].tsmatchessupport+anything@,email/_default.tscatches the rest. Same scanningconventions as
routes/, includingsrc/layouts.sendEmail()fromvoid/email, which POSTs MIME to the platform proxy.The account-level send binding never reaches the user worker.
/__void/emailcaptures sends and lets youinject inbound messages without a real MTA.
void emailCLI for managing verified recipients.void/email/testing.Scope boundary
TanStack Start, React Router
SvelteKit, Nuxt, Analog, Astro
sendEmailin production/__void/emailassetsbindingBINDING_MISSINGemail/inbound handlersInbound compiles through
routingPlugin, and no framework branch installs it —the framework wrapper wires crons and queues only. Rather than let those
projects ship dead handlers and bounce real mail, an
email/directory under adetected framework now throws at build time, next to the existing auth /
sandbox / live guards. Wiring email into the framework entries is a separate
piece of work: it needs the
WorkerEntrypointconversion below applied to eachframework wrapper.
The entry point is now a class
email()cannot be reached through a Workers-for-Platforms dispatch stub on aplain object export — only
fetch()is. The generated worker entry is thereforea
WorkerEntrypointsubclass:This is the riskiest file in the PR.
compileWorkerEntryhad grown from 8 to 11parameters while the branch sat, and the branch's own conversion to an options
object was built from the OLD list — applying it as written would have silently
dropped every interpolation
mainadded (ISR deployment stamp, live reload,notFoundhandling, remote bindings, the sandbox export). The option type wasrebuilt from
main's current signature instead, and the generated output wasdiffed to prove each interpolation survived.
emailDirwent on the existingoptions object rather than into a new positional slot — inserting it at slot 6
of a 9-argument call would have shifted every later argument across ~68 call
sites, and a same-typed shift binds silently.
The bug this re-land found
The platform delivers to
<slug>+<tag>@<zone>and the slug is stripped sohandler authors see the address they wrote. Two places were stripping it:
Both halves were individually correct and individually tested. The seam was not.
The dispatcher now routes on the preserved wire recipient (
__voidPlatformTo)and leaves
message.tostripped for the handler.Matching also no longer lowercases the address before applying the pattern —
that lowercased the captured groups too, so
support+T-42@reached thehandler as
t-42. The pattern carries theiflag instead.The codegen tests all passed while the dispatcher could not route a single real
message, because they searched the generated source for substrings. They now run
it: stub the dynamic imports, feed it a message built by the real proxy, and
assert which handler ran.
The mail zone is configuration
The original hard-coded
mail.void.cloudin the runtime. It is now__VOID_EMAIL_DOMAIN, injected by the platform, andsendEmail()throws ratherthan guessing when it is absent and
fromwas omitted.Review rounds
Five rounds of adversarial review landed on top of the re-land. The substantive
outcomes, one line each:
Deploy —
email/was invisible to every decision that asks "does thisproject need a worker", so an email-only project took the static-site path and
uploaded no worker at all. Fixed in
findBackendSources,hasBackendFilesandthe fully-static optimization. The manifest
ssrflag is deliberately untouched:dispatch reads it to classify the request type for metering, not to decide
whether a worker is required.
MIME — the writer now enforces the RFC 5322 §2.1.1 998-octet line limit on
every rendered line (part headers live inside the body), folds address lists at
comma boundaries rather than at any whitespace, chunks RFC 2047 encoded-words by
code point so no multi-octet character is split across words, escapes backslash
and quote in
filename=parameters, and rejects a header value that cannot befolded legally instead of encoding it where §5 forbids encoded-words.
Threading —
replyEmaildrops an inboundMessage-IDthat would not fit alegal header line rather than emitting an over-long
In-Reply-To, so a hostilesender cannot deny a reply.
Harness —
deliver()parses headers back out of the wire bytes it hands thehandler, so a generated
Message-IDreachesreplyEmailand theraw:overrideis byte-faithful. The unmatched-recipient reject text is byte-identical with the
production dispatcher, and local-part matching is case-preserving on both sides.
Client safety —
void/emailandvoid/email/testingare server-only stubsin any client environment, and both throw rather than returning
{}. Withoutthe stub the client build succeeded with a warning and shipped
new (emptyStub).AsyncLocalStorage, which white-screens the page.Two items were handed back rather than settled here: threading email into the
framework worker entries, and canonicalising recipient local-part case, which is
cross-repo and gated on unverified Cloudflare verified-destination behavior.
Also
email/added to the source conventions, sosrc/email/worksruntime/email.tsandruntime/email/testing.tsre-homed intovite.config.ts(the branch's
tsdown.config.tsno longer exists), withcloudflare:emailinneverBundledocs/guide/email.md, plus CLI and config reference updatesvoid deploy --backend cloudflare,sendEmailreturnsBINDING_MISSINGandemail/handlers are not routed(email guide, Cloudflare integration scope list, agent prompt, getting-started
prompt). Own-account setup is designed as a one-prompt follow-up, not in this PR.
playground/kitchen-sink/email/🤖 Generated with Claude Code
https://claude.ai/code/session_01SStP281NuirMSouULEE2pW