Skip to content

feat(email): void/email — outbound, inbound, platform proxy, and own-account setup - #316

Merged
Brooooooklyn merged 135 commits into
mainfrom
feat/email-mono
Sep 16, 2026
Merged

Brooooooklyn merged 135 commits into
mainfrom
feat/email-mono

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Sep 11, 2026 •

Copy link
Copy Markdown
Member

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 feat(email): inbound routing, sendEmail, dev inbox and test harness #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 build: less flaky integration tests #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.ai/code/session_01SStP281NuirMSouULEE2pW


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.

Reviewed by Cursor Bugbot for commit 8a243a4. Bugbot is set up for automated code reviews on this repo. Configure here.

Brooooooklyn and others added 30 commits August 30, 2026 23:27
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
Re-lands the abandoned feat/email branch onto current main. Re-applied
file by file rather than rebased.

Outbound never touches the user worker: the account-level send_email
binding lives only on proxy, which enforces the per-project verified
recipient allowlist, both suspension switches, and burst plus monthly
quotas. Inbound arrives at <slug>+<tag>@<zone>, resolves through the same
ROUTING KV the HTTP dispatcher uses, and dispatches into the tenant
worker under that project's CPU limits.

A recipient another project already verified with Cloudflare does not
inherit that consent. The row inserts pending and we send our own
HMAC-signed confirmation link, unless the address is the project owner's
own. verification_source records which path it took.

Two decisions changed during the re-land:

- The mail zone is EMAIL_SENDER_DOMAIN, declared on api and proxy and
  injected into user workers as __VOID_EMAIL_DOMAIN. The original
  hard-coded mail.void.cloud in three places including a sender regex,
  which made staging impossible. Every reader fails closed when absent.
- One slug binding. main already injected __VOID_PROJECT_SLUG for sandbox
  projects, so provisionBindings now injects it for every project.

Drift the re-land had to absorb: the branch migrations sort before main's
head so the migrator would have skipped them (regenerated as 0033); the
branch reserved-binding list dropped three names main reserves;
cleanupProjectResources no longer exists, so destination cleanup is
re-homed into teardownProject before deleteProjectCascade or the CF
destination leaks with no row left to find it from; send_email was
missing from main's new env.production scope; noreply was not reserved.

Security fixes from an adversarial review, each re-verified against the
control flow first:

- An inbound reply action could name any sender on the platform zone.
  The reply sender is pinned to the slug that received the message, with
  the domain taken from the received recipient rather than from config,
  and the pin runs before quota so a dropped reply costs nothing.
- Both revoke paths deleted the row and then best-effort re-synced the KV
  allowlist, so a KV failure stranded a revoked address in the allowlist
  with no row left to re-derive it from. The mirror is now tightened
  first, excluding the row being revoked, and a KV failure returns 503
  with the row intact.
- A malformed routing blob read as not suspended. Outbound fails closed
  with a 502; inbound rejects.
- Destination POST and DELETE return 409 once the project is deleting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
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
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
…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
… 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
…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
…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
New design doc `0079-email-lifecycle-and-ux.md`. 0078 records the
architecture that ships in this PR; 0079 records what was established
after it, on 2026-09-10 and 2026-09-11, about how the feature is lived
with — by a developer, by an operator, and in each of the three places a
Void project can run: the managed platform, `void deploy --backend
cloudflare`, and a self-hosted platform.

Three findings drive it, each verified at source or by execution:

- A `wrangler login` session already carries `email_routing (write)` and
  `email_sending (write)`, and wrangler 4.107+ ships `wrangler email`
  with `rules create --action-type worker`. The own-account mode needs
  no new token and no API client for most of the setup; Void drives
  wrangler through the `runWrangler` seam it already uses. Two residuals
  keep a human step: the catch-all has no wrangler command, and routing
  on a subdomain is documented for the dashboard only.
- Verified destinations are capped at 200 per Cloudflare account, shared
  across every domain and project. Onboarding the mail domain to Email
  Sending lifts it; per-project consent stays in our allowlist.
- No surface tells a developer their own address today — not the CLI,
  the dashboard, or the API — and the two bindings are neither typed nor
  injected in `void dev`, so `sendEmail` without `from` returns
  `INVALID_FROM` in dev while the shipped prompt promises the default.

The doc fixes one rule across all modes — a developer never sees a
Cloudflare object — and specifies the surfaces per persona, the deploy-time
detection and prompt for the own-account mode with three refusals as code,
the three code sites that pin VoidZero hosts for a self-host, and five
open decisions with recommendations.

`operations.md` is corrected where the evidence contradicted it: the mail
domain is a subdomain of the existing `void.cloud` zone, which already has
Email Routing on, so there is no registration and no nameserver change;
Email Sending onboarding is a required step, not an option; the slug
mirror self-heals hourly, so there is no one-time backfill. Neither
`mail.void.cloud` nor `mail.staging.void.cloud` has any DNS record as of
2026-09-11, recorded so the state is not assumed.

0078 gains a pointer and inline corrections for the two claims Email
Sending made stale. `progress.md` points at 0079.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
0079 was written yesterday as a companion to 0078 with its own "what 0078
got wrong" list. Two documents for one feature, one correcting the other,
is the wrong shape. This folds 0079 into 0078 and deletes it.

Where the content went:

- "Hosting modes and lifecycle" is a new top-level section after
  "Platform infrastructure": the three modes, the one rule, the lifetime
  diagram, personas, and Mode A / B / C in full.
- "Known gaps", "Open decisions" and "Open questions" are new top-level
  sections after "Rollout", which also gains the post-merge phases.
- The Class B/C boundary sits in the Inbound section; the compatibility
  date evidence sits under "Why the user worker must be a
  WorkerEntrypoint"; the verified-destination regimes replace the stale
  "Cloudflare's Constraints" body.
- The corrections are now the text, each marked *(corrected 2026-09-11)*:
  the mail domain is a subdomain of `void.cloud`; Email Sending lifts the
  200-destination cap; routing enable adds MX, SPF and DKIM and DMARC
  comes from sending; the allowlist is enforced in the proxy from a KV
  mirror, not as a binding restriction on the user worker; the MIME
  builder is our own, not `mimetext`; the two non-goals that the new
  modes qualify.
- The evidence list is appended to References.

`progress.md` now points at the section instead of the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
"Why custom domains aren't included" becomes "Custom domains on
Cloudflare". A project can send as and receive at its own domain when
that domain's zone is on Cloudflare DNS. The zone stays in the customer's
account; the customer grants one scoped token; the platform configures
the zone and runs both directions.

Of the three models 0078 rejected, two are still rejected for the same
reasons and one is now taken. NS delegation would make us own every
record the customer has. Hosting the subdomain as a zone in our account
is Enterprise-only, verified against Cloudflare's subdomain-setup page.
The token model was rejected on the "users never touch Cloudflare"
premise; that premise is kept for the shared sender and dropped for a
domain the customer already owns on Cloudflare — the token is the same
trust they give `wrangler login`, and the developer still never sees a
zone, a rule or a scope after the one-time grant.

The mechanics were checked against the docs and the OpenAPI schema
before writing them down:

- A routing rule cannot name a worker in another account (the action
  value is a bare name, unique per account), so inbound crosses accounts
  as a forward to one verified bridge address per project domain,
  `<slug>+inbound@<shared domain>`, and the router recovers the original
  recipient from the `To:` header. BCC is the documented loss.
- Outbound crosses back as `POST /accounts/{customer}/email/sending/
  send_raw` with the customer's token, so the domain signs its own DKIM
  and carries its own reputation — the fit problem the shared sender
  could never solve.
- The apex is used only when its MX is empty, read over public DNS with
  no token; otherwise a subdomain, whose routing step is dashboard-only.

The flow mirrors `void domain add` (pending → active, printed steps,
status polling), the token lives under `encryptProjectSecret`, and the
scopes are listed at their minimum. Two decisions are added: whether our
allowlist still gates custom-domain sends (recommended no), and whether
the bridge verification is auto-completed or surfaced (surface first).
Four probes on a test zone gate the build.

Non-goals, the constraints section, the reputation paragraph, Mode C,
Future Work, the rollout and references are updated to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
…mplate link

The token was the only customer-side step described as "paste a token
with these scopes". Cloudflare's dashboard accepts a template URL that
pre-fills name, permission groups and resources, so the CLI (and the
dashboard Email section) open that link and take a paste: Continue to
summary → Create Token → Copy. Documents the link builder, the
permission-group keys beyond the documented table (with their evidence
and the one single-source key), why no OAuth/callback/API path exists
today, probes 5–7, and decision 8 (ask Cloudflare for an OAuth client).
Adds the missing `Zone Read` scope the zone lookup needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
…l copy

After Cloudflare's Copy button the CLI reads the clipboard (mirror of the
existing `void auth token` write side), verifies shape + /user/tokens/verify,
clears the clipboard, and falls back to the masked paste prompt. Records
where the token lives: platform only (encryptProjectSecret); no keychain
copy on the developer's machine because nothing there reads it. Probe 8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
…es_on on the row

Five-party sequence (developer, CLI, dashboard, Cloudflare API, platform)
with the clipboard capture, the verify call, the hand-off, the zone lookup
and scope probes, and every branch the developer can hit. Stores
`token_id` and `token_expires_on` from /user/tokens/verify so `status` can
name the token to revoke and warn before a TTL runs out; adds
`token_expired` to the row status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
…at it costs

Four things "a Cloudflare app" can mean; only an OAuth client removes the
token step (one Allow click, web or CLI callback). Records breadth (a grant
is user-wide, a template token can be one zone), refresh handling, the
scopes Cloudflare's consent catalogue already has, why wrangler's client id
is not a basis for a product, and the one-line ask. Decision 8 links here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
…search

Replaces the Mode B detection/fix/residuals/token-blast-radius design with
what 16 read-only research agents measured: `wrangler auth token --json`
is the sanctioned session hand-off (keyring is opt-in, default plaintext);
wrangler >= 4.113 reconciles Email Routing rules and the catch-all from a
top-level `addresses` array on `wrangler deploy`; subdomain routing is one
REST POST (unexecuted); subaddressing is a per-zone setting that is OFF by
default, which breaks the "support@ captures support+tag@" premise. Records
the lanes, the refusals, the seven human steps, the build list, eight
unverified items, and decisions 9-13. Corrects the custom-domain bullet
that said wrangler's token is unreadable, and two open questions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
`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
…sons

progress: a Mode B item with its three prerequisites in order; custom
domains now "eight probes", token via the template link. operations: the
catch-all cannot come from wrangler's `addresses` either (apex-only), and
the zone's subaddressing setting is not to be touched — the catch-all
covers it. lessons: two patterns from this round (a provider setting is
not a default; list a tool's commands before claiming it cannot do X).
0078 rollout step 17 now names the bump → probes → feature order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
…54.7

Prerequisite for Mode B email on `void deploy --backend cloudflare`
(design doc 0078). Two wrangler capabilities the feature relies on:

- wrangler >= 4.113 makes `wrangler deploy` reconcile Email Routing rules
  from a top-level `addresses` array (`applyEmailRoutingAddresses`,
  "Email Routing plan:").
- `wrangler auth token --json` (stable since 4.57) lends the login
  session's bearer for the two REST-only steps.

Pinned pair: @cloudflare/vite-plugin 1.54.7 depends on exactly
wrangler 4.131.0, so one wrangler exists in the tree.

What the bump changed, verified against the 4.131 bundle and live
read-only commands (whoami/d1/kv/r2/queues/hyperdrive list + info):

- Every flag the deploy path spawns still exists: `deploy --env=
  --experimental-provision=false --experimental-auto-create=false
  --autoconfig=false` (the two experimental flags are hidden but
  registered in globalFlags/deployCommand), `d1 list|info --json`,
  `kv namespace list`, `r2 bucket info --json`, `queues info`,
  `secret list --format=json`, `d1 migrations apply|list --remote`,
  the `--binding --update-config` creates.
- Every parsed `--json` shape and prose signal is unchanged: d1
  `{uuid,name}`, kv `{id,title}`, r2 `{name,...}` / `[code: 10006]`,
  `Queue "<name>" does not exist`, `Queue Name:`, the secret-list
  worker-not-found block, "No migrations to apply!" /
  "Migrations to be applied:". The mirrored wrangler functions
  (getCloudflareApiBaseUrl, getDefaultEnvFiles, compareMigrationPaths,
  getDatabaseInfoFromConfig, friendlyBindingNames, dotenv LINE, ...)
  diff clean against 4.107; comments now cite 4.131.
- Config schema: `legacy_env` was removed (a stale dist/ssr/wrangler.json
  from an older build is rejected until rebuilt), `addresses`, `access`,
  `connect`, `dependencies_instrumentation` were added; no binding field
  changed shape.
- miniflare 5 (bundled with wrangler 4.131) dropped the flat
  single-worker constructor options and the per-product
  `d1Persist`/`kvPersist`/`r2Persist`. The prerender runner now builds
  its v4-shaped options through miniflare's own
  `convertV4MiniflareOptions` (as @cloudflare/vite-plugin 1.54 does) and
  persists under one `resourcePersistencePath`.

@cloudflare/workers-types stays on ^4.20260702.1: wrangler's ^5 peer is
optional, and every 5.x since 5.20260807.2 declares global
`Buffer`/`process` as `any`, which collides with @types/node in
packages/void's `types` (Buffer loses its members).

New test `wrangler-pin.test.ts` resolves wrangler the way preset.ts does
and asserts version >= 4.113.0 plus the `Email Routing plan:` and
`authTokenCommand` markers in the bundled cli.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
`email.from` is the default sender for `sendEmail()` and, on
`void deploy --backend cloudflare`, its host is the domain the CLI sets
Email Routing and Email Sending up on. It is validated with the runtime's
own address parser (`parseAddress`, now exported from mime.ts), so the
config accepts exactly what `sendEmail()` accepts — one address, optional
display name, nothing that splits into two recipients or smuggles a
header. `getEmailFromHost()` hands the lowercased host to the CLI.

`mergeBindings` now strips any root-wrangler `send_email` entry under
`serve`, so the dev inbox stays the only sink: the Cloudflare deploy
writes `send_email: [{ name: "SEND_EMAIL" }]` into the root wrangler.jsonc,
and under miniflare that binding would write .eml files or, with
`remote: true`, send real mail. Build passes the entry through untouched.
It also injects `vars.__VOID_EMAIL_FROM` from `email.from` in dev only —
on a build the root wrangler.jsonc already carries the same var, written
by the CLI once the domain is ready, so injecting it unconditionally would
advertise a sender on an account nothing was set up on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
Mode B (the developer's own Cloudflare account, design doc 0078) needs
three root-config writes that no `wrangler <res> create --update-config`
makes: the `send_email: [{ name: "SEND_EMAIL" }]` binding, the top-level
`addresses` array wrangler >= 4.113 reconciles Email Routing rules from,
and the `__VOID_EMAIL_FROM` var. Route all three through the one
comment-preserving jsonc writer so every edit keeps the file's comments
and stays byte-identical on re-run.

- `sendEmail` reuses the adopt upsert, now keyed by an identity property
  (`name` for send_email, which has no `.binding`): a matched entry is
  left alone (its destination restrictions are user-owned), a missing one
  is appended, a duplicate or cross-type collision fails closed.
- `addresses` is a whole-array write because Void owns the array in Mode
  B: `string[]` sets it (skipped when equal), `null` deletes the key — an
  empty array would delete every rule wrangler owns. Deletion uses a new
  `removeMember` splice instead of jsonc-parser's `modify(undefined)`,
  which drops the previous sibling's trailing comment, a full-line comment
  above the member and the member's own trailing comment (measured).
- `vars` upserts only the named keys, failing closed when a key is already
  another binding's name or when `vars` is not an object.
- The binding-shape registry gains a `whole-object` kind for `addresses`
  (not a binding, absent from FRIENDLY_BINDING_NAMES; no names, no
  entries) so nothing ever reads it as a namespace-array. The deploy
  verifier's whole-object pin lands separately in deploy.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
…lt, no-prefix inbound, native actions

Runtime items 1, 3, 4 and 5 of design-doc 0078 "Mode B" (a deploy to the
developer's own Cloudflare account, where there is no platform proxy and
no platform router).

- sendEmail tries `env.SEND_EMAIL` after the dev inbox and before
  `__VOID_PROXY`: the shared MIME goes through `cloudflare:email` as one
  `EmailMessage` per unique recipient, every recipient attempted, and the
  per-recipient `deliveries` shape is the proxy's. Binding errors map by
  Cloudflare's `code` table: E_RECIPIENT_NOT_ALLOWED -> UNVERIFIED_DESTINATION,
  E_SENDER_NOT_VERIFIED / E_SENDER_DOMAIN_NOT_AVAILABLE -> INVALID_FROM,
  E_RATE_LIMIT_EXCEEDED / E_DAILY_LIMIT_EXCEEDED -> QUOTA_EXCEEDED, else
  UPSTREAM_ERROR with `cause`; the legacy message-only "destination address
  not verified" phrasing the proxy keys on is kept as a fallback.
- An omitted `from` now defaults to `__VOID_EMAIL_FROM` (void.json
  `email.from`, written into the worker vars by the deploy) before the
  platform's `<slug>+noreply@<domain>`; INVALID_FROM when neither exists,
  as before. `replyEmail` defaults its sender to `message.to` when there is
  no platform slug; with a slug the platform pin is unchanged and a
  missing domain still throws.
- The generated dispatcher strips the `<slug>+` prefix only when
  `createMessageProxy` recorded one (`__voidPlatformTo`). A recipient that
  never carried it is matched on its full local part, so
  `support+T-42@zone` reaches `email/support+[ticket].ts` with
  `params.ticket = 'T-42'` instead of `_default`.
- The generated `handleEmail` detects a real `ForwardableEmailMessage`
  (functions do not survive RPC) and replays the recorded actions on it —
  setReject, forward with a Headers instance, reply as a genuine
  `EmailMessage` — before returning them; the platform path still returns
  them as data. Without this Cloudflare accepted the message and dropped
  every action.

Two existing tests encoded the old behaviour and were changed: the
dispatcher case for an unprefixed recipient now expects the matching
handler rather than `_default`, and the reply-sender case with no slug
now expects `message.to` rather than a throw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
… bearer bridge

Add `cli/email-cloudflare.ts`, the own-account (`void deploy --backend
cloudflare`) email unit from design doc 0078 "Mode B":

- `deriveAddresses` turns the `email/` scan into wrangler's top-level
  `addresses`: literal and literal+[tag] handlers → `<literal>@<domain>`;
  `_default.ts` and dynamic local parts → `*@<apex>` on the apex only,
  refused with a message on a subdomain (a catch-all exists only on an apex).
- `preflightEmail` is read-only: public DNS (MX on the domain and apex,
  `_dmarc.` / `cf-bounce.` TXT), `whoami --json` for the two email scopes,
  a label walk through `email routing settings` (the same walk wrangler's
  own `resolveDomain` does), `email routing rules list` for mine / foreign /
  absent per derived address, `email sending settings`, and — only when the
  session bearer can be borrowed — `GET /zones?name=` without `account.id`
  to tell another account from not-on-Cloudflare and refuse partial zones,
  plus `GET /zones/{z}/email/routing` for `support_subaddress`, which the
  settings prose does not print. Every blocker from the 0078 refusal table
  is a row; a foreign-routed address is pruned so wrangler's plan is never
  destructive (decision 12); a hand-written `addresses` entry Void did not
  derive skips the email step (decision 13).
- `renderEmailChecklist` prints the 0078 transcript block; `renderAddressMap`
  the post-deploy block.
- `applyEmailSetup` runs only the rows not ready, in order: apex → `email
  routing enable`; subdomain → bearer `POST …/email/routing/dns`; then the
  bearer `PATCH …/email/routing { support_subaddress: true }`; then `email
  sending enable` (`[code: 2040]` = already onboarded; any other failure is
  the Workers Free hint and inbound continues). Any bearer failure or
  non-2xx prints the dashboard fallback line and withholds `addresses` for
  the run — the key is deleted, never `[]` (decision 11). Returns the
  `sendEmail` / `addresses` / `vars.__VOID_EMAIL_FROM` patch fields.
- `fetchWithSessionBearer` is the only function that receives the token: it
  goes in the Authorization header and nowhere else, and any error message
  that echoes it is scrubbed before it is returned.

The prose parsers are pinned to wrangler 4.131.0 by verbatim read-only
captures under `test/fixtures/wrangler-email/4.131.0/` (personal
identifiers replaced); a parser miss fails closed as "unknown — run
`void email setup`". The `Tag:` of `email routing settings` is used as the
zone id (measured equal on a real zone), with `GET /zones?name=` preferred
whenever the bearer is in hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
Replace the "not on --backend cloudflare yet" warnings with the shipped
Mode B flow from design doc 0078: `email.from` in void.json, the read-only
preflight and checklist the first deploy prints, the one Enter, the
subdomain-vs-apex rule (never route over live mail), subaddressing for the
zone, the `send_email` binding / `__VOID_EMAIL_FROM` var / Void-owned
`addresses` array written into wrangler.jsonc, what `_default.ts` does on a
subdomain (nothing — no catch-all), Workers Paid for arbitrary recipients,
the CI recipe (`void email setup` once + commit + `--require-email`), and
the dashboard fallback when the subdomain step is refused. The error table
gains the own-account meaning of BINDING_MISSING / INVALID_FROM /
QUOTA_EXCEEDED.

The Cloudflare integration scope bullet now points at the guide and lists
the remaining limits; the CLI reference documents `void email setup`,
`void email status --backend cloudflare` and `void deploy --require-email`;
the config reference extends the `email.from` entry the config task added
rather than duplicating it; and the two agent prompts drop the
"platform deploys only" clause for one sentence on the own-account flow.
Only the spec's "Human steps that remain" are described as manual, and the
unverified subdomain POST is described by its fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
Add the Mode B (own Cloudflare account, design doc 0078) commands beside the
platform email subcommands:

- `void email setup --backend cloudflare` runs the deploy's step 3b-ii on its
  own — preflight → checklist → one confirm → apply → write the root
  wrangler.jsonc (send_email, addresses, vars.__VOID_EMAIL_FROM) — so a CI
  deploy that finds email not ready can say "run this once, commit, redeploy".
  Routing rules stay wrangler's job on the next deploy.
- `void email status --backend cloudflare` is the preflight alone: status rows
  + address map, exit 1 when not ready.
- Without --backend cloudflare both explain they are Mode B commands today and
  exit 1; the existing platform subcommands are unchanged.

Shared seams in cli/email-cloudflare.ts for the deploy integrator to reuse:
`createSessionBearerGetter` (the `wrangler auth token --json` bridge, null on
any failure) and `isInteractiveTerminal` (wrangler's gate: not CI, stdin AND
stdout TTYs). `emailFromDomain` now accepts the display-name form void.json
allows; `renderAddressMap` takes a pending label. deploy.ts exports the
helpers the command composes (`createDeployWranglerRunner` — with an env
overlay so `auth token` runs with WRANGLER_WRITE_LOGS=false —
`resolveDeployTransport`, `discoverRootWranglerConfig`,
`readWranglerTopLevelString`, `deriveWorkerName`, `loadProvisionModule`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
`void deploy --backend cloudflare` gains an interactivity gate that must
match wrangler's (`ci-info.isCI || !(stdin.isTTY && stdout.isTTY)`), so
the same run is called interactive by both halves of one deploy. wrangler
bundles `ci-info`; pull the same package (4.4.0, already in the lockfile
transitively) instead of replicating its vendor env list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
…ectId, document the reply gate

Round 40 of the review on #316, four threads.

email-cloudflare.ts (P1): every session blocker in the preflight told
the user to run `wrangler login`. On the deploy path that line passes
through `formatCloudflareUserMessage` and came out as "run the
underlying Cloudflare operation"; on `void email setup` / `status` it
reached the user verbatim. Both break the policy in AGENTS.md line 5.
The four branches now name `void cloudflare login`. A session that is
readable but insufficient (missing email scopes, a Global API Key) says
what to clear first — `void cloudflare logout`, or unset
CLOUDFLARE_API_KEY — because `void cloudflare login` reuses any session
`whoami --json` can read and would be a no-op. The detach warning in
deploy.ts, the checklist row, the borrow-failure line and the setup
hint drop their wrangler subcommand names the same way; docs follow.

email-router.ts (P2): a routing blob whose `projectId` is not a
non-empty string (42, {}, true, [], '', null, absent) built the
kill-switch key from the coerced value, missed the real project's
`email-suspended:` row and dispatched — and a counter-write failure
then threw inside `sanitize`. Refused as `routing data corrupt` before
the suspension lookup, the same guard shape as `workerName`. Every
writer has put a string id in the blob since the first one, so absent
is corrupt too; the H1 test that pinned "dispatch, then drop the
forward" for an id-less blob now pins the refusal before dispatch.

docs/guide/email.md (P2): "The reply is always sent" read as a delivery
promise. On the platform a reply recipient goes through the same
verified-destination gate as sendEmail() and forward(), and a reply to
any other sender is dropped silently. The sentence now says what it
meant (no subject fallback suppresses the reply) and a warning block
states the gate, what the ticket example therefore does, and that an
auto-responder to arbitrary senders needs your own account. 0078 notes
the follow-up: the tenant cannot see a dropped action today.

cli/email.ts (P2): `void email logs` pointed a hosted-project user at
`wrangler tail` of the proxy worker, which their credentials cannot
reach. It now says activity logs are not available yet and that the
handler's own console output appears in `void project logs`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
@cursor

cursor Bot commented Sep 15, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_11d58fa7-7ff3-4e8b-9cae-fc9e5b92d4f5)

@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: 8666af5eae

ℹ️ 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 platform/packages/api/src/index.ts
Comment thread docs/guide/email.md Outdated
… when a first send can deliver

Round 41 of the review on #316, two threads.

api (P2): main's self-hosted lifecycle advertises `email: false` on an
installed platform, but the email routes were mounted without
`capabilityMiddleware('email')`. `void email usage` reported email as
active; `void email allow` charged the KV rate limit, called Cloudflare
Email Routing on the operator's account, inserted the row, and then
threw on the absent EMAIL_SENDER_DOMAIN (500). Gated now: the public
confirm mount (`/email/*`, like `/webhooks/*`), the project routes
(`/:id/email/*`), and the owner-recipient auto-add at project creation.
The project router's gate is path-scoped on purpose: Hono re-homes a
sub-app `use('*')` as `ALL /projects/*` on the parent, where it runs
for every sibling mounted after it — main's `domains.ts` gate already
does that to `/projects/:id/usage` on a platform without custom
domains; that is main's defect and is not touched here. Six tests pin
the 501s, the untouched Cloudflare / KV / rows, the ungated siblings,
and the hosted default.

docs (P2): the Setup section promised `sendEmail()` works on first
deploy. Delivery reaches verified recipients only; the owner's address
is registered at creation but is verified at once only when Cloudflare
already holds it verified, otherwise the first send returns
`UNVERIFIED_DESTINATION` for that recipient until the link is clicked
AND the destinations are listed — the listing is what records the
click. The guide and the `void email allow` reference say so. 0078
notes the follow-up: nothing but a listing re-reads Cloudflare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
@cursor

cursor Bot commented Sep 15, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_c0351c02-001a-4b3b-8926-ad314ff16a0c)

@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: bb6f9253c0

ℹ️ 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/inbound.ts
Comment thread packages/void/src/cli/email.ts Outdated
…orward on a native message

Round 42 of the review on #316, two threads.

cli (P2): `void email disallow` said the address "will be rejected on
next deploy". No deploy is involved: the DELETE tightens the proxy's
`email-allowlist` KV mirror inside the request (503 if that write
fails), and the proxy re-reads the mirror with a 60 s cacheTtl on both
the send path and inbound forward/reply. The line and the CLI
reference now say the address is refused within about a minute. Test
pins the line and that it names no deploy.

inbound (P2, false positive): the claim was that `parseEmail(message)`
drains the native `raw` stream and a later `message.forward()` replay
then breaks. `forward(rcptTo, headers?)` carries no bytes and the
runtime forwards the message it holds; Cloudflare's own Email Storage
example reads `raw` to completion and then forwards and replies on the
same native message, and miniflare's local `forward`/`reply` never read
`raw` (run through miniflare 5 with our generated lane: parse, then
forward and reply, all delivered). No runtime change; a test pins that
the native forward fires while `raw` is already locked by the parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
@cursor

cursor Bot commented Sep 15, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_82118846-c12f-498b-9f33-65eea9170920)

@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: 6ef17b123a

ℹ️ 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 platform/packages/proxy/src/email.ts Outdated

@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: 6ef17b123a

ℹ️ 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 platform/packages/proxy/src/index.ts
…oxy email group on the capability

Round 43 of the review on #316, two threads.

proxy email.ts (P2): `readRoutingSuspension` read a truthy non-string
`suspended` marker (`true`, `42`, `{}`) as `clear` and let the send
through, while dispatch (429) and the inbound router (reject) refuse
the same value. No writer emits such a marker — every producer stamps a
non-empty reason string and an unsuspend deletes the key — but the
backfill and domain-copy writers preserve whatever bytes are there, so
a corrupt marker survives redeploys. The marker is now read by
truthiness like the two siblings: falsy → clear, truthy string →
suspended, truthy non-string → corrupt (502 UPSTREAM_ERROR, no send, no
quota burned).

proxy index.ts (P2): the `/email/*` group had no capability gate, unlike
`/sandbox/*`. On an installed platform with `email: false` a deployed
worker still holds `__VOID_PROXY` and a valid token, so an explicit
`from` reached `/email/send` and got "email sender domain is not
configured" (502) — or sent, if the operator left the bindings in
place. Gated with the same shape and order as sandbox (auth first, then
501); `/email/internal/send` is covered too, its only caller sits
behind the api's gate. The framework runtime used to report the 501 as
"malformed response (status 501)"; it now carries a proxy middleware
refusal `{ error, message? }` through on a non-2xx as
`UPSTREAM_ERROR "email: proxy returned 501: This Void platform does not
enable email."` — a 2xx with that shape is still malformed. The inbound
`email()` entry is not gated: no Email Routing rule targets the proxy
on such an install, and main gates no queue/tail/email entry point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
@cursor

cursor Bot commented Sep 15, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_ee6e6476-1b5e-4f46-a068-265a2fc0b88a)

41 commits from main. Five conflicts:

- platform/packages/api/src/schema.ts: both sides appended a table
  block at the same spot — kept ours (email_destinations) and main's
  (operator_sessions, operator_events).
- drizzle: both sides added migration 0033. Main's
  0033_operator_sessions_and_events stays; ours is regenerated from the
  merged schema as 0034_email_destinations (SQL byte-identical to the
  old 0033_outgoing_changeling, journal `when` newer than main's 0033,
  snapshot rebuilt by drizzle-kit).
- docs/integrations/cloudflare.md and docs/reference/cli.md: main
  rewrote both around practical examples; took main's structure and
  re-added the email content in its style — the email step in the
  deploy sequence, the `void cloudflare login` / token scope
  prerequisite, `--require-email`, the `void email` command rows, the
  Email limits bullet and the deploy-time Email paragraph.

Gates on the merged tree: fmt/lint 0, `vp run check` 0, unit
5052/5052, platform 3716/3716, platform runtime build (journal
validation) 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
@cursor

cursor Bot commented Sep 15, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_4610f556-18fc-4953-9cce-beecb7468834)

@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: bd07cbcef4

ℹ️ 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 platform/packages/proxy/src/email.ts Outdated
…s message

Round 44 of the review on #316, one thread.

The proxy's send fan-out reduced a thrown binding error to its message
and keyed only on the destination-verification regex, so a structured
Email Service code — E_RECIPIENT_NOT_ALLOWED, E_RATE_LIMIT_EXCEEDED,
E_DAILY_LIMIT_EXCEEDED — came back as UPSTREAM_ERROR and the framework
retried a permanent recipient failure or a rate limit. The framework's
own-binding lane (`classifyBindingError`) already reads the code first.
`classifyUpstream` now mirrors it: recipient code → UNVERIFIED_DESTINATION,
the two limit codes → QUOTA_EXCEEDED, then the regex fallback for the
older message-only sender, else UPSTREAM_ERROR.

The two sender codes stay UPSTREAM_ERROR on the platform lane, on
purpose: `from` is pinned to the project sender before the send, so
E_SENDER_NOT_VERIFIED / E_SENDER_DOMAIN_NOT_AVAILABLE mean the
platform's mail zone is broken, not the tenant's `from`; INVALID_FROM is
documented as "not the project sender" and as pre-flight. The code is
logged with the project id because the send log records only ours. Both
docblocks name the divergence; the QUOTA_EXCEEDED doc row now covers the
sending account on either lane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
@cursor

cursor Bot commented Sep 15, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_7cec112d-85ab-4297-a999-3ba9f7237b24)

@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: d60939a019

ℹ️ 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/cli/email-cloudflare.ts
…ision #11

Raised by review three times (rounds 13, 29, 45); the per-domain rule
is documented in the guide and stays until the owner decides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
@cursor

cursor Bot commented Sep 15, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_a7b85c03-6ebe-4f7e-8821-50b66187c0d3)

…leanup)

28 commits from main. Seven conflicts:

- packages/deploy-cloudflare/src/version.ts: both sides extended the
  trigger snapshot at the same spots — kept main's `sourceConfigPath`
  reader, settings hash and traffic-preserve flags next to our
  `addresses` reader and replay. main's new `resolvePreservedTraffic`
  rebuilds the trigger config on every versioned deploy and would have
  dropped `addresses`; it now carries the key through.
- packages/void/src/cli/deploy.ts: `discoverRootWranglerConfig` keeps
  our export and main's `wrangler.toml` refusal.
- packages/void/AGENT_PROMPT.md: our docs sentence (with email) plus
  main's platform-forks paragraph.
- platform/packages/api/test/integration/auth-email-verification.test.ts:
  main removed the preview-repo signup tests; ours sat after them.
  Dropped the removed test, kept every owner-email-change test.
- platform/meta/operations.md: main replaced the inventory with a
  maintainer runbook; re-added a condensed Email section (invariants,
  the unexecuted one-time zone setup, deploy order, rollback, the two
  retroactive pre-checks). platform/meta/progress.md: main retired it;
  took main's. platform/packages/api/README.md: both admin rows kept.

One auto-merge follow-up: main's new onboarding page test expected a
raw `'` in `void connect '<url>'`; our `escapeHtml` escapes quotes
(`&#39;`, rendered back by the browser). The test now expects the
escaped form.

Gates on the merged tree: fmt/lint 0, `vp run check` 0, unit
5103/5103, platform 3761/3761, platform runtime build 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
@cursor

cursor Bot commented Sep 15, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_b0997f03-165a-4af0-9466-c2947ead5b48)

@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: 5d8ec495b6

ℹ️ 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/email.ts Outdated
Comment thread packages/void/src/cli/email-args.ts Outdated
…r empty --project

Dispatch order (Codex round 46):

`support+vip.ts` and `support+[ticket].ts` are both `static-tag`. The scanner
sorted the same kind alphabetically, so `support+[ticket]` came before
`support+vip` (`[` < `v`) and owned every `support+…@` address; the exact
handler was dead code. Same for `[user]+vip.ts` vs `[user]+[tag].ts`.

One comparator now decides the order: `compareEmailRoutes` in
`router/email.ts` — kind, then fewer captures, then alphabetical. Both the
scanner (`parseEmailFiles`, feeds the generated `__emailHandler`) and
`createInboundTestHarness` sort through it, so the harness cannot drift from
the dispatcher. The unused `SPECIFICITY` table and the harness's private
`DISPATCH_PRIORITY` copy are gone. The docs precedence list is now seven
steps.

`--project` flag:

`void email --project` with no value, an empty value, or a repeated flag was
accepted silently (last value won, empty became "no project"). It is now
rejected like `--platform`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
@cursor

cursor Bot commented Sep 15, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_7a3627f9-3699-41a4-b013-53524f1c4cd5)

@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: fb1509bfbd

ℹ️ 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/prerender.ts Outdated
…unner use

Codex round 47. Prerender boots the built worker in its own Miniflare.
The dev server (`@cloudflare/vite-plugin`, `persistState: { path: '.void' }`
→ `<root>/.void/v3`), the migration runner, the drizzle plugin and the auth
bootstrap all address the local D1 file at
`.void/v3/d1/miniflare-D1DatabaseObject/<hash>.sqlite`.

Prerender never did. On main (miniflare 4) `d1Persist = .void` wrote to
`.void/miniflare-D1DatabaseObject/`; the wrangler 4.131 bump in this PR
(`0272bb44`) moved it to `.void/d1/` — a different, empty database either
way, so a prerendered page that reads a migrated table failed with
`D1_ERROR: no such table`. Verified by running miniflare 4 and 5 against a
seeded database.

The persistence root is now `join(persistDir, 'v3')`, the root the vite
plugin derives. KV and R2 move under `.void/v3/` with it, matching dev. A
unit test seeds a table where the runner writes it and asserts the bundled
Miniflare reads it back through `collectPrerenderPaths` (fails on the
previous head with HTTP 500).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
@cursor

cursor Bot commented Sep 15, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_0189d879-4c44-4a96-ab14-a234126057ab)

@Brooooooklyn
Brooooooklyn merged commit 2bae670 into main Sep 16, 2026
16 checks passed
@Brooooooklyn
Brooooooklyn deleted the feat/email-mono branch September 16, 2026 02:41
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.

1 participant