Skip to content

feat(auth): Login-with-Raft OAuth (dual human+agent flow) for mail.build - #7

Open
stdrc wants to merge 49 commits into
feat/per-agent-authfrom
feat/raft-oauth-session
Open

feat(auth): Login-with-Raft OAuth (dual human+agent flow) for mail.build#7
stdrc wants to merge 49 commits into
feat/per-agent-authfrom
feat/raft-oauth-session

Conversation

@stdrc

@stdrc stdrc commented Jul 15, 2026

Copy link
Copy Markdown
Member

Login-with-Raft OAuth for mail.build, on top of @postel's per-agent-auth (PR #6). Both humans (browser) and agents (CLI) sign in and become first-class mailbox owners (owner-scoped, authScope="account").

What's here

  • workers/lib/session.ts — AES-GCM sealed session cookie (agentic_inbox_session, HttpOnly+SameSite=Lax+Secure) + constant-time CSRF login-state. Re-validates principal shape on open (defense-in-depth). (reviewed GREEN by Postel)
  • workers/lib/raftAuth.ts — dual-flow token exchange: human=authorization_code, agent=urn:slock:grant-type:agent_request+request_id. Basic auth uses the PUBLIC client key (never the app UUID) + application/x-www-form-urlencoded — this is the slock-internal-dashboard PR #66 fix, so agent dogfooding won't hit the same 403. validateRaftPrincipal trusts only immutable claims (sub/type/server_id/client_id) and enforces botiverse-only via the shared serverAllowed. (reviewed GREEN by Postel)
  • workers/app.ts — session becomes the primary gate. Chain: scoped-key(Bearer) → raft session(cookie) → legacy global-key → no-identity (api/programmatic → 401, browser → 302 /auth/raft/login). /auth/raft/* exempt. CF Access kept as inert legacy (workers.dev only; unset on mail.build). Routes /auth/raft/login (CSRF state → setup) + /auth/raft/callback (dispatch by state-presence → exchange → validate → seal).
  • wrangler/typesRAFT_OAUTH_CLIENT_KEY(public) / RAFT_API_ORIGIN / RAFT_APP_ORIGIN vars; RAFT_OAUTH_CLIENT_SECRET / RAFT_SESSION_SECRET secrets. ALLOWED_SERVER_IDS=95f993fa… (botiverse, verified against live userinfo).
  • 25 tests — session seal/open + expiry + tamper + wrong-secret + CSRF; dual-flow exchange asserts public-clientKey Basic auth + form-urlencoded + correct grant_type; validation (server_not_allowed / client_not_allowed / malformed / bad-type).

Gates: typecheck clean; vitest run 65/65 (my 25 + Postel's 40, no regression).

Review focus (per Postel)

  1. callback state-validation order (browser requires matching CSRF state; agent forbids state)
  2. no-identity 401 vs 302 split (wantsHtmlRedirect)
  3. exempt paths not leaking (isAuthExemptPath)
  4. session cookie clear on failure + Cache-Control: no-store
  5. server allow-list enforced once at login only (trust sealed session per-request)

Deploy prereqs (not in this PR — teed up after review)

Register Connected App → public client_key + secret → fill RAFT_OAUTH_CLIENT_KEY + wrangler secret put RAFT_OAUTH_CLIENT_SECRET; generate RAFT_SESSION_SECRET; mail.build custom domain → worker route; then deploy + dogfood.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

Gogo and others added 30 commits July 15, 2026 22:26
…s, raftAuth.ts)

Core libs for the mail.build per-agent-auth OAuth port (not yet wired into app.ts):
- session.ts: AES-GCM sealed session cookie + constant-time CSRF login-state
- raftAuth.ts: dual human(authorization_code)/agent(agent_request) exchange,
  public-clientKey Basic auth (form-urlencoded), botiverse-only server gate,
  owner = raft:server:type:sub

Faithful to me.build session + slock-internal-dashboard PR #66 (the fresh
agent-grant fix). Remaining: wire routes + middleware (locked gating) + wrangler
env + tests -> PR.
- app.ts: session becomes the primary gate on mail.build. Auth chain =
  scoped-key(Bearer) -> raft session(cookie) -> legacy global-key -> no-identity
  (api/programmatic 401, browser 302 -> /auth/raft/login). /auth/raft/* exempt.
  CF Access kept as inert legacy (workers.dev only; unset on mail.build).
- routes: /auth/raft/login (CSRF state -> raft setup) + /auth/raft/callback
  (browser=authorization_code+state, agent=agent_request; validate -> seal session).
- botiverse-only server gate enforced ONCE at login (validateRaftPrincipal via
  shared serverAllowed); per-request trusts the sealed session.
- wrangler: RAFT_OAUTH_CLIENT_KEY(public)/RAFT_API_ORIGIN/RAFT_APP_ORIGIN vars;
  RAFT_OAUTH_CLIENT_SECRET/RAFT_SESSION_SECRET secrets.
- 25 tests (session seal/open+expiry+tamper+CSRF; dual-flow grant asserts
  public-clientKey Basic auth + form-urlencoded + correct grant_type; validation).

typecheck clean; full suite 65/65 green (incl. Postel's 40).
… replaces by default)

Dogfood caught: callback set two Set-Cookie via c.header() which REPLACES, dropping
the session cookie (only clear-login-state survived) → no session → 401. Use
{append:true}. Also fills RAFT_OAUTH_CLIENT_KEY=agentic-inbox (deploy config).
…t-manifest.json

Dogfood found the Raft integration CLI couldn't complete (invoke/env failed,
session cookie not stored) because the app's agent-behavior manifest was
unreachable (401: no route + not auth-exempt). Add a public manifest route
(auth-exempt) describing the login-with-raft auth + the HTTP API actions
(claim-mailbox, list-mailboxes, list-emails, get-email). Origins are derived
from the request so it's valid on both the workers.dev interim URL and mail.build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
…ic claim

- wrangler: PRO_SERVER_IDS += botiverse (95f993fa...) -> pro=10 mailboxes/agent
  (raft userinfo carries no tier field, so pro is a config allowlist, not a
  login-time claim).
- keyRegistry: store the claimed display name as KV metadata on the per-owner
  index; list-mailboxes now returns it instead of the bare address
  (dogfood: Maggie — list showed the email, not the claimed name).
- UI: optimistically add a just-claimed mailbox to the cache so it shows
  immediately despite the ~<=60s KV list-index lag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
Dogfood findings (Gogo/Box/Duoyu/Cardy, 3-agent cohort):

P0 adopt-on-claim: the 7/13 admin-provisioned canonical <handle>@ mailboxes
were stored with no owner field -> under self-serve, claim returned 409, list
hid them, read 403'd = dead end for every agent's own address. Claiming an
existing ownerless mailbox that is under the caller's handle namespace now
ADOPTS it (stamps owner + mints a scoped key) instead of 409'ing. Ownership
disposition extracted to a pure, unit-tested classifyClaim()
(create/adopt/idempotent/taken); anti-squat namespace gate still enforced first.
Re-claiming your own mailbox is now idempotent (200, no new key, quota-exempt).

P1 error codes: claim + read return a machine code (AUTH_REQUIRED,
ADDRESS_NOT_ALLOWED, NAMESPACE_FORBIDDEN, QUOTA_EXCEEDED, MAILBOX_TAKEN,
MAILBOX_NOT_LINKED, FORBIDDEN) so the CLI stops collapsing every 403 into
'session expired -> re-login'. Read path distinguishes an ownerless mailbox
(claim it) from one owned by another account.

P2 copy: manifest states v0 is inbound-only (no send/reply) and that raft-native
calls authenticate via the stored session and do not need the shown-once key
(answers Bugen's 'dead credential' concern).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
…); fix flaky session tamper test

Dogfood (Bugen + Gogo): get-email returned the raw DO row (body/sender/
recipient) but the manifest/docs promised body_text/body_html/from/to -> an
agent coding to body_text got undefined. Wire the route through getFullEmail so
it returns body_text (HTML stripped to plain, grep-safe for codes/links) and
body_html (null when no HTML part), plus from/to aliases over sender/recipient.
Original fields kept for the human UI (non-breaking). Manifest get-email
description now states the exact field contract.

Also fix a ~1/4 flaky session test: it tampered the LAST base64url char, which
can land on padding bits (no-op decode) -> GCM auth passes -> session opens ->
'tampered ciphertext returns null' fails. Tamper the first char (always
significant) instead. (Gogo mis-attributed this to classifyClaim, which is pure.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
…elease

Fixes a P1 quota bypass (dogfood: Cardy) + the list-lag UX (Box) + a delete
authz gap, all rooted in using the eventually-consistent KV mbox: index as the
count/list authority.

- New OwnerDO (per-owner, idFromName(owner)). DO storage is strongly consistent
  and single-threaded, so reserve() is an ATOMIC check-and-reserve — two rapid
  claims can no longer both slip past free=1 (the old kv.list count lagged ~60s).
  list() is lag-free (a just-claimed mailbox shows immediately). Seeds itself once
  from the legacy KV index for pre-DO owners, then is authoritative. migration v4.
- claim: reserve the slot atomically before provisioning; release on failure so a
  crash can't leak a slot. Admin gets an unlimited allowance but is still recorded.
- list-mailboxes: served from the DO (no kv.list).
- DELETE /mailboxes/:id: now owner-scoped (added requireMailbox to the bare
  :mailboxId route — the /* guard missed it, so delete/read/update of a
  not-owned mailbox was possible) and RELEASES the quota slot. Exposed as the
  'release-mailbox' manifest action so testers can clean up throwaway mailboxes
  instead of permanently burning quota (dogfood: Duoyu).
- KV mbox: index kept in sync as a seed/backfill safety net; the DO is the
  count+list authority.

Note: OwnerDO atomicity is DO behavior (not unit-testable without the workers
pool) — relying on Gogo review + cohort re-test (quota-deny + list-consistent +
release-frees-slot). Pure plan-limit logic remains covered in auth.test.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
internal-loopback send (tygg greenlit '可以先互发试试'; Gogo security-endorsed):
POST /mailboxes/:id/send delivers FROM an owned mailbox TO another mailbox on a
configured domain by writing straight into the recipient's inbox (reusing the
inbound createEmail(INBOX) path) — NO external SMTP egress, so no
SPF/DKIM/DMARC/open-relay/spoofing surface. Owner-scoped (requireMailbox gates
the FROM), recipient must already exist, sender rate-limit applies, copy saved to
Sent. Exposed as the 'send-mail' manifest action. External outbound stays a
separate v0.1 (domain-auth + abuse/rate/audit). Lets the dogfood cohort send to
each other in-app.

NOT_FOUND code on the agent-facing 404s (requireMailbox, get-email, get-mailbox,
send recipient) so the error contract is consistent with the 4xx codes shipped
earlier — 403 had a code, 404 didn't (dogfood: Duoyu).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
Bugen's live verify of 2baa0e8a: body_html was non-null for a text/plain-only
message — it held the raw plain text — so 'body_html === null' (the documented
'no HTML part' signal) gave false negatives and an agent could render plain text
as HTML. Root cause: getFullEmail set body_html = email.body unconditionally, and
the DO's single body column doesn't distinguish HTML from plain.

Add looksLikeHtml() (detects a closing tag or a known HTML element; a bare
<name>@host token does NOT count) and return body_html only when the body
actually contains markup, else null. body_text is unchanged (always stripped).
Unit-tested the plain/markup/false-positive cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
Dogfood (Duoyu): the DO computes snippet as SUBSTR(body,1,300), which is raw
HTML for HTML mail — so list-emails snippet showed literal <p>/<div> tags (a UI
preview would render them), and get-email had no snippet at all (the emails table
has no snippet column; it's only a list-query SUBSTR).

- list-emails: strip snippet to plain text on every return shape (threaded /
  folder+count / plain).
- get-email: derive snippet from the already-stripped body_text (matches the
  manifest description, which advertised snippet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
…trip)

Follow-up to the snippet strip fix (dogfood: Gogo saw an empty snippet on an
HTML message). Root cause: snippet = stripHtmlToText(SUBSTR(body,1,300)); when
the first 300 chars of an HTML body are all markup/inline-style, stripping leaves
nothing. Not internal-send-specific — inbound stores body = html||text the same
way; it just depends on where the first visible text sits.

Widen the DO's snippet SUBSTR window to 2000 chars (all 4 query sites) so real
text survives a long leading markup block, and trim the stripped result to 300 in
the route. Proper long-term fix is a stored stripped-snippet column at write time
(needs a migration) — deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
…ssing)

Dogfood (Gogo): send-mail's params were 'see-desc', so an agent guessed the body
field was 'body' and sent {body:...} — silently ignored (route reads
{to,subject,text,html}) → empty message + empty snippet. Add explicit body field
docs to claim-mailbox and send-mail (calling out that the text field is ,
not ), and document list-emails' query params + that its rows are
lightweight (snippet, no full body — use get-email). Prevents silent empty sends
from wrong field names.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
Dogfood (Duoyu, also Maggie): stripHtmlToText removed tags but left literal HTML
entities, so body_text/snippet kept &amp; &lt; &nbsp; &cloudflare#39; etc. — which falsifies
the advertised "safe to grep for codes/links":
  - a URL "...?token=abc&amp;uid=42" extracts as "...&amp;uid=42" -> broken link
    (params become "amp;uid"); magic-link extraction is the #1 use case and any
    2-param verification link carries "&".
  - "AT&amp;T-9931" misses a grep for "AT&T-9931".

Add decodeHtmlEntities (named + numeric dec/hex, &nbsp;->space) and run it after
tag-stripping (so decoded </> can't reintroduce markup). Each &...; token is
decoded once, so double-encoded text stays literal. Reply-quote path re-escapes
after stripping, so no regression there. Unit-tested the URL/AT&T/nbsp/numeric/
double-encode/unknown/script-leak cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
Duoyu flagged that the entity-decode fix (b3a8f98) would be a vulnerability if it
ever touched body_html: body_html is rendered, so decoding a sender's escaped
&lt;script&gt; back into a live <script> injects XSS. It does NOT today —
body_html = raw email.body; decodeHtmlEntities only runs via stripHtmlToText on
body_text/snippet (never rendered). Add a SECURITY comment at the assignment and
a regression test asserting body_html keeps &lt;script&gt; literal while body_text
decodes it, so a future refactor can't collapse the two.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
For raft #4894 (CLI stops flattening app-4xx into auth-403) + Maggie's Manual.
Add docs/agent-error-codes.md with the mechanical rule (has a code -> surface it;
bare auth-layer 403 -> the session message) and the full code table with HTTP
status / trigger / remedy.

Complete code coverage on the agent action surface so "every app 4xx carries a
code" is universally true (the rule depends on it): add codes to the previously
codeless claim body-validation (BAD_REQUEST), requireMailbox missing-id
(BAD_REQUEST), release/settings not-found (NOT_FOUND), and the send
sender-validation / rate-limit paths (BAD_REQUEST / RATE_LIMITED).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
A bare `wrangler deploy` ships the stale prebuilt build/ artifact without
rebuilding, so source changes don't go live even though the version ID bumps and
the receipt says "Deployed". Document that deploy = `npm run deploy` (build-first)
and that a version-ID bump is not evidence the code changed — verify behaviorally
(curl/invoke the live response), or `--dry-run --outdir` + grep to inspect a
bundle. (Caught by Gogo 2026-07-15 on the error-code backfill deploy.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
First slice of the tracing effort (tygg) — the version layer, which is scope-
independent and directly closes tonight's stale-deploy hole (Gogo).

- vite.config.ts injects the git short-sha + build time at build (Vite `define`),
  so the stamp tracks the actual bundle — a hand-written constant would itself go
  stale. Falls back to CI SHAs then "unknown" (honest, never a fake sha).
- GET /health (public, auth-exempt) → {status, version, build_time}; version is a
  plaintext field so monitoring can assert running-version == expected deployed
  SHA and auto-catch a stale deploy (no more manual-curl luck).
- X-Agentic-Inbox-Version header on every response.

Verified behaviorally (not by receipt): built the bundle and grepped the worker
entry — it carries the real HEAD sha (ca97f64), confirming the define injects
rather than shipping "unknown".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
Walk the human web UI end-to-end (the un-dogfooded half of the dual-audience
product): login redirect + botiverse gate, mailbox list (own-only), claim +
shown-once key, read a seeded email (iframe-rendered, code/link visible), folders
+ threading, compose + internal send, search/settings, session persistence.
Includes negative cases and asks reporters to attach the version header on a
failure. Awaiting tygg's call on the tester + botiverse register/login readiness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
…owner isolation invariant)

The human browser-login path (authorization_code grant) had zero test
coverage — only agent principals were exercised anywhere. Before the first
live human dogfood (Artea), lock the unit-level contract:

- session: a HUMAN principal seals + reopens with type="human" intact
- validateRaftPrincipal: accepts valid human userinfo
- ownerFromPrincipal: derives raft:{server}:human:{sub}
- isolation invariant: ownerFromPrincipal(human) != ownerFromPrincipal(agent)
  even at identical sub/server — a regression guard on the type-in-owner-key
  property behind cross-principal mailbox isolation.

Test-only; no runtime change.

Signed-off-by: Gogo <gogo@mail.build>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…act)

Maggie (Manual owner): make the code table retrievable via the manifest, not just
the repo doc, so an agent can look up what a code means without leaving the
integration surface. Add a compact `errors` map (code -> HTTP status + meaning +
remedy) to the agent-behavior manifest, with the same rule up front: read `code`
to decide; only a bare auth-layer 401/403 with no code means re-login. Full detail
stays in docs/agent-error-codes.md; the Raft Manual takes the general integration
rule (Maggie owns). No behavior change — manifest metadata only; ships with the
next redeploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
…directory)

Dogfood (Artea, first real human to walk the UI): a logged-in human saw a
"Mailboxes" list of everyone's addresses (gogo@/ray@/jianwei@/…) and no way to
claim. Root cause was a legacy single-tenant/provision-all model in home.tsx that
predates per-owner claim (agent testing used the CLI, so the UI was never walked):
- it rendered the whole EMAIL_ADDRESSES config directory as the mailbox list,
- auto-claimed every configured address on load (anti-squat blocked most, but it
  fired a burst of failing claims and was conceptually wrong),
- and hid the claim/delete buttons behind `isConfigured` (always true, since
  EMAIL_ADDRESSES is populated) — hence "no place to claim".

Fix: render only the user's OWNED mailboxes (useMailboxes), always show the
claim + delete buttons, remove the auto-claim effect, clearer empty state.

Defense-in-depth (backend): GET /mailboxes now FAILS CLOSED — only a verified
admin sees all; anyone else sees only their own (empty if none). The old fallback
returned ALL mailboxes for any request without a clean owner — a fail-open leak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
Dogfood (Gogo, human side, live via Playwright login-with-raft): a human whose
raft handle contains a hyphen (e.g. `gogo-signup-dogfood`) could claim NOTHING —
every claim 403'd. claimAllowedForHandle folded the local-part to its first
hyphen-segment (reservedHandleForLocalPart: `gogo-signup-dogfood` -> `gogo`) and
compared that to the WHOLE handle, so `"gogo" === "gogo-signup-dogfood"` was
always false. Agents never hit it — agent handles have no hyphen — which is
exactly why testing the human side mattered.

Anchor to the caller's full handle instead: the claimable namespace is
`<handle>@` and `<handle>-*@`, i.e. lp === handle || lp.startsWith(handle + "-").
Regression tests: a hyphenated handle claims its own namespace, and still can't
claim a different/shorter handle's bare name. Existing anti-squat cases unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
Dogfood (tygg): visiting `http://mail.build` (no s) produced the OAuth error
"returnUrl does not match registered OAuth client". The login route built the
callback from the request URL, inheriting its scheme, so an http request yielded
return_to=http://mail.build/auth/raft/callback — which doesn't match the
registered https:// redirect. Force the callback to https for non-localhost hosts
(keep http for local dev), so it always matches the registered redirect
regardless of how the user typed the URL. (Edge "Always Use HTTPS" is a
complementary infra-side fix.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
tygg: keys need a rotate interface, and we can't just hand over a bare key — both
human and agent need guidance. Validates dogfood (Bugen: key felt like a dead
credential with no guidance; Maggie: wanted rotate/re-issue).

Backend (keyRegistry + routes):
- rotateKey: MINT new first, THEN revoke prior live key(s) at that scope — so a
  mint failure never locks the owner out (Gogo's atomicity bar: prefer a brief
  "both valid" over any "both invalid"). Ends with exactly one active key/scope.
- listOwnerKeys (metadata only, never the raw token) + revokeOwnerKey (owner-scoped;
  principal A can't touch B's key). keyGuidance() structured onboarding block.
- Routes (all under requireMailbox = owner-scoped): POST .../keys/rotate,
  GET .../keys, DELETE .../keys/:keyId. Claim + rotate responses carry
  `key_guidance` so a bare credential is never handed out.
- Manifest: rotate-mailbox-key / list-mailbox-keys / revoke-mailbox-key actions.
- Tests: rotate (new valid / old dead / one active / scope-isolated), no-plaintext
  leak, cross-principal revoke denial, guidance shape. 93/93.

Human UI (home.tsx): key dialog now renders the guidance (what / how to use /
rotate / raft-native-doesn't-need-it); a per-mailbox rotate (key) button issues a
new key and shows it once.

@gogo review-gates the key-security half (atomicity/authz/revoke/no-leak).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
DELETE /mailboxes/:id/keys/:keyId now also requires the key's scope to equal the
path mailbox, so a revoke under mailbox A's path can't touch mailbox B's key
(path-scope consistency). Not an escalation — the owner gate already scoped it to
their own keys — but tighter and less surprising. revokeOwnerKey takes an
optional scope; test covers wrong-scope denial + correct-scope success.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
tygg: mail.build needs its own landing page, then login with raft. Signed-out
browsers were bounced straight into the raft OAuth with no landing. Now an
unauthenticated browser gets a self-contained landing page (product intro +
"Login with Raft" CTA); only clicking it starts OAuth. The CTA carries the `next`
param so login returns the visitor where they were headed. Authed browsers and
/api (401) are unchanged; server-rendered + inline styles so it needs no SPA
assets. Can iterate into a fuller SPA landing later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
The onNewEmail auto-draft fired on every inbound email unconditionally, which
(a) ran Workers AI ~3-4x per message (injection checks + draft + verify) = the
dominant AI cost driver, and (b) drafted autonomously, conflicting with the
"no autonomous draft/write without explicit human instruction" discipline. Gate
it behind an explicit per-mailbox `autoDraft.enabled` setting (default off);
direction is MCP-forward (a user's own agent manages the mail) over a built-in
model. Reversible — opt-in re-enables per mailbox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
Gogo flagged: /mcp requires auth, but the MCP tools ignore identity
(verifyMailbox = existence-only, list_mailboxes = all), so any authenticated
non-admin caller (any scoped-key holder) could hit /mcp and enumerate/read every
tenant's mailboxes = authenticated cross-tenant exposure. It's latent (not in the
manifest, agents use the REST API) but reachable. Close it now: restrict /mcp to
admin until the per-tool owner-scoping rebuild lands. No real-world impact (/mcp
isn't advertised or in active agent use); admin can still exercise it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
Postel and others added 19 commits July 18, 2026 05:57
…n manifest

AX dogfood (HuangSong + 跳虎, fresh first-time agents):
- send-mail silently dropped in_reply_to / attachments and still returned 202
  "sent" — false confidence that threading/attachments worked (a silent partial
  success, the hardest kind to notice). Now reject any field other than
  to/subject/text/html with 400 UNSUPPORTED_FIELD (listing them), so a 202 means
  exactly what you sent was delivered. (No threading/attachments in v0.)
- The manifest carried a `body` schema but the CLI's --list-actions doesn't render
  it, so the "recommended next command" (claim with no args) errored immediately
  and the required fields / @mail.build domain were undiscoverable. Inline the
  required fields + domain + example into the claim-mailbox and send-mail
  descriptions (which the CLI does show).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Postel <postel@mail.build>
…an be revoked in one step

AX (HuangSong fresh-agent dogfood): claim/rotate returned the raw key but
not its id, while revoke needs the keyId from list-mailbox-keys — so
"revoke the key I just got" forced an extra list-keys round-trip. mintKey
and rotateKey already return the hash (which list-keys exposes as id);
surface it as keyId on both responses.

Co-authored-by: Postel <postel@mail.build>
…+ actionable errors

AX (跳虎 fresh-agent dogfood): an agent whose raft handle is non-ASCII (e.g.
CJK) was fully locked out of claiming ANY mailbox — the CJK address 400'd
(Invalid email) and any ASCII address 403'd (NAMESPACE_FORBIDDEN, since no
ASCII local-part can anchor to a non-ASCII handle). A whole class of agents
had no path, and neither error said what to do.

- asciiNamespaceForHandle(handle): ASCII handles unchanged; a non-ASCII
  handle gets a stable derived slug `a-<fnv1a-hash>` it can claim under.
- claim endpoint anchors the anti-squat gate on the derived namespace, and
  every rejection (bad shape / non-ASCII local-part / namespace) returns the
  caller's exact claimable `namespace` so the error is actionable, not a dead
  end. Shape/ASCII validation moved before the auth check so malformed input
  returns a clean 400 (relaxed the zod .email() to a plain string to own the
  message). Manifest claim-mailbox description + error map updated.
- tests: asciiNamespaceForHandle (stable/distinct/claimable), isValidAsciiLocalPart,
  and the CJK/shape 400 contract in index.test.ts.

Co-authored-by: Postel <postel@mail.build>
…y (un-break raft-native send-mail)

AX (跳虎 live re-verify): after the strict-field fix (c694d89), `send-mail`
became uncallable via `raft integration invoke` — the CLI merges a POST
action's PATH param into the request BODY (GET/DELETE bind it to the path
correctly), so send arrived with a redundant `mailboxId` that equals the
path, which the new unsupported-field check then 400'd. Before the strict
fix this field was silently dropped, so CLI-send worked by accident; the
strict fix correctly surfaced it but broke the primary agent send path.

Postel's law: be liberal about a harmless redundant path echo (drop a body
`mailboxId` that matches the path param) while staying strict about
MEANINGFUL unsupported fields (in_reply_to/attachments still rejected loud).
A `mailboxId` that does NOT match the path falls through and is rejected.
The underlying CLI POST path-param/body merge is a CLI bug, routed to Ray.
Verified end-to-end on live (a mock can't prove the CLI path itself works).

Co-authored-by: Postel <postel@mail.build>
…k the tolerant-drop boundary

Behavior-identical to 3d10d00 (already live + verified end-to-end): pulls the
send-field validation into a pure `unsupportedSendFields(body, pathMailbox)`
helper so the three boundaries can be unit-tested without bindings (Gogo's
suggestion). Tests cover: == path `mailboxId` dropped (CLI POST path-echo),
≠ path `mailboxId` still reported (misrouting guard), meaningful fields
(in_reply_to/attachments/cc) still reported, and combined cases. 106 tests pass.

Live self-test on 3d10d00 (postel@ via raft-native invoke): send WITH the
`mailboxId` path-echo → 202 + delivered; send WITH `in_reply_to` → 400
UNSUPPORTED_FIELD. Boundary confirmed through the real CLI.

Co-authored-by: Postel <postel@mail.build>
…gment fix (production AX)

First real production use (Yingjun/RisingWave FDE — headless account-verification
flow, DKIM/SPF/DMARC green) surfaced four get-email AX gaps. This lands the three
worker-side ones (#4's durable half is Gogo's DO ingest column, coordinated):

1. Path param `:id` → `:emailId` (get-email route + manifest) to match `mailboxId`
   — agents intuitively tried `emailId` and hit an unhelpful "missing path parameter".
2/3. get-email is now LEAN by default: raw `body` (redundant with body_html/body_text)
   and `raw_headers` (large, rarely needed) are DROPPED unless requested via
   `?include=raw_body` / `?include=raw_headers`. A verification email is ~8KB of
   HTML+headers for ~60 bytes of signal — agents shouldn't pay those tokens by
   default. The human UI (shares this endpoint) opts into both in api.ts; components
   untouched. Manifest description updated.
4. list-emails snippet no longer leaks mid-tag truncation fragments (`…<img class="s`):
   extracted `cleanSnippet()` drops a trailing incomplete `<…` before stripping.
   Scoped to the preview — stripHtmlToText still never clips a legit trailing `<` in
   full body_text. Interim; Gogo persists a stripped snippet column at ingest (root fix).

Tests: cleanSnippet (5 cases: fragment/entities/plain/truncate/null). tsc + 111 pass + build clean.

Co-authored-by: Postel <postel@mail.build>
…column ingest

Word-boundary snippet primitive (semantics pinned with Gogo): strip via the
same primitive as body_text (snippet is a prefix of body_text — zero drift),
truncate on last whitespace ≤ maxLen (no ellipsis), hard-cut at maxLen when the
first maxLen chars have no whitespace (long URL/token) rather than returning
empty. Dormant export — Gogo's M1–M3 PR (add column + ingest write + self-healing
read-write-back) imports it; the SUBSTR fallback stays cleanSnippet. 6 tests.

Co-authored-by: Postel <postel@mail.build>
…t (drop sender/recipient/raw body from UI)

First-principles interface pass (tygg: pre-launch, all users internal, break
freely). The API exposed DO storage column names (sender/recipient) and a raw
`body` redundant with body_html/body_text. This migrates the whole UI + type onto
the canonical shape so the fields can be deleted:

- app Email type → from/to (not sender/recipient), body_html/body_text (not raw body).
- UI reads from/to everywhere (SingleMessageView, ThreadMessage, EmailPanel,
  EmailPanelDialogs, AgentPanel, useComposeForm, email-list, search-results) and
  renders via new `emailBodyHtml()` helper (body_html, else escaped body_text —
  plain-text mail never renders blank; behavior-equivalent since the iframe
  already collapsed plain-text whitespace). Previews/quotes use body_text directly.
- api.ts get-email opts into `?include=raw_headers` only (UI no longer needs raw body).
- list-emails + search now return canonical rows (from/to aliases + cleaned snippet)
  via a shared `canonicalRows()` — the list/search UI reads `from`.
- get-email manifest description → canonical.

This is the UI base for Gogo's storage PR (feat/durable-snippet-column): he adds
ingest-computed body_text/body_html/snippet columns + deletes sender/recipient/raw
body from the response (safe now that nothing reads them) in one migration+deploy.

tsc -b clean, 116 tests pass, build clean.

Co-authored-by: Postel <postel@mail.build>
… (stop silent drops)

Two fixes to the inbound path, both requested by artin (#proj-mail 3479a648):

1. Plus-addressing: `artin+staging-smoke@mail.build` now delivers to
   `artin@mail.build`. Only the mailbox LOOKUP is normalized (new
   `deliveryMailbox()`); the stored `recipient` keeps every original address
   including the tag, so it stays filterable — the whole point of the feature.
   Also routes the DO instance + settings + auto-draft to the base mailbox.

2. Unknown recipients are now REJECTED IN-SESSION instead of silently dropped.
   Previously the catch-all accepted the message (sender got SMTP 250) and the
   Worker returned early — mail vanished with no bounce. Because catch-all →
   Worker is the only delivery path there is no safety net, so the 250 was final
   and the deceived party is EXTERNAL (a signup flow, a verification email): we
   could neither detect nor correct it. This was not just a plus-addressing gap —
   every unknown/typo'd/released address hit it.

   Implemented with Cloudflare's `setReject()` (permanent SMTP error to the
   connecting server), NEVER by generating a bounce ourselves: spam forges the
   From, so mailing a bounce would make us a backscatter source and get
   mail.build blacklisted — and unknown-recipient probes hit this path constantly
   under a catch-all. In-session reject sends zero mail. (infra review: Gogo.)
   `setReject` was previously unreachable only because our handler typed the
   message as `{raw, rawSize}`, discarding the capability; type widened.

   The enumeration trade-off this introduces (a prober can now distinguish live
   from dead addresses, where the blanket 250 hid it) is documented AT the
   decision site as knowingly accepted, with the reason and the non-regressive
   alternative (rate-limit/tarpit probes) — so a future security review doesn't
   "fix" it by restoring the silent drop, which is the worst state. (Gogo's ask.)

Also: claiming a `+` address now explains that sub-addresses aren't separate
mailboxes and names the base address, instead of a bare INVALID_LOCALPART.

Tests: deliveryMailbox unit cases + end-to-end receiveEmail cases driving the
real MIME→lookup→reject/deliver path (base-mailbox routing, tag preserved on the
stored recipient, unknown base rejected, off-domain rejected, no bounce sent).
tsc -b clean, 128 tests pass, build clean.

Co-authored-by: Postel <postel@mail.build>
…hared mailbox-identity rule

artin asked what happens on reply, which surfaced a defect my plus-addressing
change made reachable: reply-all excludes "yourself" from the recipients by
comparing address strings, so mail addressed to `me+shop@x` did not match the
mailbox `me@x` and got added as a recipient — you would email yourself. The
comparison was always written this way, but plus-addressed mail was silently
dropped before, so the path was dead. Delivering it made it live.

Fix: compare by MAILBOX, not by literal string, in both the to- and cc-side
self-exclusion (`sameMailbox`).

The identity rule now lives in `shared/addresses.ts` and the Worker re-exports
it, so there is exactly ONE definition. Two copies would drift: inbound would
treat `a+tag@x` and `a@x` as the same mailbox while the UI treated them as
different people — which is precisely this bug.

Also widened the vitest include to run `shared/**` tests (they were silently not
running; `shared/` is imported by both the worker and the web UI).

Not addressed here (awaiting artin's product call): whether a reply should go out
FROM the base address (current) or preserve the tag it was received on.

tsc -b clean, 134 tests pass (8 files), build clean.

Co-authored-by: Postel <postel@mail.build>
…ckscatter guard)

Gogo's infra review found that "the inbound path never sends" was only a
BEHAVIORAL guarantee — it held because I read the code, with nothing enforcing
it. The platform offers no help here: `env.EMAIL` is unrestricted (verified on
the live version) and must stay that way, since replies/forwards legitimately
mail arbitrary addresses — so a binding allow-list cannot be the control point.
Without teeth, the property lasts only as long as every future editor reads the
backscatter reasoning in the comments.

Adds an EMAIL.send spy to the inbound test env and asserts zero sends across all
four receive outcomes: normal delivery, +tag delivery, reject-unknown-mailbox
(the actual backscatter case — spam forges the From, so mailing a "bounce" hits
an innocent third party and gets mail.build blacklisted), and reject-off-domain.

Verified these guards actually fail: temporarily injecting an `EMAIL.send` into
the reject path turned exactly the two covering tests red, and the off-domain
test correctly stayed green (that branch returns earlier). Reverted after.

tsc -b clean, 138 tests pass, build clean.

Co-authored-by: Postel <postel@mail.build>
… address hidden

artin's call (#proj-mail 3479a648), matching Proton's documented behaviour:
mail received at `you+shop@mail.build` is now replied to FROM `you+shop@`, not
from the base `you@`. Replying from the base both surprises the correspondent
(that isn't the identity they wrote to) and discloses the main address, which
cancels the point of a per-purpose disposable alias.

- `receivedAtAddress()` (shared) picks OUR address out of the original To/Cc,
  preserving its tag; tolerates `Name <addr>` form and case; falls back to the
  bare mailbox when the message carries no address of ours (e.g. Bcc).
- `validateSender` now accepts the mailbox OR any `+tag` sub-address of it
  (`sameMailbox`). Impersonation stays closed: a different local-part
  (`artin-ci@`) or a different domain (`artin@evil.example`) is still rejected —
  the tag is the ONLY thing ignored.
- Compose uses it for reply/reply-all/forward; a plain compose still sends from
  the mailbox. Agent-facing manifest documents the widened `from`.

NOT verified yet (needs deploy): that Cloudflare's send_email binding accepts a
`+tag` From on our own domain. SPF/DKIM are domain-level so it should be fine,
but I have not sent one. Deliberately NO silent fallback to the base address if
it is refused — a silent downgrade would quietly defeat the intent, which is the
exact failure shape we have been removing from this path. It fails loudly and we
decide then.

tsc -b clean, 148 tests, build clean.

Co-authored-by: Postel <postel@mail.build>
… any +tag recipient)

Caught by a pre-deploy baseline probe, not by review: sending to
`postel+baseline@mail.build` returned 404 "Recipient mailbox does not exist".
The inbound SMTP fix (eac41f3) normalized only the receive path, while the
internal send endpoint still checked existence against the RAW recipient — and
internal send is the ONLY send v0 supports, so plus-addressing would have looked
broken on precisely the path agents use most.

Existence check and delivery now resolve the base mailbox via `deliveryMailbox`;
the stored `recipient` keeps the tag so it stays filterable, matching inbound.
The 404 now names the base address it actually looked for, rather than echoing
the tagged address the caller typed.

Tests: +tag recipient with an existing base mailbox delivers (202, base mailbox,
tag preserved); +tag whose base does not exist still 404s and names the base.

tsc -b clean, 152 tests, build clean.

Co-authored-by: Postel <postel@mail.build>
…wing a human credential

artin's call. Deploys were manual `wrangler deploy` runs from a laptop, so the
only credential available was a HUMAN's Cloudflare login (Gogo declined to use
richard's keychain OAuth to write to production infra — correctly: the authority
to deploy is not authority to act as someone else). No agent could deploy without
either breaching that line or blocking. CI carries its own scoped credential, so
the action is available without borrowing an identity.

Encodes the deploy discipline we've been applying by hand:
- typecheck + tests gate BEFORE deploy — red never reaches production;
- build as its own step, because `wrangler deploy` alone can ship a stale build/
  artifact (that once shipped old code under a fresh version id);
- after deploying, POLL /health until the running version equals the deployed
  short SHA, and fail otherwise — verify by querying the system, not by trusting
  the deploy receipt (Gogo's rule, now automated);
- concurrency group so two deploys can't race.

Triggers: push to main / feat/raft-oauth-session, plus manual workflow_dispatch
with an optional ref.

Requires ONE-TIME human setup, which cannot be done from here: repo secrets
CLOUDFLARE_API_TOKEN (scope to Workers Scripts:Edit — not DNS, Email Routing or
Access) and CLOUDFLARE_ACCOUNT_ID. A guard step fails with that instruction
rather than an opaque auth error if they are absent.

Co-authored-by: Postel <postel@mail.build>
…of pushing

artin's call. Removes the push triggers; `ref` input lets you deploy any
branch/tag/SHA, so work continues on a feature branch while production is an
explicit choice. Notes the GitHub constraint that workflow_dispatch only appears
once the file is on the default branch.

Co-authored-by: Postel <postel@mail.build>
artin: "你应该统一这些路径吧,要代码保持干净". He is right, and I had created
exactly the drift I warned Gogo about on the snippet primitives: the `+tag` rule
lived at each call site, so the inbound path got it and internal send did not —
external mail to `someone+tag@` delivered while agent-to-agent mail 404'd.

`workers/lib/mailboxRef.ts` now owns the mapping (`mailboxOf`, `mailboxKey`,
`mailboxExists`, `readMailboxSettings`, `mailboxStub`, `emailAgentStub`). Every
address→storage/DO lookup routes through it: inbound receive, internal send,
claim, the settings GET/PUT/DELETE routes, requireMailbox, MCP, and the agent.

Two of those were latent bugs, not just duplication:
- requireMailbox resolved the RAW path param, so a `+tag` address looked like a
  different, non-existent mailbox;
- the settings routes re-derived the key from the raw param AFTER the middleware
  had authorized a different (resolved) mailbox — authorization and target could
  diverge.

Adds a structural guard test: no file outside mailboxRef may build a
`mailboxes/<addr>.json` key or name a mailbox/agent Durable Object. Verified it
fails — restoring one hand-built key in mcp/index.ts turned it red. Comments
alone would not have stopped the original split; this does.

tsc -b clean, 155 tests (9 files), build clean.

Co-authored-by: Postel <postel@mail.build>
The workflow only needs to exist on the default branch: workflow_dispatch is
offered solely for workflows on `main`, and its `ref` input already lets a deploy
target this branch. Keeping a second copy here just guarantees the two drift —
the exact failure this branch spent a commit eliminating for mailbox resolution.

Co-authored-by: Postel <postel@mail.build>
artin onboarded the mail.build apex to Cloudflare Email Sending (Enabled /
DNS Configured / 200-per-day quota) and chose to start external sending behind a
domain allow-list rather than opening it outright: we have never sent outbound,
so deliverability is unmeasured. A narrow list proves alignment on a controlled
recipient before every agent can mail anyone.

- `EXTERNAL_SEND_DOMAINS` (gmail.com, botiverse.dev, cat.ms, oranix.io). Empty
  keeps the previous internal-only behaviour.
- Recipients on our own domains still take the internal path unchanged.
- A non-allowed domain returns 400 SEND_EXTERNAL_UNSUPPORTED and NAMES the
  allowed domains, rather than the old blanket "internal-only" message.
- The mailbox-must-exist check now applies to internal delivery only. For an
  external address, whether it exists is the receiving server's answer to give —
  via a bounce, exactly the behaviour we gave our own inbound path today.
- A provider failure returns 502 SEND_FAILED and is NOT filed as Sent. Returning
  202 for a send that did not happen is precisely the lie removed from the
  inbound path in this same batch.

Tests cover the routing decision (internal / allowed-external / refused), that a
refusal names the allow-list and hands nothing to the provider, that an external
recipient is not required to exist locally, and that a provider error surfaces as
502 with nothing filed. 21 in this file, 160 total; tsc + build clean.

Co-authored-by: Postel <postel@mail.build>
…mail Sending

The first real outbound send failed with: "custom header 'Message-ID' is not
allowed. Only whitelisted headers and X-* headers are accepted." Cloudflare
assigns its own Message-ID; we cannot supply one.

Found by sending to a single controlled recipient rather than by opening the
allow-list first — had we gone straight to open sending, every agent's first
external email would have failed instead of one test message.

Worth noting: it failed the way it was designed to. The 502 SEND_FAILED path
surfaced the provider's exact reason and filed nothing as Sent, rather than
returning a 202 for mail that never left.

Consequence recorded in-code: for EXTERNAL mail our stored message_id is not the
one the recipient sees, so replies to it cannot be threaded by that id. Internal
delivery is unaffected.

Co-authored-by: Postel <postel@mail.build>
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