feat(auth): Login-with-Raft OAuth (dual human+agent flow) for mail.build - #7
Open
stdrc wants to merge 49 commits into
Open
feat(auth): Login-with-Raft OAuth (dual human+agent flow) for mail.build#7stdrc wants to merge 49 commits into
stdrc wants to merge 49 commits into
Conversation
…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 & < &cloudflare#39; etc. — which falsifies the advertised "safe to grep for codes/links": - a URL "...?token=abc&uid=42" extracts as "...&uid=42" -> broken link (params become "amp;uid"); magic-link extraction is the #1 use case and any 2-param verification link carries "&". - "AT&T-9931" misses a grep for "AT&T-9931". Add decodeHtmlEntities (named + numeric dec/hex, ->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 <script> 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 <script> 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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.validateRaftPrincipaltrusts only immutable claims (sub/type/server_id/client_id) and enforces botiverse-only via the sharedserverAllowed. (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).RAFT_OAUTH_CLIENT_KEY(public) /RAFT_API_ORIGIN/RAFT_APP_ORIGINvars;RAFT_OAUTH_CLIENT_SECRET/RAFT_SESSION_SECRETsecrets.ALLOWED_SERVER_IDS=95f993fa…(botiverse, verified against live userinfo).Gates: typecheck clean;
vitest run65/65 (my 25 + Postel's 40, no regression).Review focus (per Postel)
wantsHtmlRedirect)isAuthExemptPath)Cache-Control: no-storeDeploy prereqs (not in this PR — teed up after review)
Register Connected App → public
client_key+ secret → fillRAFT_OAUTH_CLIENT_KEY+wrangler secret put RAFT_OAUTH_CLIENT_SECRET; generateRAFT_SESSION_SECRET; mail.build custom domain → worker route; then deploy + dogfood.🤖 Generated with Claude Code
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.