feat(auth): Login-with-Raft OAuth (dual human+agent flow) for mail.build - #7
Open
stdrc wants to merge 38 commits into
Open
feat(auth): Login-with-Raft OAuth (dual human+agent flow) for mail.build#7stdrc wants to merge 38 commits into
stdrc wants to merge 38 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>
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.