Skip to content

Repository files navigation

Gmail TUI

gmail-tui is a keyboard-driven terminal client for Gmail, written in Go on top of Bubble Tea's Model-View-Update framework. It talks to the Gmail API directly over OAuth2 (no IMAP/SMTP), and handles inbox browsing, search, labels, compose/reply, attachment transfer, and read/unread state entirely from the terminal. The project is structured as a small two-package system — a Gmail API client (api/) and a terminal UI (ui/) — with unit, property-based, and integration tests covering both.

Features

  • Concurrent inbox fetch using bounded worker goroutines, backed by an LRU cache to avoid redundant API calls.
  • Compose, reply (with Re: subject normalization and thread headers), delete, search, label browsing, read/unread toggling.
  • Attachment download with filename sanitization, path-escape protection, and dedup.
  • Layered configuration: built-in defaults → TOML file → GMAIL_TUI_* env vars.
  • Structured JSON logging to ~/.config/gmail-tui/app.log.
  • Pagination and preserved search state across navigation.

Architecture

Bubble Tea enforces a strict Model-View-Update loop: user input and async API results become messages, Update produces a new model plus any follow-up commands, View renders it. The API package is kept UI-agnostic — it returns plain data/errors, and ui/commands.go wraps calls as tea.Cmds so the network never blocks the render loop.

┌──────────────────────────────┐
│ main.go │
│ load config → init logger │
│ → init Gmail client → run TUI│
└───────────────┬────────────────┘
│
┌───────┴────────┐
▼ ▼
┌───────────────┐ ┌─────────────────────────────┐
│ api/ │ │ ui/ │
│ OAuth, Gmail │◄──┤ Model-View-Update (Bubble Tea)│
│ REST calls, │ │ model.go - app state │
│ parsing, │ │ update.go - msg handling │
│ validation, │ │ commands.go- async tea.Cmd │
│ LRU cache, │ │ view.go - rendering │
│ JSON logger │ │ keys.go - keybindings │
└───────┬────────┘ └─────────────────────────────┘
│
▼
Gmail REST API (google.golang.org/api/gmail/v1)

Async operations (fetch, send, download) run as tea.Cmds returning a result message; Update never calls the network synchronously.

Tech Stack

  • Language: Go 1.23.3
  • TUI framework:charmbracelet/bubbletea, bubbles, lipgloss
  • Gmail access:google.golang.org/api (Gmail v1), golang.org/x/oauth2
  • Config:BurntSushi/toml
  • Testing: stdlib testing, pgregory.net/rapid (property-based tests, vendored under third_party/rapid)
  • Tooling:golangci-lint, pre-commit (gofmt, goimports, go vet), GitHub Actions CI

Installation

Prerequisites: Go 1.23.3+, a Google account with Gmail enabled, and a Google Cloud project with the Gmail API enabled.

git clone https://github.com/rdx40/gmail-tui
cd gmail-tui
go build .

Create an OAuth 2.0 Desktop App client in Google Cloud Console, download it as credentials.json, and place it in the project root (or point GMAIL_TUI_CREDENTIALS at it).

Configuration

Resolved in order: built-in defaults → ~/.config/gmail-tui/config.tomlGMAIL_TUI_* env vars.

max_results = 10search_max_results = 30downloads_dir = "downloads"max_concurrent = 5cache_max_size = 500log_level = "INFO"inbox_query = "in:inbox category:primary"

Equivalent env vars: GMAIL_TUI_MAX_RESULTS, GMAIL_TUI_SEARCH_MAX_RESULTS, GMAIL_TUI_DOWNLOADS_DIR, GMAIL_TUI_MAX_CONCURRENT, GMAIL_TUI_CACHE_MAX_SIZE, GMAIL_TUI_LOG_LEVEL, GMAIL_TUI_INBOX_QUERY, GMAIL_TUI_CREDENTIALS.

inbox_query controls what the main list shows — e.g. in:inbox to include all categories, not just Primary.

Running locally

go run .

On first launch the app opens your browser for OAuth authorization and captures the redirect on a local loopback port — no copy-paste needed. If a local listener can't be started (headless environments), it falls back to printing the URL and reading the code from the terminal. The token is cached locally after that.

For day-to-day verification:

go test ./...
go test -tags integration ./api/...
go build ./...
go vet ./...
golangci-lint run

(make test, make build, make lint, make coverage wrap the same commands if make is available.)

Example usage

KeyAction
j / kMove selection
enterOpen selected email
/Search
cCompose
rReply
dMove to trash
aArchive (remove from inbox)
mToggle read/unread
lLabels
RRefresh inbox
ctrl+dDownload attachment
n / pNext / previous page
?Help
qQuit

Sending with an empty recipient is rejected inline (Error: recipient address is required); an empty subject prompts Send with no subject? [y/n] rather than failing silently.

Project Structure

.
├── main.go # composition root: config → logger → client → tea.Program
├── api/ # Gmail API layer, no UI dependency
│ ├── auth.go # OAuth2 flow, token persistence
│ ├── client.go # Gmail REST calls (list, get, send, trash, labels, attachments)
│ ├── cache.go # bounded LRU cache for fetched messages
│ ├── config.go # layered config load (defaults/TOML/env)
│ ├── parse.go # MIME/body parsing, HTML stripping
│ ├── validate.go # address/attachment/size validation
│ ├── errors.go # typed error values used across the package
│ ├── logger.go # structured JSON logger
│ ├── types.go # shared data types (Message, Attachment, ...)
│ ├── interfaces.go # client interface for UI-side mocking
│ └── *_test.go, property_test.go, integration_test.go
├── ui/ # Bubble Tea TUI layer
│ ├── model.go # application state
│ ├── update.go # message handling / state transitions
│ ├── commands.go # async tea.Cmd wrappers around api/ calls
│ ├── view.go # rendering
│ ├── keys.go # keybinding definitions
│ └── *_test.go, property_test.go
├── third_party/rapid/ # vendored pgregory.net/rapid (property-based testing)
├── images/ # screenshots used in this README
├── .github/workflows/ # CI: build, test, lint
└── .golangci.yml, .pre-commit-config.yaml

Design Decisions

  • Bubble Tea / MVU over ad-hoc terminal rendering: a strict message-driven update loop keeps state transitions testable (update_test.go) without a running terminal, and makes async Gmail calls composable as tea.Cmd instead of littering goroutines through the render path.
  • api/ has zero UI imports: the Gmail client is usable standalone and unit-testable without spinning up Bubble Tea. interfaces.go exists specifically so ui/ can mock the client in tests.
  • LRU cache in front of Gmail REST calls: Gmail API quota and latency make repeated identical fetches (e.g., re-rendering the same inbox page) expensive; a bounded in-memory cache trades a small memory cost for fewer round trips.
  • Layered config (defaults → TOML → env): covers both the "just run it" case and containerized/CI use where env vars are the natural override mechanism, without forcing either.
  • Requested scopes, checked against what's actually granted: the app requests gmail.readonly, gmail.send, and gmail.modify (modify is required for trash/read-toggle). Google's consent screen lets a user deselect individual scopes, so the granted set can be narrower than requested — HasSendScope() reads the scope Google actually returned on the token and disables compose/reply rather than failing the whole app if send wasn't granted.

Challenges

  • Gmail messages arrive as nested multipart MIME with inconsistent charset/encoding; parse.go and its large test suite (parse_test.go, property_test.go) exist because plain-text extraction and HTML stripping had many edge cases (empty parts, base64 padding, nested alternatives).
  • Attachment handling needed explicit path-escape and size-limit checks (download path escapes downloads directory, 25 MB cap) since filenames come from an untrusted remote source.
  • Keeping the Gmail client fully decoupled from Bubble Tea required deliberate interface boundaries (interfaces.go) so async commands could be tested without a live terminal or real network.

Future Improvements

  • Multi-account support and account switching.
  • Offline/local message index for faster search.
  • Configurable keybindings instead of a fixed scheme.
  • Thread-level (conversation) view rather than flat message list.

Screenshots

InboxCompose
InboxCompose
Attach (compose)Attach (received)
Attach sendAttach receive

About

gmail but in your terminal

Topics

Resources

Contributing

Stars

30 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages