Skip to content

Latest commit

 

History

History
581 lines (484 loc) · 33.7 KB

File metadata and controls

581 lines (484 loc) · 33.7 KB

Webmail UI — Implementation Plan

Status: Phases 0–6 done + Phase 9 built & locally verified over HTTPS — full read+write client (login, 3-pane inbox, flags/move/delete, compose/reply/forward) now ships as a single binary that serves the embedded SPA over its own TLS. Prod deploy to mail.stacksjs.com is the one remaining explicit step (runbook in Phase 9). Next: trigger the deploy, or Phase 7 (search/polish). Owner: TBD Last updated: 2026-06-01 Estimated effort: ~8–12 weeks (multi-phase, see Phases)

This document is the single source of truth for building a browser-based webmail client for this mail server. It captures (1) the real current state of the codebase, (2) the target architecture, and (3) a phased plan broken into shippable increments.

Read Current State first — it corrects the common assumption that "there is no UI yet." There is a lot of scaffolding and design work, but none of it is live and none of it serves real mail.


Goals

Primary goal

A user (e.g. someone with a mailbox like you@mail.stacksjs.com) can open a browser, log in with their mail credentials, and read, search, organize, and send email from their real mailbox — the same mailbox Apple Mail talks to over IMAP/SMTP.

Non-goals (for v1)

  • Admin/operator dashboard (separate effort; api.zig + admin.html already partially cover this).
  • Calendar/Contacts UI (CalDAV/CardDAV exist at the protocol level; defer).
  • Mobile native apps.
  • Multi-account / multi-tenant switching within one session.

Success criteria for v1 ("usable daily")

  • Log in / log out with mailbox credentials over HTTPS with a secure session.
  • See folders with accurate unread counts (Inbox, Sent, Drafts, Trash, …).
  • List messages in a folder with pagination and basic search.
  • Open a message: rendered HTML (sanitized), plain-text fallback, headers, attachments (download).
  • Mark read/unread, flag/star, delete, move — persisted so Apple Mail sees the same state (Maildir flags / IMAP consistency).
  • Compose, reply, reply-all, forward → actually delivered via the existing delivery queue / SES path.
  • Works on desktop + responsive on mobile.

Current State

Evidence-based inventory as of 2026-05-29. Legend: 🟢 Live = wired in and working · 🟡 Code-complete but dead = compiles but never invoked · 🔵 Mock = returns fake/in-memory data · ⚪ Mockup = static design reference · 🔴 Missing = does not exist.

What exists

Component Path Status Notes
Webmail handler (Zig) packages/zig/src/api/webmail.zig (4203 ln) 🟡 + 🔵 Full WebmailHandler with routes for folders/messages/compose/search/attachments/threads/templates/signatures/receipts/scheduled/groups. Never imported anywhere. All handlers return mock/in-memory data — does not read Maildir or SQLite.
Embedded SPA webmail.zig serveMainPage() (HTML ~ln 1309–4070) 🟡 A complete ~2,760-line vanilla-JS/CSS 3-pane SPA embedded as a Zig string literal (compose modal, rich-text editor, attachments, threading, contacts, dark mode, mobile responsive). Fetches /webmail/api/*. Strong visual base — but dead (never served) and its API returns mock data.
Webmail API handlers webmail.zig handleApiGet/Post/Delete 🔵/🟡 Split: folders/messages/user/compose/search return hardcoded/empty JSON. threads/templates/signatures/scheduled/groups/inline-images are wired to real in-memory feature managers (no SQLite/Maildir, lost on restart). No *Database or Maildir field on WebmailHandler.
Admin REST API packages/zig/src/api/api.zig (~1600 ln) 🟡 Complete APIServer: /api/users (CRUD), /api/stats, /api/queue, /api/filters, /api/search(+rebuild), /api/config, /api/logs, /api/audit, /api/csrf-token, GraphQL, autoconfig, password-reset, CSRF on mutations. Never instantiated in main.zig — fully dead code.
Admin dashboard page packages/zig/src/api/admin.html (708 ln) 🟡 Complete dashboard, but only served by api.zig, which never runs.
Devtools (email testing) packages/devtools/ (server.ts, src/) 🟢 (standalone) Mailpit/Mailhog-style testing tool: Bun HTTP server, own in-memory store, chaos mode, webhooks, HTML/spam/link checks. Not end-user webmail; does not talk to the Zig server.
Devtools UI templates packages/devtools/pages/app.stx (58KB), home.stx (51KB) 🟢 (in devtools) The only place stx is actually wired, via bun-plugin-stx. Excellent design reference.
Static mockups examples/webmail.html (38KB), examples/devtools.html (35KB) Standalone design demos. Not served by anything.
Browser auth/session 🔴 No cookie/JWT/session login flow. WebmailSession struct exists but is never populated. What does exist: SMTP/IMAP AUTH, HTTP Digest auth + NonceManager (RFC 7616) in auth.zig, password.zig (Argon2id), csrf.zig — all server-protocol auth, not browser sessions.
crosswind CSS 🔴 Not installed in any package.json (verified via grep). CLAUDE.md aspiration only. No Tailwind config.
Live HTTP listener 🔴 No HTTP/HTTPS port is opened at all. main.zig spawns only SMTP/IMAP/CalDAV/ManageSieve threads. Neither api.zig nor webmail.zig is referenced from main.zig.

* "Live" for api.zig/admin.html is conditional on it being enabled in config.

What production actually runs

main.zig's serve command starts only mail-protocol listeners in their own threads: SMTP (25), SMTPS (465), Submission (587), IMAP/IMAPS (143/993), CalDAV (8008/8009), ManageSieve (4190). There is no HTTP listener of any kindgrep for APIServer/webmail/WebmailHandler in main.zig returns nothing. Both api.zig and webmail.zig are complete but orphaned: they compile and have tests, but nothing instantiates or routes to them. So today there is zero HTTP surface to build a UI on — wiring one up is task #1 of the backend work.

Implications for this plan

  1. We are near-greenfield for a real webmail, but with strong assets:
    • A data-model skeleton in webmail.zig (WebmailMessage, FolderType, EmailAddress, threads, signatures, contacts) worth reusing.
    • Visual references (examples/webmail.html, devtools app.stx, serveMainPage).
  2. The four hardest missing pieces, in rough dependency order:
    1. Browser auth/session layer (login → secure cookie/token → CSRF).
    2. A wired HTTP listener that routes to a webmail handler.
    3. Real data: replace mock handlers with Maildir + SQLite-backed queries that stay consistent with IMAP flag semantics.
    4. A real compose → delivery-queue/SES send path.
  3. Tooling decision needed (see Open Questions): crosswind is not installed and stx only runs inside devtools' Bun setup.

Decisions Locked In

From scoping discussion:

Decision Choice Notes
Scope (v1) Webmail client (end-user inbox) Admin dashboard deferred; can grow on same shell later.
Frontend stack stx + crosswind Per CLAUDE.md. Requires installing/wiring crosswind (currently absent).
Production serving Decide later Build frontend as its own package now; choose embed-in-Zig vs. static-assets vs. Bun sidecar at Phase 9.

Target Architecture

┌──────────────────────────────────────────────────────────────────┐
│  Browser                                                            │
│  ┌──────────────────────────────────────────────────────────────┐ │
│  │  Webmail SPA  (packages/webmail)                               │ │
│  │  stx templates + crosswind CSS + signals/composables          │ │
│  │  3-pane: folders │ message list │ reading pane + compose modal │ │
│  └───────────────┬──────────────────────────────────────────────┘ │
└──────────────────┼─────────────────────────────────────────────────┘
                   │ HTTPS (JSON over fetch), session cookie + CSRF
                   ▼
┌──────────────────────────────────────────────────────────────────┐
│  Zig mail server (packages/zig)                                    │
│  ┌──────────────────────────────────────────────────────────────┐ │
│  │  HTTP listener (new/expanded)                                  │ │
│  │   ├─ /webmail/*        → Webmail API (real data)              │ │
│  │   ├─ /webmail/auth/*   → session login/logout (NEW)          │ │
│  │   └─ static assets     → built SPA (prod, if embedded)        │ │
│  └───────┬───────────────────────────┬──────────────────────────┘ │
│          │                           │                              │
│   ┌──────▼───────┐          ┌────────▼─────────┐                  │
│   │ SQLite       │          │ Maildir          │                   │
│   │ users, UIDs, │          │ /opt/mail/mail/  │                   │
│   │ sessions(NEW)│          │ {user}/{cur,new} │                   │
│   └──────────────┘          └──────────────────┘                  │
│          │                                                          │
│   ┌──────▼───────────────────────────────────────────┐           │
│   │ Delivery queue → SES / SMTP (existing send path)  │           │
│   └───────────────────────────────────────────────────┘          │
└──────────────────────────────────────────────────────────────────┘

Key architectural principles

  • The browser never speaks IMAP/SMTP. It speaks a small JSON HTTP API. The Zig server is the only thing that touches Maildir/SQLite/queue.
  • Maildir is the source of truth for messages & flags. The webmail API must read/write Maildir filenames (:2,FLAGS) so state stays consistent with what Apple Mail sees over IMAP. (See CLAUDE.md "IMAP flag persistence".)
  • Reuse, don't reinvent, the send path. Compose must funnel into the existing delivery queue / SES integration, not a new SMTP client.
  • Frontend is a separate package (packages/webmail) regardless of how it's served in prod, so we can iterate with hot reload against a dev proxy.
  • Security is a first-class phase, not an afterthought (HTML sanitization, CSP, session hardening, CSRF, rate limiting).

Proposed new/changed layout

packages/
├── webmail/                    # NEW — frontend SPA (stx + crosswind)
│   ├── package.json
│   ├── bunfig.toml             # linker = "hoisted"; bun-plugin-stx
│   ├── crosswind.config.*      # crosswind setup
│   ├── pages/                  # stx templates (shell, login, inbox, message, compose)
│   ├── src/
│   │   ├── api/                # typed client for /webmail/* (reuse/extend packages/ts)
│   │   ├── components/         # message-list, folder-tree, reading-pane, composer
│   │   ├── composables/        # auth, mailbox state, signals
│   │   └── styles/
│   └── dev-server / proxy config → packages/zig API
└── zig/src/
    ├── api/
    │   ├── webmail.zig         # REWORK — wire in + replace mock data with real
    │   ├── webmail_session.zig # NEW — browser session/cookie/CSRF for webmail
    │   └── http.zig / api.zig  # wire an always-available HTTP listener path
    ├── storage/                # add sessions table + Maildir read/query helpers
    └── auth/                   # reuse password.zig, csrf.zig

Phases

Each phase is independently shippable and ends with a demoable increment. Estimates are rough (calendar weeks, one engineer). Treat the acceptance criteria as the definition of done.

Phase 0 — Foundations & decisions · ~3–4 days

Set up the skeleton and resolve the open architectural choices so later phases don't churn.

Tasks

  • Resolve Open Questions: prod serving model, crosswind setup approach, session storage location.
  • Scaffold packages/webmail (Bun + bun-plugin-stx, bunfig.toml with linker = "hoisted"). stx layout: pages/, layouts/, partials/, functions/, public/ + stx.config.ts.
  • Stand up a dev server with a proxy to the Zig HTTP API (server.ts uses bun-plugin-stx/serve + its onRequest hook to proxy /webmail/*; unreachable backend returns a labelled 502). Verified: pages, login, static asset, and proxy all respond.
  • Decide the JSON API contract shape — drafted in API Contract; typed client stub at functions/useApi.ts.
  • CI: lint clean with pickier (bunx --bun pickier .).
  • Install + wire crosswind; produce a one-screen "design system" page (colors, type scale, spacing, buttons, inputs) so the look is decided early. (Currently placeholder CSS in public/styles.css — see note below.)
  • Add dev:webmail / build:webmail scripts to pantry.jsonc.

Acceptance

  • cd packages/webmail && bun run dev serves the placeholder pages and proxies /webmail/* to the Zig server (labelled 502 until Phase 2 — expected).

Notes / deviations from plan

  • The bun-plugin-stx/preload entry crashes under Bun 1.3 (build.config.root undefined), so we do not preload it. Dev uses the plugin's programmatic serve() instead (bun server.ts). bun run pages runs the bundled serve bin as a fallback.
  • stx uses Blade-style layout composition (@extends / @section('content') / @yield('content'), @include('Partial')), not <slot />. Pages set a meta object in <script> for title/description.

Risks: crosswind integration is unproven here — timebox it; fall back to a documented decision if it fights the toolchain.


Phase 1 — Backend: browser auth & sessions · ✅ DONE

The webmail can't do anything per-user without authenticated browser sessions.

Tasks

  • webmail_sessions table in SQLite (session_id, username, email, csrf_secret, created/last_activity/expires, ip, user-agent). Migration + CRUD.
  • webmail_session.zig: create/validate/revoke sessions; HttpOnly, SameSite=Lax, Secure cookies; sliding idle expiry; 256-bit tokens.
  • POST /webmail/auth/login — verifies via AuthBackend (Argon2id), mints session.
  • POST /webmail/auth/logout — revokes session (server-side + clears cookie).
  • GET /webmail/auth/me — current user info.
  • CSRF: per-session secret issued; same-origin check on logout. (Per-action CSRF token enforcement on mailbox mutations deferred to Phase 5, when those endpoints exist.)
  • Rate-limit login per IP (429) via auth/security.zig RateLimiter.
  • Login timing equalized (dummy Argon2 on unknown/disabled user) — no username enumeration.

Acceptance ✅ verified end-to-end with curl: login→cookie, /auth/me, logout invalidation, bad-password 401, rate-limit 429, cross-origin logout 403.

Note: Fixed a pre-existing bug — users.digest_ha1 was SELECTed but never created, so every credential check failed on a fresh DB. Added the migration.


Phase 2 — Backend: HTTP wiring + real read path · ✅ DONE

Make the webmail API live and back it with real mail from Maildir + SQLite.

Tasks

  • HTTP listener (webmail_http.zig) routing /webmail/*, config-gated via SMTP_ENABLE_WEBMAIL (off by default). New module, not the old dead WebmailHandler; thread-per-conn mirroring the IMAP accept loop.
  • Folders: real folders + unread/total counts (webmail_maildir.zig).
  • Message list: reads Maildir new/cur, parses From/To/Subject/Date, flags from :2,FLAGS, paginated, newest-first; JSON flag mapping.
  • Message detail: headers + MIME parse → text + html parts + attachment metadata. Wrote a minimal MIME parser (multipart, base64/QP, RFC 2047).
  • UID consistency: assigns UIDs via the shared imap_uids table in oldest-first order (matching IMAP syncUids) — verified identical mapping.
  • Attachment download: metadata is returned; streaming the bytes by id is deferred to Phase 4/6 (reading-pane work).

Acceptance ✅ verified end-to-end: logged-in user sees their real INBOX (counts + list) and opens a real message (headers + text + html + attachment metadata) via the API; path-traversal folders rejected (400); missing UID 404.

Note: Also fixed a pre-existing macOS/BSD socket bug (SOCK_CLOEXEC in the socket() type arg) that prevented the server binding anywhere but Linux.


Phase 3 — Frontend: app shell & auth UI · ✅ DONE

Tasks

  • App shell with the 3-pane layout (folder sidebar │ list │ reading pane), responsive collapse for mobile (single-pane on ≤860px).
  • Login + logout pages wired to Phase 1 endpoints; session-expiry handling (any 401 → redirect to /login).
  • Loading / empty / error states.
  • [~] Client-side routing — kept simple: /login + / full pages; in-app view switching (list ↔ reading) is state-driven. Compose view is Phase 6.
  • [~] API access — inline fetch per the standalone-page approach (the Phase 0 useApi.ts stub was removed; see "Notes" below).

Acceptance ✅ verified in headless Chrome end-to-end: log in via the form → land on inbox → folders + message list render from the API → open a message (headers + sandboxed HTML iframe / plain-text) → sign out returns to /login. Bonus already done (overlaps Phase 4): real folder tree with unread counts, paginated-ready list, sandboxed HTML rendering.

Notes / deviations (see memory stx-client-side-gotchas):

  • Pages are standalone HTML + vanilla <script> (the devtools/app.stx approach), not stx @extends layouts or Alpine directives — those get stripped under a layout without their reactive runtime. The Phase 0 layouts/, partials/, functions/useApi.ts scaffold was removed as dead.
  • Login field is type="text" (backend accepts bare username or full address; type="email" silently blocked username-only login via HTML5 validation).
  • HTML email renders in an empty-sandbox iframe via srcdoc (no scripts, no same-origin) — the Phase 8 XSS-isolation approach, brought forward.

Phase 4 — Frontend: reading mail · ~1.5 weeks

Tasks

  • Folder tree with live unread counts.
  • Message list: virtualized/paginated, sender/subject/snippet/date, unread & flagged indicators, multi-select.
  • Reading pane: sanitized HTML render in a sandboxed iframe, text fallback toggle, full-header view, attachment chips with download.
  • Basic search box (subject/from/body) hitting the read API.
  • Keyboard navigation (j/k, enter, etc.) — nice-to-have within phase.

Acceptance

  • A user can browse folders, scroll/paginate a real inbox, open messages with correctly rendered (and safely sandboxed) content, and download attachments.

Risks: HTML email sanitization is security-critical — see Phase 8. Do a minimal-but-correct sanitize here; harden later.


Phase 5 — Flags & message actions (persisted) · ✅ DONE

Tasks

  • Backend write ops (webmail_maildir.zig): set flags (read/unread, flag/star) via :2,FLAGS rename; delete (→ Trash, or purge in Trash); move between folders. PUT/DELETE /webmail/api/messages/:uid with same-origin (CSRF) + session enforced.
  • IMAP consistency: UIDs keyed on the Maildir base name, so flag renames keep the UID stable and webmail/IMAP/ActiveSync agree. Fixed two pre-existing UID bugs in the shared database.zig path (full-name keying; assignUid duplicate-UID on INSERT OR IGNORE). Covered by uid_consistency_test.zig.
  • Frontend: reading-pane toolbar (flag / mark-unread / delete), flagged ★ in the list, optimistic UI + rollback, auto-mark-read on open (race-guarded).
  • Verify round-trip: webmail action → correct :2,FLAGS on disk + stable base-name UID in imap_uids (what IMAP reads). Confirmed via curl + disk inspection + headless-browser drive.
  • [~] Bulk/multi-select actions: deferred to a later polish pass.

Acceptance ✅ mark-read writes :2,S keeping the UID; move relocates the file + returns the dest UID; delete moves to Trash; cross-origin mutation → 403.

Hardening (adversarial review, 11 findings fixed): error-propagating unlinkPath (no phantom-success deletes / no move duplication), dest-UID-before- move ordering, setFlags retry→409 on concurrent rename, symlink skip in listEmlFiles (planted-symlink path-escape), jsonBoolField terminator check, frontend rollback + auto-read race guard.

Known unfixed (separate subsystem): SMTP delivery can overwrite a message on same-millisecond delivery (bare-timestamp Maildir name + O_TRUNC). See memory smtp-maildir-filename-collision. Not a webmail bug; fix in the delivery path.


Phase 6 — Compose & send · ✅ DONE

Tasks

  • Composer UI: to/cc/bcc (validated), subject, plain body, reply / reply-all / forward (quote + Re:/Fwd: + In-Reply-To/References).
  • Backend: POST /webmail/api/compose builds a correct RFC 5322 MIME message (webmail_compose.zig) and sends via the existing outbound.deliverToRemote (SES/direct) — not a new client.
  • Save to Sent (writeMaildir to the sender's Sent).
  • DKIM is automatic: deliverToRemote signs via the process-wide signer, so webmail mail is signed exactly like SMTP submission. Local recipients land in their INBOX; remote go out.
  • Multi-agent adversarial review + 11 fixes (see below).
  • [~] Rich (HTML) compose, attachment upload UI, and Drafts persistence: deferred to a polish pass. (The MIME builder already supports html + base64 attachments; only the upload UI/draft endpoints remain.)

Acceptance ✅ verified in headless browser + on disk: compose → recipient INBOX with correct headers + Sent copy; reply threads via In-Reply-To/References; multi-line + non-ASCII bodies correct; header-injection recipient → 400.

Security hardening (adversarial review): fixed a CRITICAL path traversal (crafted local-part escaping mail/{user}/), predictable/unchecked MIME boundaries, silent partial-delivery loss (now reported), Maildir overwrite (O_EXCL + retry), Bcc validation, and 8bit→base64 for non-ASCII. Kept isLocalDomain's parent-domain acceptance to stay identical to the SMTP path.

Risks: Outbound signing matches production by reusing the same path. Real external deliverability (SPF/DKIM/DMARC at the receiver) still warrants a live send test against an external mailbox before launch.


Phase 7 — Search & organization polish · ~1 week

Tasks

  • Integrate the real search index (api.zig referenced /api/search, /api/search/rebuild, search/stats) instead of naive scan.
  • Advanced search (from/to/subject/has-attachment/date range/folder).
  • Signatures, templates, contact groups, scheduled send — port the webmail.zig managers from mock to persistent storage as desired (each is optional; prioritize by user value).
  • Settings page (display density, signature default, etc.).

Acceptance

  • Search returns relevant real results quickly; at least signatures + a settings page are persisted and usable.

Phase 8 — Security hardening · ~1 week (overlaps earlier phases)

Security work is seeded in earlier phases; this phase is the dedicated audit.

Tasks

  • HTML email sanitization: strict allow-list; render in sandboxed iframe with a restrictive sandbox attr; block remote content by default with a "load remote images" opt-in (privacy + tracking protection).
  • CSP headers for the SPA; no inline-script reliance where avoidable.
  • Session hardening review: rotation, idle + absolute timeouts, revoke-all, cookie flags, fixation protection.
  • CSRF on every mutating endpoint; verify Origin/Referer.
  • Rate limiting on auth + send + search.
  • Attachment handling: content-type sniffing safety, Content-Disposition: attachment, no inline execution.
  • Run /security-review on the diff; address findings.
  • Authorization: ensure a user can only ever access their own mailbox (path/UID scoping) — write tests that attempt cross-user access.

Acceptance

  • Security review passes; manual XSS attempts via crafted emails are blocked; cross-user access is impossible.

Phase 9 — Production serving & deployment · 🟡 BUILT & LOCALLY VERIFIED (prod deploy deferred)

Decision: Option A — embed the built SPA in the Zig binary, with the webmail server terminating TLS directly (like IMAPS/CalDAV). Single binary, no extra moving parts. Verified locally over HTTPS; the live prod deploy is a separate, explicit step (see runbook below).

Serving model — done

  • packages/webmail/build.ts renders the two standalone pages + CSS to packages/zig/src/api/webmail_dist/ (gitignored). zig build runs it automatically (build.zig system step), so embedded assets are always fresh.
  • webmail_http.zig @embedFiles index.html/login.html/styles.css and serves GET /, /login, /styles.css (with nosniff/DENY/cache headers). Pages call /webmail/* same-origin — no proxy in prod.
  • Direct TLS (webmail_tls.zig): nonblock handshake + record I/O via a Stream abstraction (plain or TLS), mirroring caldav.zig. Reuses the Let's Encrypt CertKeyPair loader. Config: enable_tls + tls_cert_path/tls_key_path; wired in main.zig (binds 0.0.0.0 and sets Secure cookies only when TLS is on).
  • Verified locally end-to-end over HTTPS (self-signed): login → inbox (6 folders) → all served from the single binary on :8443.

Fix found during this phase: an accepted connection inherited the listener's non-blocking mode on this platform, so the TLS handshake read hit WouldBlock and failed. Added socket.Connection.setBlocking(), called after accept. (Would have broken the prod deploy — caught locally.)

Deferred to the explicit prod-deploy step (runbook):

  • Open the webmail port (default 8443) in the EC2 security group (packages/cloud/cloud.config.ts).
  • Set env on the box (/etc/mail/mail.env): SMTP_ENABLE_WEBMAIL=true, SMTP_WEBMAIL_PORT=8443 (TLS reuses the existing SMTP_ENABLE_TLS=true + SMTP_TLS_CERT/SMTP_TLS_KEY Let's Encrypt paths).
  • Deploy via SSM (CLAUDE.md flow): cd packages/zig && zig build -Doptimize=ReleaseFast -Dtarget=x86_64-linux (auto-builds the frontend + embeds it) → aws s3 cp zig-out/bin/mail s3://…/deploy/mail-server-new → SSM swap binary + systemctl restart mail.
  • Smoke test: curl https://mail.stacksjs.com:8443/login → 200; log in with a real mailbox.
  • (Optional) Decide final URL: :8443 vs. moving CalDAV off 443 to free it, vs. a webmail. subdomain. Add webmail health to the Discord monitor.

Acceptance (local): ✅ webmail fully served from the single binary over HTTPS, login→inbox verified in a headless browser. Prod acceptance remains the deferred deploy above.


Phase 10 — Polish, a11y, mobile, tests · ~1 week (ongoing)

Tasks

  • Accessibility pass (focus, ARIA, keyboard, contrast).
  • Mobile/responsive refinement.
  • Performance (list virtualization, lazy bodies, caching).
  • E2E tests for the critical flows (login → read → flag → reply → send).
  • Empty/error/offline states; toasts; undo for destructive actions.

Acceptance

  • Lighthouse/a11y baseline met; E2E suite green; usable one-handed on mobile.

API Contract (draft)

The interface both frontend and backend build against. Refine in Phase 0. All under /webmail. JSON in/out. Session cookie + X-CSRF-Token on mutations.

Auth

  • POST /webmail/auth/login {username, password} → sets cookie, {user}
  • POST /webmail/auth/logout204
  • GET /webmail/auth/me{user} | 401

Folders

  • GET /webmail/api/folders[{id, name, type, unread, total}]

Messages

  • GET /webmail/api/messages?folder=&page=&limit=&q={items:[{uid, from, to, subject, snippet, date, flags:{seen,flagged,answered,draft,deleted}, hasAttachments}], total, page}
  • GET /webmail/api/messages/:uid{uid, headers, html, text, attachments:[{id, filename, size, contentType}], flags}
  • GET /webmail/api/messages/:uid/attachments/:id → binary stream
  • PUT /webmail/api/messages/:uid {flags?, folder?} → updated message (flag/move)
  • DELETE /webmail/api/messages/:uid → move to Trash (or purge if already Trash)

Compose / drafts

  • POST /webmail/api/compose {to, cc, bcc, subject, html, text, attachments[], inReplyTo?}{queued:true, id}
  • POST /webmail/api/drafts / PUT /webmail/api/drafts/:id / DELETE /webmail/api/drafts/:id
  • POST /webmail/api/attachments (multipart) → {id, filename, size}

Search / extras (later phases)

  • GET /webmail/api/search?q=&from=&to=&hasAttachment=&since=&before=
  • GET/POST /webmail/api/signatures, /templates, /groups, /scheduled

The existing webmail.zig already declares most of these routes (handleApiGet/Post/Delete, ~ln 317–390) — reuse the shapes where sensible, but replace the mock bodies with real data.


Open Questions / Decisions

# Question Options Default leaning
1 Production serving model Embed in binary / static on disk / Bun sidecar Embed (single-binary deploy, matches precedent) — confirm in Phase 0
2 crosswind integration Confirm it works with stx/Bun here Timebox in Phase 0; document fallback
3 Webmail HTTP: always-on or config-gated? Always-on / gated like api.zig Gated initially, flip on when ready
4 Hostname/path mail.stacksjs.com/ vs webmail. subdomain Decide before Phase 9
5 Reuse webmail.zig vs. fresh handler Revive 4203-ln file / start clean & port types Reuse types, rework handlers against real data
6 Rich text editor Build vs. library Decide in Phase 6
7 Session storage SQLite sessions table (chosen) SQLite

Risks & Mitigations

Risk Impact Mitigation
Maildir/IMAP flag inconsistency Webmail & Apple Mail disagree Treat Maildir as source of truth; integration tests in Phase 5; reuse UID mapping
HTML email XSS Account/data compromise Sandboxed iframe + strict sanitizer + CSP; dedicated Phase 8 + /security-review
Outbound mail not DKIM-signed correctly Deliverability / spoofing Reuse existing delivery/queue/SES + DKIM path; verify headers on a real send
crosswind not actually integrable Frontend churn Timebox in Phase 0; fallback to documented alternative
webmail.zig mock code mistaken for working False sense of progress This doc + tests; every handler must be proven against real data
Zig 0.16-dev breaking changes / compat layers Build friction Follow CLAUDE.md "Zig 0.16 Specifics"; use *_compat modules
Scope creep (calendar/contacts/admin) Slips v1 Non-goals are explicit above; defer

Reference: key files

  • packages/zig/src/api/webmail.zig — handler skeleton + embedded SPA + data model (reuse types; rework handlers).
  • packages/zig/src/api/api.zig — admin HTTP server + router patterns to follow.
  • packages/zig/src/api/admin.html — existing served-HTML precedent.
  • packages/zig/src/auth/{auth,password,csrf}.zig — credential verification, Argon2id, CSRF primitives to build sessions on.
  • packages/zig/src/storage/ — SQLite layer (add sessions, Maildir helpers).
  • packages/devtools/{server.ts,pages/app.stx} — stx wiring reference + design.
  • examples/webmail.html — visual/design reference.
  • packages/ts/src/index.ts — existing TS package (deploy/config helper; extend or sibling a typed API client).
  • CLAUDE.md — Maildir/flag semantics, Zig 0.16 specifics, SSM deploy, tooling (pickier/stx/crosswind/better-dx) conventions.

Changelog

  • 2026-05-29 — Initial plan + current-state inventory.