# wspc — build a single-file email client > This guide teaches an AI — a coding agent (Claude Code / Codex) OR a general > chat assistant that can write code (Claude app, ChatGPT) — to ask the user a > couple of quick questions about how they use email, then generate a single-file > email client they can open in a browser right away (deploying to a static host > is optional). The result is a real, usable mailbox: read, triage, reply, and > send — backed by the wspc Email API with OAuth login. ## Read this first This document covers ONLY the email-client build flow. The OAuth essentials your app needs are inline in the next section, so you do not have to leave this file to get login working. For the rest of the shared platform mechanics (domains, the full OAuth reference, single-page-app conventions), see the general guide: - → https://wspc.ai/llms.txt Before generating any REST call, also fetch the live email schema and treat it as the final source of truth (this prose may lag the API): - email OpenAPI: https://api.wspc.ai/email/openapi.json Do not invent fields, endpoints, enum values, scopes, or error shapes the live schema does not list. ## OAuth 2.1 device flow (use these EXACT endpoints) Email endpoints accept an OAuth access token — the SAME device flow used by other wspc apps. Do not guess auth endpoints. They live under `/auth/oauth/...` on `api.wspc.ai` — NOT `/oauth/...`. All requests/responses are JSON (not form-urlencoded). The only scope is `wspc:full` — never request `email`, `email:read`, or any other scope; they are not issued. 1. Register a client ONCE, then cache `client_id` in localStorage. Device-flow clients still need a placeholder `redirect_uri` — use the RFC 6749 OOB sentinel: POST https://api.wspc.ai/auth/oauth/register content-type: application/json { "client_name": "My Email Client", "redirect_uris": ["urn:ietf:wg:oauth:2.0:oob"], "grant_types": ["urn:ietf:params:oauth:grant-type:device_code"], "token_endpoint_auth_method": "none" } → { "client_id": "client_...", ... } 2. Start device authorization (JSON body — NOT query params, NOT a scope): POST https://api.wspc.ai/auth/oauth/device content-type: application/json { "client_id": "" } → { "device_code", "user_code", "verification_uri", "verification_uri_complete", "expires_in", "interval" } 3. Open `verification_uri_complete` in a new browser tab/window for the user. It already embeds `user_code`, so do NOT ask the user to type a code. Meanwhile poll the token endpoint every `interval` seconds: POST https://api.wspc.ai/auth/oauth/token content-type: application/json { "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": "", "client_id": "" } - 200 { access_token, refresh_token, expires_in, token_type, scope } — approved - 400 { error: "authorization_pending" } — keep polling - 400 { error: "slow_down" } — add 5s to the interval, keep polling - 400 { error: "expired_token" } — restart at step 2 - 400 { error: "access_denied" } — user denied; surface to UI Schedule each poll with `setTimeout` using the CURRENT interval (re-read it each cycle) rather than a fixed `setInterval` — a fixed interval cannot honor `slow_down`, which must actually lengthen the gap before the next poll. 4. `access_token` TTL is 15 minutes. Refresh with the standard grant (each rotation invalidates the previous refresh token); on refresh failure, clear tokens and restart the device flow: POST https://api.wspc.ai/auth/oauth/token content-type: application/json { "grant_type": "refresh_token", "refresh_token": "", "client_id": "" } Send all data/API traffic to `api.wspc.ai`; use `app.wspc.ai` only for the browser window that completes consent. ## The core idea Unlike a todo app, an email client has NO custom-field schema to design. The shape of an email is fixed: a subject, a sender, a plain-text body, a read flag, the wspc alias it arrived on, and zero or more attachments. There is nothing to model. So what makes ONE person's client feel right is not the data — it is the **workflow and layout**: whether they live in an unread-first triage view, reply constantly, or sort incoming mail across several aliases. A good client starts by learning how THIS user works with mail, then leans the default UX that way. There is also no "folders" or "labels" concept. Organization comes from three things only: the **alias** an email arrived on, its **read/unread** state, and **soft-delete** (trash). Build around those, not around an invented label system. Follow the phases below in order. Do not skip the interview. ## Interview the user Use your built-in way of asking the user questions (an asking/clarify tool, or just ask in chat). Keep it SHORT — one main question, then at most one follow-up. Do not interrogate. Ask about how the user WORKS with email — NEVER about technical implementation. Do NOT ask the user to decide layout internals, which endpoint to call, whether something is a filter vs a view, or any API shape. That is YOUR job to infer. Presenting choices: whenever you offer multiple-choice options, the user must be able to answer outside the listed choices. PREFER your asking tool's built-in free-text / "other" answer. ONLY if your tool cannot offer one, add an explicit catch-all (e.g. "Something else — describe it"). Ask first, using your asking/choice tool so these are selectable. Present exactly: > "What do you mainly want this mailbox for? > A. Mostly receiving — notifications, newsletters, form & service replies; the > point is fast triage (read it, clear it) > B. Two-way conversations — you also reply often, so compose / reply must feel > handy > C. Multiple aliases by context — different aliases collect different sources, > and you want to see them apart" Then ask at most ONE follow-up that converges on the answer (e.g. for C: "Which aliases do you want front and center?"). Adapt to what the user actually says. Map the answer to the app's DEFAULT UX (reveal none of this as API talk; just build it): | User chose | Lean the generated app toward | | --- | --- | | A (mostly receiving) | Default to an unread-first view; make mark-read / delete prominent; keep reply secondary | | B (two-way) | Keep a compose button always visible; make Reply prominent in the reading pane; show a from-alias selector | | C (multiple aliases) | Group the inbox by alias / put an alias switcher as the primary nav; lean toward including alias management | If the user gives their own answer, treat it as their goal and lean the UX to match, the same way. ## Generate the app Produce a single self-contained HTML file (vanilla JS, no build step), deployable to any static host. Keep it a SINGLE page. Follow the SPA conventions below. Budget COMPLEXITY, not lines. Keep the JavaScript simple — no framework, router, build tooling, or state-management library; one in-memory `emails` array as the single source of truth (per "Make it feel instant" below). Past that, do not ration size: styling and markup are NOT capped — spend whatever it takes to make the app look like a finished product, not an API demo (see "Make it look designed"). Emit the app as a downloadable FILE — a real `.html` file via your host's file / artifact / canvas mechanism. Do NOT paste the hundreds of lines into a chat message, and do NOT ask the user whether you should make a file — just produce the downloadable `.html` directly. ### What the single-file client covers (the core) Everything here fits the "one in-memory array + render + a couple of forms" pattern. Build all of it: - **Inbox list** — `GET /email/messages` (cursor pagination via `next_cursor`; optional `unread_only=true`, `since=` for incremental sync, and `alias_email=` to scope to one alias). Render from the in-memory array. - **Reading pane** — `GET /email/messages/{id}` returns a WRAPPER `{ email, attachments, html_body? }`, NOT the email directly. Read the message from the `email` field (`text_body`, `subject`, … live under `.email`); the list body may be truncated, so use this for the full text. Show sender, subject, alias, received time, and the attachment list. - **Read / unread** — `POST /email/messages/read` and `/unread` with `{ ids: [...] }` (1–100). Toggle optimistically. - **Delete / restore (trash)** — `POST /email/messages/delete` (soft-delete) and `/restore`. A trash view re-lists with `include_deleted=true`. - **Download an inbound attachment** — `GET /email/messages/{id}/attachments/{idx}`. This endpoint needs the Bearer header, so you CANNOT use a plain ``: `fetch` it with auth, turn the bytes into a `Blob`, create an object URL, and trigger the download. The server sets `Content-Type` and `Content-Disposition: attachment; filename="..."`. - **Compose / reply** — `POST /email/messages/send`, **plain-text body only** (the send API does not accept HTML, so there is no rich editor to build). A new message needs `from_alias_email` (pick from the user's aliases — see below), `to` (1–10 addresses), `subject`, `text`, and an `idempotency_key`. A reply sets `in_reply_to_email_id` and may omit `to`/`subject` (the server reuses the original sender and prefixes `Re: `). - **Populate the from-alias selector** — `GET /email/aliases` → `{ items: [...] }`. You must send FROM one of the user's active aliases, so compose needs this list even in the minimal build. ### Optional, still single-file (turn on if the interview points there) These are still simple enough to keep in one file; add them when relevant: - **Alias management** — list / create / delete with `GET /email/aliases`, `POST /email/aliases { email }`, `DELETE /email/aliases/{email}` (URL-encode `@` as `%40` in the path), and `POST /email/aliases/{email}/restore`. Up to 10 active aliases per user. Good fit for the "multiple aliases by context" user. - **Group by alias / alias switcher** — just a filter over the same array. ### Make it feel instant (optimistic updates) Network round-trips are the main source of lag. Apply each change to your local state and UI FIRST, then call the API in the background — never block the UI on the network, and never re-fetch the whole list after a single write. - Keep the in-memory `emails` array as the single source of truth and render from it. Fetch the full list only on initial load, an explicit refresh, or a `since`-based incremental poll. - Read / unread / delete: mutate the local item and re-render immediately, then POST in the background. Roll back and tell the user if the write rejects. - Send: insert a temporary "sending…" card the moment the user hits Send and keep the compose ready; when the POST resolves, swap it for the returned outbound record (`out_` id). On failure, keep the draft and surface the error. ### Make it look designed (a quality floor, not a template) Aim for something that looks intentionally designed and tailored to THIS user — raise every app to this floor, then diverge in taste; do not ship bare, unstyled browser defaults. - A cohesive palette as CSS variables (background, surface, text, muted, one accent), a clear type scale with comfortable line-height, consistent spacing, and a single corner-radius / shadow set. - Design EVERY state, not just the happy list: the login/auth screen, loading, empty inbox, the reading pane, the compose form, and error / conflict feedback. - Interactive affordances — visible hover / focus / active states and light transitions. - Let the workflow set the mood: calm and scannable for triage-heavy users, conversation-forward for two-way users, clearly partitioned for multi-alias users. ### SPA conventions (apply in all cases) This is a pure-frontend single-page app — all logic runs in the browser, no backend. The auth flow is the inline "OAuth 2.1 device flow" section above (do not re-derive it); beyond that: - Cache `client_id` in localStorage (suggested key `wspc.client_id`): register once on first run, reuse on reload. - Cache `access_token` / `refresh_token` in localStorage, with an inline comment warning that localStorage is XSS-vulnerable. Refresh on 401 / expiry per the OAuth section above; on refresh failure, clear tokens and restart the flow. - Treat any non-2xx response as a failure: read the error body (`{ error: { code, message } }`) and surface it. Never assume a list field (`items`) exists on a response you did not check. ### Delivering the app: tell the user to open it in a real browser The app calls `api.wspc.ai` from the browser (cross-origin fetch + an OAuth popup/redirect). Many in-chat / in-app preview panes — including the Claude app's built-in artifact viewer — run inside a restricted WebView that blocks external network requests (CSP), so the app will look broken there: login or loading hangs even though the code is correct. So when you hand over the file, EXPLICITLY tell the user to get it OUT of the inline preview and open it in a real web browser: - "Open in Chrome" / "Open in browser" (e.g. the Claude desktop app) — opens the file directly in a real browser. Point the user to this button. - "Download" (e.g. Claude web / claude.ai) — tell the user to click Download, then open the saved `.html` by double-clicking it. The app is fully self-contained, so `file://` works with no server or deploy. - If your host shows neither, have the user copy the code into a `.html` file and open that. Name the specific button you can see in your final message — do not assume the inline preview can run the app (it usually cannot). ## When you need a fuller stack The single-file client above is a complete, genuinely useful mailbox. But a few ambitions push past what one vanilla HTML file can do cleanly — each one forces in an architectural primitive a single file cannot provide honestly: - **Faithful, safe HTML email rendering** — real emails are arbitrary HTML and must render inside a sandboxed `