# 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 regenerates `docs/keybindings.md` from `internal/ui/keys.go`) - `make run ARGS="..."` — build and run - `make 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 tidy` - `make docs` — regenerate keybindings doc from `internal/ui/keys.go` (runs as part of `build`) and sync README to docs site - `make docs-serve` — serve Hugo docs locally at http://localhost:1313 - `make docs-build` — build Hugo docs site to `docs/public/` - `make send-test TO=addr` — run `./cmd/sendtest` to send a test email - `make demo` / `make demo-hp` — run with demo configs at `~/.config/neomd-demo/` and `~/.config/neomd-demo-hostpoint/` - `make benchmark` — IMAP latency benchmark (requires `IMAP_PASS_SIMU`, `IMAP_APPPASS_GMAIL_NEOMD` env vars) - `make android` — cross-compile ARM64 for Termux - `make 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 rendering - `reader.go` — glamour-rendered email, attachments, numbered links - `compose.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`. `imgSrcRe` rewrites local `` to `cid:` refs. **IMAP client** (`internal/imap/client.go`) uses `go-imap/v2` (beta). Known API quirks to preserve: - `imapclient.FetchMessageBuffer` (not `FetchedMessage`) - `conn.Copy()` / `conn.Store()` (not UID-prefixed variants) - `BodySection[0].Bytes` for raw MIME - APPEND pattern: `conn.Append(folder, size, opts)` → `.Write(raw)` → `.Close()` → `.Wait()` - `go-message` v0.18.2: `mail.PartHeader` lacks `ContentType()` — type-assert to `*mail.InlineHeader` / `*mail.AttachmentHeader` - `bubbletea` v1.3.10: key type is `tea.KeyMsg` (not `KeyPressMsg`) 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/v2 - `internal/smtp/` — email sender, MIME builder (`BuildMessage` is the main entry point) - `internal/screener/` — HEY-style sender classification - `internal/config/` — TOML config parsing - `internal/editor/` — spawns $EDITOR with neomd-*.md temp files - `internal/render/` — glamour-based Markdown rendering for terminal - `internal/daemon/` — headless background mode (`--headless`): screener loop without TUI - `internal/mailtls/` — TLS/STARTTLS connection helpers - `internal/oauth2/` — OAuth2 flow for Gmail/Office365 - `internal/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`, and `ctrl+a`/`ctrl+e` collide 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.lua` defines the `a` yazi picker scoped to `BufEnter 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/`.