Repository navigation
feat(email): void/email — outbound, inbound, platform proxy, and own-account setup - #316
Conversation
Re-lands the abandoned feat/email branch onto current main. Re-applied file by file rather than rebased: the branch was four months and several hundred commits behind, and its reversal commits replay against ground main had rewritten. - email/ handlers with the same scanning conventions as routes/, so src/email/ works. support+[ticket].ts matches support+anything@. - sendEmail() from void/email POSTs MIME to the platform proxy. The account-level send binding never reaches the user worker. - Dev inbox at /__void/email, void email CLI, void/email/testing harness. The generated worker entry is now a WorkerEntrypoint subclass, because email() is not reachable through a Workers-for-Platforms dispatch stub on a plain object export. compileWorkerEntry had grown from 8 to 11 parameters while the branch sat; its option type was rebuilt from main's current signature and the generated output diffed to prove every interpolation main added survived (ISR stamp, live reload, notFound, remote bindings, sandbox export). emailDir went on the existing options object, not into a new positional slot. Fixes a double strip the seam between the two repos hid: the proxy removes the <slug>+ prefix so handlers see the address the author wrote, and the generated dispatcher removed it a second time, leaving an empty tag that no anchored pattern could match. The dispatcher now routes on the preserved wire recipient. Matching no longer lowercases the address first, which was also lowercasing the captured groups. The codegen tests passed throughout while the dispatcher could not route a single real message, because they searched the generated source for substrings. They now run it. The platform mail zone is configuration (__VOID_EMAIL_DOMAIN), not the hard-coded literal the original used. sendEmail() throws rather than guessing when it is absent and from was omitted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
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
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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".
… 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
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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".
…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
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
💡 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".
…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
Bugbot couldn't run - usage limit reachedBugbot 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
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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".
…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
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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".
…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
Bugbot couldn't run - usage limit reachedBugbot 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 (`'`, 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
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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".
…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
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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".
…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
Bugbot couldn't run - usage limit reachedBugbot 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) |
void/email— the framework half, the platform half, and own-account setup — in one PR, now that the platform lives underplatform/.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 underplatform/.What it adds
Framework (
packages/void): file-based inbound handlers,sendEmail, dev inbox at/__void/email,void emailCLI,void/email/testingharness, theWorkerEntrypointclass 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.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_DOMAINas configuration (fails closed); migration0033; admin page. Unchanged from build: less flaky integration tests #117.Own Cloudflare account — new in this PR.
void deploy --platform cloudflaredetects email in use, runs a read-only preflight through the developer'swrangler loginsession, prints a checklist, and asks once:Then it enables Email Routing (apex through
wrangler email routing enable; a subdomain through one REST call with the session bearer fromwrangler auth token --json), turns on the zone's subaddressing setting, onboards Email Sending, writessend_email+addresses+vars.__VOID_EMAIL_FROMinto the rootwrangler.jsonc, and letswrangler deploy(≥ 4.113) create the routing rules and catch-all fromaddresses. Second deploy: zero keystrokes. CI:void email setup --platform cloudflareonce, commit,--require-emailto fail instead of degrade. Runtime: a directenv.SEND_EMAILtransport,email.frominvoid.jsonas the default sender, no-prefix inbound dispatch, and handler actions enacted on the native message.wrangler 4.107 → 4.131,
@cloudflare/vite-plugin1.43 → 1.54.7 (default catalog only;catalogs.platformuntouched). Needed foraddressesreconciliation on deploy. Miniflare 5 changed the prerender options —void:prerendernow routes throughconvertV4MiniflareOptions.@cloudflare/workers-typesstays on 4.x (5.x declaresBuffer/processglobally and collides with@types/node; comment inpnpm-workspace.yaml). Awrangler-pintest asserts ≥ 4.113 and the two capabilities the feature relies on.What Void never does
WRANGLER_WRITE_LOGS=falseon theauth tokenchild (wrangler otherwise writes the token into~/.wrangler/logs).addressesis pinned whole-object in the deploy verifier.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
POST /zones/{z}/email/routing/dns { name }for a subdomain andPATCH /zones/{z}/email/routing { support_subaddress: true }with the OAuth session. The code treats any refusal as "print the dashboard step, withholdaddresses". One probe on a throwaway subdomain of a staging zone settles both.--require-emailshould pass when the worker already ships the binding; whether a deleted handler's staleaddressesentry counts as foreign (decision 13) or Void-managed.platform/meta/operations.md"Email"):mail.void.cloud/mail.staging.void.cloudrouting subdomain, Email Sending onboarding, the catch-all rule.+; an own-account address needs the<anything>+prefix in tests (documented; aslug: falseoption 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 inplatform/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
addresseshandling or setup flows can break routing or delete rules on rollback.Overview
Adds
void/emailas a documented platform capability: a new Email guide (docs/guide/email.md), sidebar entry, and updates to Cloudflare, Node/Bun/Deno, CLI, andvoid.jsonconfig 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, andemail.from/inference.emailconfiguration.@void/deploy-cloudflarenow snapshots and replays Wrangleraddressesin version trigger config so Email Routing plans sync on deploy/rollback without treating “missing” like[](which would delete rules).packages/voidexposesvoid/emailandvoid/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 selectivesend_emailstripping inAGENTS.md. Project creation can surface owner recipient registration; Cloudflare backend detection now treatsemail/as backend surface area.Reviewed by Cursor Bugbot for commit 8a243a4. Bugbot is set up for automated code reviews on this repo. Configure here.