CLAUDE.md#
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Build & Dev Commands#
make build— compile to./neomd(also regeneratesdocs/keybindings.mdfrominternal/ui/keys.go)make run ARGS="..."— build and runmake install— install to~/.local/bin/neomd(default target)make test—go test ./...(unit tests, no network)make test-integration— integration tests against real IMAP/SMTP (requires demo account env vars)make vet/make fmt/make tidymake docs— regenerate keybindings doc frominternal/ui/keys.go(runs as part ofbuild) and sync README to docs sitemake docs-serve— serve Hugo docs locally at http://localhost:1313make docs-build— build Hugo docs site todocs/public/make send-test TO=addr— run./cmd/sendtestto send a test emailmake demo/make demo-hp— run with demo configs at~/.config/neomd-demo/and~/.config/neomd-demo-hostpoint/make benchmark— IMAP latency benchmark (requiresIMAP_PASS_SIMU,IMAP_APPPASS_GMAIL_NEOMDenv vars)make android— cross-compile ARM64 for Termuxmake release VERSION=v0.1.0— tag and push a new release (runs docs build, GitHub Actions handles publishing)- Single test:
go test ./internal/smtp -run TestBuildMessage
Requires Go 1.22+. Binary version is injected via -ldflags -X main.version=$(git describe).
Architecture#
Entry point: cmd/neomd/main.go wires config → IMAP client → bubbletea program. Two side CLIs: cmd/docs (regenerates keybindings doc) and cmd/sendtest (sender smoke test).
TUI is a single bubbletea Model in internal/ui/model.go with a viewState enum state machine: stateInbox, stateReading, stateCompose, statePresend, stateHelp. All state transitions flow through Update(). Sibling files specialize views but share the one model:
inbox.go— folder tabs, list, threading connector renderingreader.go— glamour-rendered email, attachments, numbered linkscompose.go— multi-step compose form (To/CC/BCC/Subject)search.go,cmdline.go,thread.go,keys.go,styles.go
Keybindings are declared once in internal/ui/keys.go and drive both the in-app help overlay and the generated docs/keybindings.md. When adding a binding, edit that table — do not hand-edit the markdown docs.
Compose flow: user's $EDITOR (nvim) opens a temp neomd-*.md file with a prelude of # [neomd: to: ...] / # [neomd: subject: ...] headers built by internal/editor/editor.go. On editor exit, parsing extracts headers and [attach] /path inline lines (plain-text marker, NOT HTML comments — treesitter hides those). Then statePresend shows a review screen before sending.
MIME structure (internal/smtp/sender.go, BuildMessage is the exported entry point, reused by draft-save):
- no attachments →
multipart/alternative(text/plain + goldmark HTML) - file attachments only →
multipart/mixed > multipart/alternative - inline images only →
multipart/related > (alternative + image parts with Content-ID) - both →
multipart/mixed > (multipart/related > alt+images) + file parts - Image extensions:
.png .jpg .jpeg .gif .webp .svg.imgSrcRerewrites local<img src="/abs/path">tocid:refs.
IMAP client (internal/imap/client.go) uses go-imap/v2 (beta). Known API quirks to preserve:
imapclient.FetchMessageBuffer(notFetchedMessage)conn.Copy()/conn.Store()(not UID-prefixed variants)BodySection[0].Bytesfor raw MIME- APPEND pattern:
conn.Append(folder, size, opts)→.Write(raw)→.Close()→.Wait() go-messagev0.18.2:mail.PartHeaderlacksContentType()— type-assert to*mail.InlineHeader/*mail.AttachmentHeaderbubbleteav1.3.10: key type istea.KeyMsg(notKeyPressMsg)
Folder operations prefer RFC 6851 MOVE; u undo uses UIDPLUS destination UIDs captured on move/delete.
Screener (internal/screener/) reads line-based lists of email addresses from paths defined in config. Default paths are under ~/.config/neomd/lists/. Classification (I/O/F/P) appends to the corresponding list file and moves the message to the matching folder. Auto-screening runs on Inbox load and on a 5-minute background timer (ui.background_sync_interval).
Config (internal/config/) — TOML at ~/.config/neomd/config.toml, auto-created with placeholders. Supports multiple [[accounts]] and SMTP-only [[senders]] aliases (cycled with ctrl+f in compose/pre-send). OAuth2 authentication supported via oauth2_client_id, oauth2_client_secret, oauth2_issuer_url, oauth2_scopes fields. -config PATH flag overrides location.
Documentation — Hugo site in docs/ served at https://ssp-data.github.io/neomd/. README.md is synced to docs/content/overview.md via scripts/sync-readme-to-docs.sh. Keybindings are auto-generated from internal/ui/keys.go via cmd/docs/main.go — never hand-edit the markdown tables.
Package structure:
internal/ui/— bubbletea TUI: model.go (state machine), inbox.go, reader.go, compose.go, keys.go (single source of truth for keybindings)internal/imap/— IMAP client wrapper using go-imap/v2internal/smtp/— email sender, MIME builder (BuildMessageis the main entry point)internal/screener/— HEY-style sender classificationinternal/config/— TOML config parsinginternal/editor/— spawns $EDITOR with neomd-*.md temp filesinternal/render/— glamour-based Markdown rendering for terminalinternal/daemon/— headless background mode (--headless): screener loop without TUIinternal/mailtls/— TLS/STARTTLS connection helpersinternal/oauth2/— OAuth2 flow for Gmail/Office365internal/integration_test.go— integration tests (live IMAP/SMTP); lives at package level, not in a sub-package
Spy pixel detection (internal/imap/tracker_list.go + client.go): Two-layer approach — (1) curated denylist of 150+ tracking services in KnownTrackers with IdentifyTracker() for attribution ("Mailchimp", "HubSpot"); (2) generic 1×1 pixel heuristic via detectSpyPixels() on raw HTML. Results flow through SpyPixelInfo struct returned by FetchBody() and ScanSpyPixels(). Cached to ~/.cache/neomd/spy_pixels (format: +key for spy, -key for scanned clean).
IMAP connection resilience (internal/imap/client.go):
withConn()— no retry, for mutating operations (MOVE, APPEND, STORE)withConnRetry()— one automatic retry on network error, for read-only operations (FETCH, SEARCH, STATUS)- NOOP health probe after 2+ minutes of inactivity (handles laptop suspend/resume)
- Charset support:
_ "github.com/emersion/go-message/charset"blank import registers ISO-8859-1, Windows-1252, etc.
Goroutine safety (internal/ui/model.go): All background goroutines MUST use safeGo() instead of bare go func(). It recovers panics and writes stack traces to ~/.cache/neomd/crash.log.
Attachment safety (internal/ui/model.go): Two checks before xdg-open: (1) dangerousExts blocks executable extensions; (2) isMimeMismatch() detects magic-byte mismatches (e.g. script disguised as .png via http.DetectContentType()).
Browser view (internal/render/html.go): SanitizeForBrowser() injects CSP (script-src 'none'; frame-src 'none'; object-src 'none') into raw HTML emails opened with O. Remote images are intentionally allowed.
Config validation (internal/config/config.go): validate() runs on load — checks host:port format, port range 1-65535, required fields. Cache path helpers (CrashLogPath(), SpyPixelCachePath()) use config-name-aware directories for demo/production isolation.
CI: GitHub Actions runs go test ./... + go vet ./... on every PR.
Project-Specific Conventions#
- Keep diffs minimal — fix the specific thing asked; do not refactor adjacent code.
- Avoid modifier keys for new bindings — user's tmux prefix is
C-t, andctrl+a/ctrl+ecollide with bubbles textinput line-start/end. Prefer plain letters, especially on the pre-send screen. - Inline markers must be visible plain text — use
[attach] /path, never HTML comments (hidden by treesitter in the neovim compose buffer). - Neovim integration lives in dotfiles, not in this repo:
/home/sspaeti/git/general/dotfiles/nvim/.config/nvim/lua/sspaeti/custom.luadefines the<leader>ayazi picker scoped toBufEnter neomd-*.md. - Screener list files historically live at
~/.dotfiles/neomd/.lists/for this user (overridden in their config), though the default for new installs is~/.config/neomd/lists/.