Skip to content

feat(email): inbound routing, sendEmail, dev inbox and test harness - #160

Closed
TheAlexLichter wants to merge 8 commits into
mainfrom
feat/email
Closed

TheAlexLichter wants to merge 8 commits into
mainfrom
feat/email

Conversation

@TheAlexLichter

@TheAlexLichter TheAlexLichter commented Apr 25, 2026 •

Copy link
Copy Markdown
Member

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/                        void/email
  ──────                        ──────────
  support+[ticket].ts  ──┐      sendEmail({ to, subject, ... })
  _default.ts          ──┤          │
                         │          └─▶ POST __VOID_PROXY/email/send
     scan + compile ─────┤
                         │      void/email/testing
  export default class ──┘          createTestMessage(), collectEmails()
    extends WorkerEntrypoint {
      async email(msg) { ... }    dev: an inbox UI at /__void/email
    }
  • File-based inbound routing. email/support+[ticket].ts matches
    support+anything@, email/_default.ts catches the rest. Same scanning
    conventions as routes/, including src/ layouts.
  • sendEmail() from void/email, which POSTs MIME to the platform proxy.
    The account-level send binding never reaches the user worker.
  • Dev inbox. A local inbox at /__void/email captures sends and lets you
    inject inbound messages without a real MTA.
  • void email CLI for managing verified recipients.
  • Test harness at void/email/testing.

Scope boundary

Void app Class A framework
TanStack Start, React Router
Class B/C framework
SvelteKit, Nuxt, Analog, Astro
sendEmail in production yes yes yes
Dev inbox at /__void/email yes yes, once the app declares an assets binding no — BINDING_MISSING
email/ inbound handlers yes build error build error

Inbound 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 a
detected 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 WorkerEntrypoint conversion below applied to each
framework wrapper.

The entry point is now a class

email() cannot be reached through a Workers-for-Platforms dispatch stub on a
plain object export — only fetch() is. The generated worker entry is therefore
a WorkerEntrypoint subclass:

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) }
  async queue(batch)        { ... }
  async email(message)      { ... }
}

This is the riskiest file in the PR. compileWorkerEntry had grown from 8 to 11
parameters 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 main added (ISR deployment stamp, live reload,
notFound handling, remote bindings, the sandbox export). The option type was
rebuilt from main's current signature instead, and the generated output was
diffed to prove each interpolation survived. emailDir went on the existing
options 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 so
handler authors see the address they wrote. Two places were stripping it:

  wire:      acme+T-42@mail.void.cloud
  proxy:     createMessageProxy  strips "acme+"  →  T-42@...     (message.to)
  generated: dispatcher          strips "T-42+"  →  ""           ← no pattern matches

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.to stripped 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 the
handler as t-42. The pattern carries the i flag 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.cloud in the runtime. It is now
__VOID_EMAIL_DOMAIN, injected by the platform, and sendEmail() throws rather
than guessing when it is absent and from was 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 this
project need a worker", so an email-only project took the static-site path and
uploaded no worker at all. Fixed in findBackendSources, hasBackendFiles and
the fully-static optimization. The manifest ssr flag 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 be
folded legally instead of encoding it where §5 forbids encoded-words.

Threading — replyEmail drops an inbound Message-ID that would not fit a
legal header line rather than emitting an over-long In-Reply-To, so a hostile
sender cannot deny a reply.

Harness — deliver() parses headers back out of the wire bytes it hands the
handler, so a generated Message-ID reaches replyEmail and the raw: override
is 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/email and void/email/testing are server-only stubs
in any client environment, and both throw rather than returning {}. Without
the 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, so src/email/ works
  • runtime/email.ts and runtime/email/testing.ts re-homed into vite.config.ts
    (the branch's tsdown.config.ts no longer exists), with cloudflare:email in
    neverBundle
  • Docs: docs/guide/email.md, plus CLI and config reference updates
  • Docs say email is platform-only today: on void deploy --backend cloudflare,
    sendEmail returns BINDING_MISSING and email/ 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 handlers in playground/kitchen-sink/email/

🤖 Generated with Claude Code

https://claude.ai/code/session_01SStP281NuirMSouULEE2pW

@TheAlexLichter
TheAlexLichter marked this pull request as draft April 25, 2026 17:38
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
@Brooooooklyn Brooooooklyn changed the title feat: cf email support feat(email): inbound routing, sendEmail, dev inbox and test harness Aug 30, 2026
@Brooooooklyn
Brooooooklyn marked this pull request as ready for review August 30, 2026 15:42
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 30, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-11T09:44:02.067030Z 3b90aeb New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/void/src/plugin-email-dev-inbox.ts
Comment thread packages/void/src/runtime/email/testing.ts Outdated
Comment thread docs/guide/email.md Outdated
Comment thread docs/guide/email.md Outdated
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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/void/src/index.ts
Comment thread packages/void/src/runtime/email/mime.ts Outdated
Comment thread packages/void/src/runtime/email/inbound.ts Outdated
Comment thread packages/void/src/runtime/email/testing.ts
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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/void/src/runtime/email.ts
Comment thread packages/void/src/runtime/email/mime.ts Outdated
Comment thread docs/guide/email.md Outdated
Comment thread packages/void/src/cli/help.ts Outdated
…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
@socket-security

socket-security Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedpostal-mime@​2.7.610010010092100

View full report

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/void/package.json
Comment thread packages/void/src/runtime/email/mime.ts
Comment thread packages/void/src/runtime/email/mime.ts Outdated
Comment thread packages/void/getting-started-prompt.txt Outdated
… 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/void/src/router/scan.ts
Comment thread packages/void/src/runtime/email/testing.ts Outdated
Comment thread docs/guide/email.md Outdated
…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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/void/src/router/compile.ts
Comment thread packages/void/src/runtime/email/inbound.ts Outdated
Comment thread packages/void/AGENT_PROMPT.md Outdated
…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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +64 to +68
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.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

cpojer pushed a commit that referenced this pull request Sep 11, 2026
…14.1 (#160)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
@Brooooooklyn

Copy link
Copy Markdown
Member

Superseded by #316 — the platform repo was merged into this one, so the framework half, the platform half (under platform/) and the new own-account setup ship as one PR. The 22 review threads here stay as the record; #316 carries every fix they produced.

@TheAlexLichter
TheAlexLichter deleted the feat/email branch September 12, 2026 07:07
Brooooooklyn added a commit that referenced this pull request Sep 16, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants