A terminal email client for Gmail. Keyboard-first, instant, built around triage instead of reading.
Two processes on one machine: a long-lived daemon holds the IMAP IDLE connection, SQLite cache, and OAuth tokens. A thin TUI (opentui + Solid) attaches to it over SSE. Any number of TUI windows can connect to the same daemon concurrently.
Single-user, single-Gmail-account, macOS. Not multi-account. Not multi-provider. See
prd.mdfor scope.
New install? See SETUP.md — walks through Google Cloud
Console, .env, and sign-in.
Already set up:
bun install
bun run oauth:login # one-time (weekly while GCP app is in Testing mode)
bun run dev:server # pane 1 — daemon + IDLE
bun run dev:tui # pane 2 — TUIHealth check any time: bun run doctor.
- Mail flow. Real-time push via IMAP IDLE (sub-second latency from Gmail to the TUI). 1000-message local window per folder, backfilled in the background with a live progress pill.
- Reader. Plain-text default with HTML-to-text fallback for
marketing mail;
vfor rich render viaw3m -dump;Vfor browser eject. Hybrid storage — text + metadata in SQLite, HTML and raw.emlon disk at~/.grace/bodies/. - Mutations.
mread ·sstar ·earchive ·#trash ·llabel — all optimistic, with server-authoritative rollback on error. Labels round-trip throughX-GM-LABELSSTORE. - Search.
/→ two-phase: instant SQLite LIKE hits (Lbadge) stream first, GmailX-GM-RAWremote hits (Rbadge) stream in after. Enter on a remote-only hit imports it before opening. - Compose.
c→ full-screen overlay with To / Cc / Bcc / Attach / Subject / Body.alt+c/alt+b/alt+areveal the hidden rows.shift+rfrom the reader pre-fills a threaded reply withIn-Reply-To/Referencesheaders. - Triage.
shift+t→ fullscreen one-message-at-a-time.spacearchive + next,aarchive,rreply,j/knav. - Sidebar. Tab toggles focus. Folder switch lazy-bootstraps + backfills; up to 4 concurrent per-folder IDLE supervisors keep activated folders live.
- Resilience. IDLE reconnect with exponential backoff (1s → 60s
cap), fresh access token per attempt.
idle.statusbus events expose state to clients.
Global: / search · c compose · : palette · ? help · r
refresh · shift+t triage · ctrl+c quit
Nav: j/k down/up · g/G top/bottom · Tab focus sidebar ·
Enter open · Esc close
Mail: m read · s star · e archive · # trash · l label
Reader: v w3m · V browser · shift+r reply · t plain-text ·
z toggle quotes
Triage: space archive+next · a archive · r reply
Compose: ctrl+s/ctrl+return send · alt+c/alt+b/alt+a
toggle Cc/Bcc/Attach · Tab next field
Full list inside the app — press ?.
┌──────────────────┐ HTTP + SSE ┌───────────────────────────────┐
│ TUI │ ───────────────→│ Daemon (Elysia + Bun) │
│ opentui + Solid │ loopback only │ SQLite via Drizzle │
│ Eden Treaty │ │ IMAP IDLE (imapflow) │
└──────────────────┘ │ SMTP (nodemailer) │
↑ │ OAuth2 + macOS Keychain │
│ any # of TUIs └───────────────────────────────┘
└── events ── Bus.publish('mail.received' | 'mail.updated' …)
prd.md has the why. plan.md has what's next. progress.md has what
shipped and when.
| Script | What it does |
|---|---|
bun run dev:server |
Run daemon + IMAP IDLE (turbo-watched) |
bun run dev:tui |
Run TUI (connects to daemon on 127.0.0.1:3535) |
bun run oauth:login |
Browser OAuth flow; stores tokens in Keychain |
bun run oauth:logout [email] |
Clear Keychain entry; defaults to active account |
bun run doctor |
Env + keychain + db + IMAP health check |
bun run smoke:imap |
Standalone IMAP handshake |
bun run smoke:bootstrap |
Pull N headers into SQLite |
bun run check-types |
Project-wide tsc -b |
grace/
├── apps/
│ ├── server/ — Elysia daemon + CLIs (doctor, oauth, smoke)
│ └── tui/ — opentui+Solid client
├── packages/
│ ├── api/ — routes + bus + imap-action + folder-manager singletons
│ ├── auth/ — OAuth2 (loopback+PKCE) + keychain + refresh
│ ├── db/ — Drizzle schema + bun:sqlite
│ ├── mail/ — IMAP client, bootstrap, IDLE supervisor, backfill,
│ │ fetch-body, mutations, list-folders, SMTP send
│ ├── env/ — zod-validated env
│ └── config/ — shared tsconfig base
└── prd.md · plan.md · progress.md · SETUP.md
- Tokens. macOS Keychain under service
grace. Inspect with Keychain Access.app orsecurity find-generic-password -s grace -w. - Database.
~/.grace/grace.db(SQLite;.db-journalduring writes). - Bodies.
~/.grace/bodies/<gmMsgid>.{html,eml}. - Drafts.
~/.grace/drafts/current.jsonl(append-only).
bun run oauth:logout clears the Keychain entry but leaves ~/.grace/
intact — re-signing as the same account reuses the cache. Wipe the
directory manually for a full reset.