Repository navigation
feat(email): custom domains on the customer's own Cloudflare zone - #326
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
Design doc 0078 (relay approach): per-project email domains on zones the customer owns. A relay worker deployed into the customer's account holds the zone's catch-all and forwards to the platform proxy; the proxy terminates /email/relay against the recipient-domain mirror in ROUTING KV. - api: email_domains table + partial unique indexes (one live row per zone, one relay name per account), relay engine + CF customer client, credential lease, domain routes (add/sync/status/remove), internal relay lease endpoint, admin revoke-grant route, teardown cleanup - proxy: /email/relay branch keyed on the recipient domain - CLI: void email domain add/status/sync/remove (+ --force), OAuth PKCE grant flow via wrangler's public client, API-token fallback, help tree + guide/reference docs + getting-started prompt - durability: deploy checkpoint closing the catch-all race, winner repoint in race compensation, keep-row removal with ?force=1, non-unique apply reconciliation, catch-all drift reporting (sync ensure + status probe), RFC 7009 grant revocation on remove/replace/teardown - tests: platform-unit 2473, platform-api-integration 1532, framework email 667; live write probes against voidcloud.dev (subdomain enable, sending onboard, relay deploy, catch-all scope, send_raw envelope)
Resolution notes: - drizzle: our 0035/0036 collided with main's 0035-0038; regenerated as a single 0039_email_domains via drizzle-kit against the merged schema (table + checks + FK + both partial unique indexes, semantics verbatim; snapshot chain relinked to main's 0038). - Accepted main's deletions: AGENT_PROMPT.md, getting-started-prompt.txt (replaced by AGENTS_TEMPLATE.md mechanism in 8b7c5db), design doc 0047 (superseded in 5df4b8a). - db.ts: git auto-merge union-duplicated the 446-line email-destinations section (both sides added it vs the old base); removed the byte-identical second copy. - 0078: main's slim decisions-record rewrite kept; our three fix-round addenda + probe outcomes spliced into a new Custom domains chapter. - All other conflicts: main's version + the feature delta (computed per file as the diff 2bae670..HEAD) re-applied.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
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_62e13e41-6e8e-4607-a1bd-7efba4c8f839) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2d86633eb9
ℹ️ 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".
Review follow-ups on the custom-domain lifecycle: - Record the relay name before the fallible writes, so a failed secret PUT leaves the row pointing at the uploaded worker (retry redeploys in place; remove can find it) instead of leaking it and walking to '-2'. - Compensate any checkpoint-write failure from the captured checkpoint payload, not just unique-violation races: a non-unique error or a teardown cascade previously orphaned the deployed relay. - revokeOauthGrant throws on network failure and non-2xx (CF answers 200 even for bogus tokens), making the engine's revoke_grant failed_step reachable; the DELETE surfaces it, the replace path logs and proceeds. - The operator revoke-grant route flips the proxy mirror to token_revoked before revoking, so inbound stops immediately instead of whenever the next status probe repairs drift; a failed revoke returns 502 with the row active (the next status GET repairs the mirror). - The admin full-removal route passes the revokeGrant dep, so an admin revoke of an oauth_grant row no longer orphans the rotated grant.
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_5fa4229f-93d1-444e-abf5-f7d8da9d95a5) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 25671f1ab2
ℹ️ 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".
… history The chapter was spliced from the fix-round records during the main merge, dropping the design prose and keeping only the chronological fix rounds. Restore the designed shape and control flow — model choice, the two paths, the add flow, re-runnable setup, concurrency/durability backstops, removal and teardown, the credential grant paths, limits, probe results, and where each piece lives — with every fix-round outcome folded in as present-tense design.
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_5e038a13-25da-48f6-a7ca-f81c7bad78b5) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 35dd85b233
ℹ️ 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".
Round-2 review fixes: - Revocations go through the row's serialized chain (new revokeOauthGrantSerialized) everywhere — owner DELETE, admin removal, project teardown — so a lease refresh interleaved with cleanup can no longer orphan the rotated head behind CF's 200-for-a-consumed-token. - Credential replace captures the old chain head and commits the new credential under one row lock; revoke happens after the commit. - Sync (and the replace-sync branch) runs under the row lock via withEmailDomainRowLock, so overlapping syncs cannot interleave the worker secret PUT with the row/mirror persist. - Project teardown no longer skips relay-less oauth_grant rows: they own a live grant and it is revoked. - The owner DELETE re-reads the row status after removal: an invalid_grant mid-removal persisted token_revoked, which takes the unconditional-delete path instead of keep-row. - Relay forward submits the envelope recipient as send_raw's MAIL FROM (the customer account rejects the external original sender). - Relay header parsing accumulates pieces per name and joins once, removing the quadratic concat on repeated same-name headers. - CLI clipboard watch settles its poll loop when Enter wins; the parked timer is unref'd so it cannot keep the process alive.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ee14c13920
ℹ️ 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".
… setup on Workers Free Round-3 review fixes: - The serialized refresh and revoke helpers re-check credential_kind under the row lock and throw EmailDomainCredentialChangedError on a raced replace, instead of submitting the replacement API token to the grant endpoint and marking it revoked off the invalid_grant answer. The lease re-reads once and converges on the credential the row now holds. - The status GET's probe patch is fenced on the probed credential's identity (kind + ciphertext), so a renewal replace landing mid-poll voids the probe instead of stamping the old token's verdict — or metadata — on the new credential. - The status GET's mirror repair now holds SELECT … FOR UPDATE on the project row across the KV write, giving a total order with teardown's 'deleting' flip and cascade: a poll admitted before teardown can no longer recreate an active mirror nothing would ever sweep. - Email Sending onboarding refusal by entitlement (CF 2036/10105, Workers Free) no longer fails setup: Email Routing needs no paid plan, so the relay and catch-all deploy and the row lands active with a sending_onboarding remaining step; only the outbound lane stays refused at Cloudflare. sync re-attempts the onboarding POST, the status GET self-clears the step on a confirming read-back, and the CLI prints the plan hint. CloudflareApiError now carries the envelope's numeric codes. - Design doc 0078 records the free-plan shape and both completion paths.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2fcb996c03
ℹ️ 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".
…ublishes the mirror under the row lock - Status self-clear: suppressed while a removal failure (failed_step: 'remove') is pending, and the UPDATE is fenced on the probed status + setup_state, so a revocation or removal marker landing mid-poll is never overwritten back to a live state (which also reauthorized the mirror). - Sync: the row — credential included — is re-read and decrypted inside withEmailDomainRowLock, so a replace that committed while the request waited is synced as the new credential instead of stamping the superseded credential's verdict onto it. syncEmailDomainAndApply no longer takes a caller-supplied credential at all. - Sync: the mirror PUT moved inside the serialized section, so overlapping syncs publish relay hashes in the same order they rotated the worker secret; a mirror-write failure now rolls the row apply back with it.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 799589359a
ℹ️ 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".
…gainst project teardown - DELETE: cleanup, mirror clear and row delete now run inside withEmailDomainRowLock with the row and credential re-read at lock time. A replace committing an oauth_grant while a removal waited no longer has its fresh grant orphaned by the row delete — the removal cleans up as the NEW credential and revokes it last. (The replace never invalidates an old api_token, so the stale-snapshot cleanup would have succeeded and deleted the grant's only handle.) - Sync: the serialized transaction takes the project row FOR UPDATE before the domain row (teardown's lock order) and refuses with the same 409 when the project is being deleted — a sync that raced the entry guard can no longer redeploy the relay or republish the mirror after teardown cleared them, leaving recreated customer-side state with no database handle. - Tests: the lease-refresh interleave test now asserts the serialization (a refresh fired mid-cleanup queues behind the removal and finds the row gone); two new tests land a replace commit / a teardown flip between the entry checks and the lock via a db.transaction pause seam.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d3bc5e4148
ℹ️ 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".
… rejected grants Six race fixes, all in PR-introduced code: - The add route inserts under the project FOR UPDATE with the 'deleting' verdict re-read inside, and the engine runs in a follow-up serialized section (project + row locks, savepointed checkpoint/apply): teardown can no longer cascade-delete a completed add it never saw, and the sync-vs-setup branch is decided on the locked row, so concurrent retries of an incomplete setup no longer write one relay secret to the worker while recording another on the row and mirror. - Every rejected add (validation 400, cross-project conflict 409, teardown 409) revokes the freshly minted OAuth grant the CLI drops on an error response — guarded by a decrypt-compare so a re-pasted grant matching the holder row is never revoked. - The status route's mirror repair re-reads the domain row under the row lock and re-checks drift against it: a rotation or revocation committed mid-poll is no longer overwritten back from the stale entry snapshot. - The admin domain revoke runs under the row lock with the credential re-read inside: a replace committed mid-removal is cleaned up as the new credential (grant revoked), and the mirror clear orders against a replacement sync's republish. - The proxy relay lane keys the send-time mirror and credential lease on the FROM domain: a reply may send from any active project domain, and two domains of one project can live in different customer accounts. Each fix carries an integration/unit test that fails on the pre-fix code (the concurrency seam pauses the route's first transaction so the interleaving lands deterministically).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 599883126c
ℹ️ 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".
…voke-grant under the row lock - add route parses the body before the entry deleting-409 and revokes a posted grant on every post-parse rejection, so the entry guard no longer strands the grant it refused - admin revoke-grant runs as one serialized section under the row lock: the credential re-read inside the lock is the grant revoked, so a replace committed before the lock dies as the new grant and a committed replace can no longer be marked token_revoked while staying live in the mirror - CLI: a lost add answer is not a rejection — reconcile via the domain status; a row means the add committed, a 404 revokes the minted grant the CLI still holds, anything else is reported unknown (never a blind revoke) - CLI: an OAuth exchange completing after the flow settled mints a grant only the CLI holds — revoke it fire-and-forget and answer the browser tab a cancelled page instead of hanging on the closed server
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f046bc7351
ℹ️ 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".
…d reconcile; revoke on project-guard rejections - relay: pipe message.raw through a FixedLengthStream(rawSize) — the runtime derives Content-Length from the body and ignores a hand-set one, so the relay POST went out chunked and the proxy's pre-auth length gate bounced every custom-domain inbound. RELAY_VERSION 3; deployed v2 relays report drift until sync redeploys them - add: every attempt carries a random attempt id, stamped on the row at insert and at credential replace (additive attempt_id column) and always serialized on the status GET. A lost add answer now reconciles against that identity over a bounded poll: my id means the add committed (never revoked); a different id or null means the row kept its existing credential, so the minted grant is revoked (a committed mine would have stamped); 404 at every poll revokes; an api without the key falls back to the pre-attempt behavior, never revoking; no informative read stays unknown, never revoking - add route: the project guards' 404/403 now revoke a posted grant like every other rejection — possession of the token already authorizes its revocation on a public client, and the CLI drops its only copy on any error response
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 250e9e2fbc
ℹ️ 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".
…ase-revocation mirror; harden the CLI reconcile - relay lane: forward/reply actions on a registered custom domain no longer consult the shared recipient allowlist — the send leaves through the customer's own send_raw account, whose onboarding lifts the verified-destination gate (0078 already carved this out; the burst cap and kill switch still apply). The platform lane is unchanged - internal domain-credential route: the token_revoked mirror write after a lease-time invalid_grant is fenced through the row lock — re-read FOR UPDATE, published from the locked row, and skipped when the row no longer holds the revoked credential, so a committed replace's active mirror is never overwritten with a stale status and relay hash - CLI: only the add route's deliberate 400/403/404/409 rejections are trusted as server-side revocations — a 5xx or any other ambiguous answer now goes through the same attempt-identity reconcile as a transport failure instead of dropping the minted grant - CLI: revokeCfOauthGrant reports whether Cloudflare confirmed the revocation; the reconcile only claims a confirmed revoke and otherwise prints manual-revocation steps (the post-settlement path stays fire-and-forget)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4c6d6e9249
ℹ️ 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".
…swers; cap relayed header pieces - api: the name-walk claims each CF-free candidate on its own row (savepoint UPDATE against the relay unique index) BEFORE any upload, so two setups racing one account serialize first-to-claim instead of last-write-wins — a racing loser skips the contested name rather than overwriting the winner's worker. An engine throw after a successful claim restores the row's locked pre-engine values. - CLI: 403 is an ambiguous answer — middleware answers 403 pre-route without parsing the body, so no grant revoke ran. The rethrow set is now exactly the add route's deliberate rejections (400/404/409); everything else reconciles. - proxy: the relayed-header parser caps total header pieces (distinct names + repeat occurrences + folds) at 4096 alongside the distinct name cap, refusing with 413 before dispatch. Design doc 0078 updated. New and rewritten regression tests fail pre-fix.
What
Per-project custom email domains on the customer's own Cloudflare zone (design doc
platform/meta/design-docs/0078-email-cf-native.md, relay approach). A small relay worker is deployed into the customer's account and holds the zone's catch-all; it forwards to the platform proxy'sPOST /email/relay, which terminates against the recipient-domain mirror in ROUTING KV. Customers authenticate with an OAuth grant (PKCE via wrangler's public client) or an API token.Surface
void email domain add | status | sync | remove(remove --force), PKCE grant flow, help tree + guide/reference docs.email_domainstable (migration0039), relay engine + customer CF client, credential lease, owner routes, internal relay-lease route, admin grant-revoke route, teardown cleanup./email/relaybranch keyed on the recipient domain (cross-domain attribution is refused, never guessed).Durability mechanics (adversarial-review rounds, all pinned by tests)
project is being deleted.removed: false+failed_steps;?force=1overrides);token_revokedrows andrevoke_grant-only failures still delete; status GET suppresses mirror repair while a removal mark stands.relay_version— the version distinguishes a committed apply from the checkpoint write) and compensates from the engine result instead of orphaning the relay.syncensure outcomes, statuscatch_all/catch_all_drift), never fought; a foreign enabled rule is never overwritten.wrangler logout's shape) on remove (last, with the rotated chain head), replace (after the credential commit; same-grant re-paste skipped), and teardown; conflict compensation deliberately does not revoke.Verification
platform-unitplatform-api-integrationunitemail + cli-helpvp run checkvp lint/vp fmtLive write probes against
voidcloud.dev(zone restored byte-identical afterward): subdomain routing enable, sending onboard via API token, zone-scoped catch-all covering apex+subdomain, relay deploy + secret PUT + catch-all PUT,send_rawJSON envelope →200 queued.Merge notes
0039_email_domainsvia drizzle-kit against the merged schema (semantics verbatim; snapshot chain relinked).AGENT_PROMPT.md,getting-started-prompt.txt, design doc 0047); the email getting-started content now lives in the bundled docs.## Custom domains on Cloudflarechapter.Follow-ups (not in this PR)
Note
High Risk
Introduces cross-account mail routing, encrypted Cloudflare credentials, relay-secret inbound auth, and OAuth/token handling—security- and deliverability-critical paths with substantial orchestration surface.
Overview
Adds custom send/receive domains on the Void platform by registering a customer’s Cloudflare zone with
void email domain(add,status,list,sync,remove, including--subdomainandremove --force).The CLI orchestrates credential collection (PKCE OAuth via wrangler’s public client in
cf-oauth.ts, with a scoped API-token template + clipboard/paste fallback), local MX hints for apex vsmail.<domain>, and calls newPlatformClient/projects/.../email/domainsAPIs. Help, CLI reference, and the email guide document the flow and update platformfrom/ inbound behavior for registered domains.Inbound runtime changes treat relayed mail (
envelope.viaDomain) as a separate lane: generatedhandleEmailandcreateMessageProxyskip project-slug stripping so addresses likesupport+T-42@acme.comroute correctly;replyEmaildefaultsfromto the receiving address on the customer domain instead of@mail.void.cloud.Platform side (per PR scope) adds the
email_domainsschema/migration and expands design doc 0078 with the relay-worker +/email/relaymodel, credential storage, and teardown/removal rules.Reviewed by Cursor Bugbot for commit 35dd85b. Bugbot is set up for automated code reviews on this repo. Configure here.