Skip to content

feat(email): custom domains on the customer's own Cloudflare zone - #326

Merged
Brooooooklyn merged 148 commits into
mainfrom
feat/email-reland
Sep 18, 2026
Merged

Brooooooklyn merged 148 commits into
mainfrom
feat/email-reland

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

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's POST /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.

customer zone catch-all ──► relay worker (customer account)
                                │  X-Void-Email-Domain + bearer
                                ▼
                     proxy /email/relay ──► ROUTING KV mirror ──► user worker

Surface

  • CLI: void email domain add | status | sync | remove (remove --force), PKCE grant flow, help tree + guide/reference docs.
  • api: email_domains table (migration 0039), relay engine + customer CF client, credential lease, owner routes, internal relay-lease route, admin grant-revoke route, teardown cleanup.
  • proxy: /email/relay branch keyed on the recipient domain (cross-domain attribution is refused, never guessed).

Durability mechanics (adversarial-review rounds, all pinned by tests)

  • Partial unique indexes: one live row per zone, one relay name per account.
  • Deploy checkpoint persists relay identity before the catch-all write — a racing loser 23505s there and never writes the rule (closes the last-write-wins window); checkpoint-vs-teardown → 409 project is being deleted.
  • Race compensation repoints an owned catch-all at the winner instead of resetting it.
  • Failed removal keeps the row (removed: false + failed_steps; ?force=1 overrides); token_revoked rows and revoke_grant-only failures still delete; status GET suppresses mirror repair while a removal mark stands.
  • Non-unique apply failure re-reads the row (relay name and relay_version — the version distinguishes a committed apply from the checkpoint write) and compensates from the engine result instead of orphaning the relay.
  • Catch-all drift is reported (sync ensure outcomes, status catch_all/catch_all_drift), never fought; a foreign enabled rule is never overwritten.
  • OAuth grants are revoked (RFC 7009, wrangler's client id — exactly 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

Gate Result
platform-unit 2524 passed
platform-api-integration 1586 passed, 2 skipped (email-domains 43→59)
framework unit email + cli-help 667 + 18 passed
vp run check 26/26 clean
vp lint / vp fmt clean

Live 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_raw JSON envelope → 200 queued.

Merge notes

  • Our migrations collided with main's 0035–0038 → regenerated as a single 0039_email_domains via drizzle-kit against the merged schema (semantics verbatim; snapshot chain relinked).
  • Accepted main's deletions (AGENT_PROMPT.md, getting-started-prompt.txt, design doc 0047); the email getting-started content now lives in the bundled docs.
  • Main's slim 0078 rewrite kept; this feature's three fix-round addenda + probe outcomes spliced into its ## Custom domains on Cloudflare chapter.

Follow-ups (not in this PR)

  • Dashboard Domains block for custom email domains (deferred; dashboard is auth-only since 971bf93 — the API routes are mounted and ready).
  • Browser PKCE probe (probe 8, owner's manual run) and a message-level inbound E2E (needs observability an API token can't reach).

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 --subdomain and remove --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 vs mail.<domain>, and calls new PlatformClient /projects/.../email/domains APIs. Help, CLI reference, and the email guide document the flow and update platform from / inbound behavior for registered domains.

Inbound runtime changes treat relayed mail (envelope.viaDomain) as a separate lane: generated handleEmail and createMessageProxy skip project-slug stripping so addresses like support+T-42@acme.com route correctly; replyEmail defaults from to the receiving address on the customer domain instead of @mail.void.cloud.

Platform side (per PR scope) adds the email_domains schema/migration and expands design doc 0078 with the relay-worker + /email/relay model, 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.

Brooooooklyn and others added 30 commits August 30, 2026 23:27
Re-lands the abandoned feat/email branch onto current main. Re-applied
file by file rather than rebased: the branch was four months and several
hundred commits behind, and its reversal commits replay against ground
main had rewritten.

- email/ handlers with the same scanning conventions as routes/, so
  src/email/ works. support+[ticket].ts matches support+anything@.
- sendEmail() from void/email POSTs MIME to the platform proxy. The
  account-level send binding never reaches the user worker.
- Dev inbox at /__void/email, void email CLI, void/email/testing harness.

The generated worker entry is now a WorkerEntrypoint subclass, because
email() is not reachable through a Workers-for-Platforms dispatch stub on
a plain object export. compileWorkerEntry had grown from 8 to 11
parameters while the branch sat; its option type was rebuilt from main's
current signature and the generated output diffed to prove every
interpolation main added survived (ISR stamp, live reload, notFound,
remote bindings, sandbox export). emailDir went on the existing options
object, not into a new positional slot.

Fixes a double strip the seam between the two repos hid: the proxy
removes the <slug>+ prefix so handlers see the address the author wrote,
and the generated dispatcher removed it a second time, leaving an empty
tag that no anchored pattern could match. The dispatcher now routes on
the preserved wire recipient. Matching no longer lowercases the address
first, which was also lowercasing the captured groups.

The codegen tests passed throughout while the dispatcher could not route
a single real message, because they searched the generated source for
substrings. They now run it.

The platform mail zone is configuration (__VOID_EMAIL_DOMAIN), not the
hard-coded literal the original used. sendEmail() throws rather than
guessing when it is absent and from was omitted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
Re-lands the abandoned feat/email branch onto current main. Re-applied
file by file rather than rebased.

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

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

Two decisions changed during the re-land:

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
Addresses four review findings, each reproduced before being fixed.

The dev inbox never captured anything. The sink was registered on the
Vite plugin's Node globalThis while user code runs in a separate workerd
process, so hasActiveSink() was always false in the worker and every send
fell through to BINDING_MISSING - whose message told the user to run
void dev, which is what was already running. The comment in dev-inbox.ts
claiming both sides share one runtime was simply wrong.

sendEmail now hands captured messages to the dev server over the worker's
assets binding, which the Cloudflare vite plugin already backs with the
Vite middleware stack. Baking the dev-server origin as a var was rejected:
mergeBindings runs before httpServer.listen, so it can only bake the
configured port, and Vite increments on collision. The injection is gated
on routeEnvironment === 'dev', which excludes vite preview, and carries a
binding name rather than a credential; a production build emits vars: {}.
A 200 is only trusted with the ack header, so a plain asset response can
never look like a capture. An unreachable inbox returns UPSTREAM_ERROR
under a 5s bound rather than claiming success or hanging the request.

The test harness still lowercased the local part and matched without the
i flag, so a handler under test saw ticket t-42 where production hands it
T-42. The production dispatcher was fixed for exactly this reason and the
shared pattern compiler documents the contract the harness was violating.
A green suite would have certified behavior the deployed code does not
have.

Both documented harness examples used an address production never
delivers: deliver() takes the wire form <slug>+<tag>@<domain> and reads
support+abc-123@acme.dev as slug support. The first example routed to
_default; the second passed only because _default was its sole route. The
same broken example was in the harness JSDoc.

The reply docs claimed from defaults to message.to. It does not, and that
address is the one the platform drops: the proxy pins a reply sender to
the receiving slug, while message.to is the stripped <tag>@<domain>. A
handler following the docs got a reply that vanished with no error. The
guide and the ReplyEmailOptions JSDoc now document the pin, the throw,
and the asymmetry with sendEmail, which returns errors instead.

Also removed a promise of a local inbound round-trip; no inbound dev
trigger exists.

Class A frameworks declare no assets binding and so have no loopback;
they still return BINDING_MISSING, which the guide now states.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
Four more review findings, each verified against the real code before
being touched. One was only partly true and was fixed narrower than
reported; one turned out to be a documentation error of mine.

encodeWord emitted one unbounded RFC 2047 encoded-word, and foldHeader
could never repair it: its fallback splits on whitespace and base64 has
none, so it always saw a single token and returned the line unchanged.
Measured, a 16-character CJK subject produced a 76-character word against
the 75-character limit in RFC 2047 section 2, and 246 CJK characters
produced a 1005-octet line against the 998 that RFC 5322 section 2.1.1
makes a MUST. Nothing downstream repairs it; the proxy forwards our bytes
verbatim. The file already tested that same 998-octet limit for the
References header and cited it in two comments, so the encoded-word path
was bypassing a guard the authors knew they needed.

encodeWord now chunks by code point, not by byte and not by UTF-16 unit,
and base64s each chunk independently. RFC 2047 section 5 forbids splitting
a multi-octet character across words, so slicing one base64 string would
not do. A UTF-16 chunker or a slice-after-encoding both corrupt an astral
character; tests pin that. foldHeader's two mutually exclusive branches
became one pass, which also closes a gap the address-list branch left
open: it never re-folded inside a part, so one recipient with a long
encoded display name still produced a 1058-octet line. Verified through
Python's stdlib parser: exact round-trip, no U+FFFD, zero defects, every
line at most 73 octets.

The test harness recorded no rejection when nothing matched and there was
no _default, while the generated dispatcher calls setReject and the sender
gets a non-delivery report. A test for catch-all or anti-spam behavior
could therefore observe an accepted message that deployment bounces. The
harness now rejects with the dispatcher's reason byte for byte, built from
the wire recipient with its case preserved, and routed through setReject
so the terminal-reject semantics stay identical.

PARSE_FAILED was declared in the ParseEmailError code union and never
constructed. postal-mime is lenient enough that binary garbage, truncated
parts and bad header bytes all parse; what it does reject is a nesting
bomb, which escaped as a bare Error carrying parser internals. It is now
wrapped, with the original kept as cause. The reviewer also claimed
handlers could not distinguish oversize from parse failure. They cannot
distinguish anything: ParseEmailError is not exported from void/email at
all. Exporting it is a scope decision, not a bug fix, so the class stays
internal.

The Class B/C finding was right about the mechanism and my own
documentation was wrong about the cause. I had written that those
frameworks lack an assets binding. In fact Void never installs the
Cloudflare Vite plugin for them, so both the dev-inbox plugin and
mergeBindings are out of reach, and declaring an assets binding would not
help: SvelteKit runs in Node and Nuxt and Analog run in worker_threads, a
different JS realm, so there is no workerd-behind-Vite loopback to bind
to. Only Astro could work. The guide now states the real boundary and
points those users at the in-process harness. Supporting them needs three
different mechanisms for four frameworks and is left as a design task.

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

Three more review findings. A fourth, about recipient local-part casing,
was verified and declined; see below.

The address shape check only required an '@' to be present anywhere, so
user@, @example.com and a@b@c passed the preflight. Measured, sendEmail
returned ok:true for all three and the dev inbox showed a capture, while
the platform then refused them - as INVALID_FROM for a sender, but for a
recipient as a mid-batch UNVERIFIED_DESTINATION rather than the advertised
INVALID_TO. Nothing malformed could ever reach Cloudflare, because the api
already gates every allowlist entry with this rule, so the defect is a
dev-prod divergence rather than a delivery or injection one. Header
injection was already closed: comma, semicolon, angle, quote, whitespace
and CRLF all threw.

The check now requires exactly one separator with a non-empty local part
and domain. Deliberately not the api's isValidEmail, which also requires a
dot in the domain and would newly reject user@localhost and x@y, both
legal RFC 5321 and accepted today; both are pinned by tests. Quoted local
parts were already rejected by the existing specials class, so the
stricter separator rule breaks nothing that worked.

The introductory docs example read result.error after only narrowing on
result.ok. SendEmailResult has two failure members and error exists on
just one of them, so the example threw a TypeError rather than printing
the error - and it failed to typecheck, which is how a reader would first
notice. It sits on the most common failure path: UNVERIFIED_DESTINATION is
reachable only through the per-recipient branch, so the first send to an
unverified recipient hits exactly this. The example now narrows on
'error' in result and handles both branches, and the error-code table says
which branch carries which code.

void email logs --limit is parsed, honoured and documented in the CLI
reference, but the help page was built with the shared project-only helper
so the flag was invisible in every help surface. It is the only such
mismatch across the five email subcommands: the parser accepts --limit
everywhere but only logs forwards it. cli-help.test.ts previously asserted
path resolution only and never rendered content, so nothing could have
caught this; it now renders the pages and asserts the flags.

Declined: the envelope recipient array is lowercased before the POST,
which RFC 5321 section 2.3.11 says an intermediate should not do to a
local part. Fixing it in the framework alone changes zero bytes on the
wire, because the proxy lowercases again on receipt and hands that same
string to EmailMessage - proven by running the real proxy worker. The
genuine fix spans both repos and is gated on unverified Cloudflare
behaviour: the api registers the lowercased address as the verified
destination, so transmitting mixed case risks tripping Cloudflare's own
verification check. That is a decision about the canonical address form,
not a bug fix, and the codebase already made the opposite choice
deliberately for the sender, which is transmitted verbatim under a named
test.

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

Four review findings, all verified against running code. Two of them are
holes left by my own earlier fixes in this branch.

void/email was never added to SERVER_ONLY_STUBS, so a shared module reached
from a client graph pulled in runtime/env-raw.ts, whose node:async_hooks
import and module-scope AsyncLocalStorage have no browser equivalent. The
build does not fail; it succeeds with an externalization warning and ships
new (emptyStub).AsyncLocalStorage, so the page white-screens on load. Proven
by ablation on a real playground build: without the stub the warning appears
and AsyncLocalStorage lands in the client chunk, with it neither does.
void/email/testing needs its own entry because the resolve filter is
anchored, and for a different reason - it drags node:path, node:fs and
node:module in through router/email.ts. Both stubs throw rather than
returning an empty object, following void/sandbox, and their export lists
were checked against the real modules programmatically.

The RFC 2047 chunking added earlier only covers non-ASCII values. A
pure-ASCII token bypasses encodeWord entirely and leaks through two separate
paths in foldHeader - the single-chunk early return and the fold loop, which
still gives an over-long chunk its own line - so a 1100-character token
produced a 1109-octet line against RFC 5322 section 2.1.1, which is a MUST.
Nine header paths were affected, including the header name and the
filename/name/Content-ID parameters that are built outside foldHeader. One
post-render guard covers them all. Encoding instead of rejecting is not a
general remedy: RFC 2047 section 5 permits encoded-words only in unstructured
fields, comments and phrases, so it is illegal in References, In-Reply-To,
Content-ID and parameter values. Truncating corrupts silently - a clipped
References breaks threading with no signal.

That guard needed a companion. buildReplyMessage threads the inbound
Message-ID and References verbatim, and a remote sender controls both, so
the guard would have let anyone make an app's reply throw. Threading is
best-effort, so a value that cannot be represented is dropped and the reply
still sends.

Attachment filenames were emitted into quoted-string parameters with quotes
deleted and backslashes left raw. qtext excludes both, so each may appear
only as a quoted-pair: a trailing backslash escaped the closing quote and
left the parameter unterminated, and every embedded backslash was silently
eaten, so a Windows path C:\Users\bob\report.pdf arrived as
C:Usersbobreport.pdf. This file's own folder already tracks quoted-pairs, so
the emitter was contradicting it. All five cases now round-trip exactly
through an independent parser with zero defects. Not a security issue -
rejectCRLF already closes header injection and the quote strip closed
parameter injection.

The shipped agent prompt still said every void dev send is captured by the
local inbox, which the guide stopped claiming last round. That prompt seeds
agents, so it would have propagated the wrong guidance into user projects.

Handed back, not fixed here: attachment filenames are still emitted as raw
8-bit UTF-8 rather than RFC 2231, which is inconsistent with the RFC 2047
treatment every other header string in the file gets. Modern parsers recover
them correctly; a naive one produces mojibake. Separate defect, separate
compatibility trade-off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
…k boundary

Three review findings on the email re-land.

1. `email/` was invisible to every deploy decision that asks "does this
   project need a worker". An email-only project took the static-site path,
   which uploads no worker script at all, so the handlers were dropped and
   inbound mail bounced. Fixed at all three sites:
     - `findBackendSources` / `hasBackendFiles` (preset.ts) now count
       `paths.emailDir`, so `detectPreset` stops returning a static preset
       for an email-only project and the `--backend cloudflare` reject
       message lists `email/`.
     - the fully-static optimization in `runFullDeploy` now derives
       `hasEmail` from `routeScan.email`. The predicate is extracted as
       the exported `hasNonPageBackend`, following `hasPrerenderSources`
       25 lines above it, because the inline expression had no test seam.

   The manifest `ssr` flag is deliberately NOT touched. Dispatch uses it
   for request-type metering (`'ssr'` vs `'static'`), so threading email
   into it would mis-meter every request on a page-less project.

2. The harness rebuilt `ForwardableEmailMessage.headers` from `input`
   only, so `buildMime`'s generated `Message-ID` never reached the
   handler and `replyEmail` produced an unthreaded reply that production
   would have threaded. Headers are now parsed back out of the wire
   bytes, which also fixes the `raw:` override path.

3. Inbound is Void-app-only. `routingPlugin` compiles the `email()`
   export and no framework branch installs it — the framework wrapper
   wires crons and queues only. An `email/` directory under TanStack
   Start, React Router, SvelteKit, Nuxt, Analog or Astro is now a build
   error next to the existing auth / sandbox / live guards, instead of
   silently dead code. Gated on the handler directory, not on
   `bindings.needsEmail`: outbound `sendEmail` is a binding and keeps
   working everywhere.

   The guide is corrected to match. It also claimed inbound provisioning
   was pending "the platform's Phase 3" and that handlers sit dormant.
   That is stale — `meta/operations.md` documents one shared mail
   facility with a single zone catch-all and no per-project DNS work,
   so handlers are live as soon as the deploy lands.

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

Two review findings acted on, one declined with evidence.

1. `quotedPrintableDecode` accepted any `=XY` pair that merely STARTED with
   a hex digit: `parseInt('0G', 16)` is 0, not NaN, so `=0G` decoded to a
   NUL and `normalizeEmail` then threw `MIME_ERROR` on the reply subject.
   Both characters must now be hex; a malformed escape stays literal, as
   RFC 2045 §6.7 asks and as postal-mime does.

   That predicate was one of several doors to the same throw. A
   well-formed `=00` / `=0A`, a B-encoded NUL, or a 999-octet ASCII subject
   with no whitespace all reached `rejectCRLF` or the 998-octet guard from
   the same remote-sender-controlled header — and in production a rejected
   handler bounces the whole delivery, replies and forwards alike. The
   default subject is now best-effort, like the threading headers already
   were: C0 controls become a space, and an ASCII subject no line can hold
   falls back to a bare `Re:`. A non-ASCII subject is never rejected, since
   `encodeWord` splits it into foldable encoded-words. Seven tests pin
   each door and both controls.

2. `AGENT_PROMPT.md`, which `void init` injects into the project's agent
   instructions, and `getting-started-prompt.txt` both advertised `email/`
   for inbound without the Void-app-only boundary. The previous round had
   qualified the dev inbox in the latter, not inbound handlers. Both now
   say the directory is a build error under a third-party framework and
   that `sendEmail` keeps working.

Declined: gating the `WorkerEntrypoint` entry on `hasEmail` for workers
pinned before the 2024-04-03 RPC compatibility date. The `rpc` flag gates
the CALLER's Fetcher stub, not the callee's class. Verified in workerd
1.20260701.1: a `WorkerEntrypoint` subclass serves `fetch`, `scheduled`,
`queue` and RPC `email()` at compatibility dates back to 2021-11-03 and
under an explicit `no_rpc`. The only caller is the platform proxy at
2026-02-12. The premise — Void preserves the user's date with no floor and
the conversion is unconditional — is true and now documented in the
review thread; the failure it predicts does not exist.

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

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

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

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

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

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

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

Where the content went:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SStP281NuirMSouULEE2pW
`void deploy --backend cloudflare` succeeds, but sendEmail returns
BINDING_MISSING and email/ handlers are not routed (no rule, no binding,
actions not applied). State it in the email guide, the Cloudflare
integration scope list, the agent prompt and the getting-started prompt,
and widen the BINDING_MISSING row to name that case.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

chatgpt-codex-connector Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-17T22:43:35.060786Z 16850e7 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

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

@cursor

cursor Bot commented Sep 17, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

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

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

(requestId: serverGenReqId_62e13e41-6e8e-4607-a1bd-7efba4c8f839)

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread platform/packages/api/src/admin/routes/email.tsx Outdated
Comment thread platform/packages/api/src/admin/routes/email.tsx Outdated
Comment thread platform/packages/api/src/email/cf-customer.ts
Comment thread platform/packages/api/src/email/relay-engine.ts Outdated
Comment thread platform/packages/api/src/routes/email.ts Outdated
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.
@cursor

cursor Bot commented Sep 17, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

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

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

(requestId: serverGenReqId_5fa4229f-93d1-444e-abf5-f7d8da9d95a5)

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread platform/packages/api/src/provisioning/cleanup.ts Outdated
Comment thread platform/packages/api/src/routes/email.ts Outdated
Comment thread platform/packages/proxy/src/email-router.ts Outdated
… 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.
@cursor

cursor Bot commented Sep 17, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

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

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

(requestId: serverGenReqId_5e038a13-25da-48f6-a7ca-f81c7bad78b5)

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread platform/packages/proxy/src/email-relay.ts Outdated
Comment thread platform/packages/api/src/email/relay-engine.ts Outdated
Comment thread platform/packages/api/src/admin/routes/email.tsx Outdated
Comment thread platform/packages/api/src/routes/email.ts Outdated
Comment thread platform/packages/api/src/routes/email.ts Outdated
Comment thread packages/void/src/cli/email.ts Outdated
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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread platform/packages/api/src/email/credential-lease.ts
Comment thread platform/packages/api/src/routes/email.ts
Comment thread platform/packages/api/src/email/relay-engine.ts
Comment thread platform/packages/api/src/routes/email.ts Outdated
… 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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread platform/packages/api/src/routes/email.ts
Comment thread platform/packages/api/src/routes/email.ts Outdated
Comment thread platform/packages/api/src/routes/email.ts Outdated
…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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread platform/packages/api/src/routes/email.ts Outdated
Comment thread platform/packages/api/src/routes/email.ts Outdated
…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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread platform/packages/api/src/routes/email.ts Outdated
Comment thread platform/packages/api/src/routes/email.ts
Comment thread platform/packages/api/src/routes/email.ts Outdated
Comment thread platform/packages/api/src/admin/routes/email.tsx Outdated
Comment thread platform/packages/api/src/routes/email.ts Outdated
Comment thread platform/packages/proxy/src/email-router.ts Outdated
… 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).

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread platform/packages/api/src/routes/email.ts Outdated
Comment thread packages/void/src/cli/email.ts Outdated
Comment thread packages/void/src/cli/cf-oauth.ts
Comment thread platform/packages/api/src/admin/routes/email.tsx Outdated
…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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread platform/packages/api/src/email/relay-worker.ts Outdated
Comment thread packages/void/src/cli/email.ts Outdated
Comment thread platform/packages/api/src/routes/email.ts Outdated
…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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread platform/packages/proxy/src/email-router.ts Outdated
Comment thread packages/void/src/cli/email.ts Outdated
Comment thread packages/void/src/cli/cf-oauth.ts Outdated
Comment thread platform/packages/api/src/routes/internal-email.ts Outdated
…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)

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread packages/void/src/cli/email.ts
Comment thread platform/packages/api/src/email/relay-engine.ts Outdated
Comment thread platform/packages/proxy/src/email-relay.ts
…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.
@Brooooooklyn
Brooooooklyn merged commit 8b547b3 into main Sep 18, 2026
19 checks passed
@Brooooooklyn
Brooooooklyn deleted the feat/email-reland branch September 18, 2026 02:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant