A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

add disabling account for pulling

sspaeti 427aac00 023e42ea

+61 -7
+3
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + # 2026-04-30 4 + - **Send-only accounts (`imap_disabled = true`)** — accounts can be marked as send-only by setting `imap_disabled = true`; neomd skips IMAP connection, folder fetching, and screening for that account; the account remains available as a From address via `ctrl+f` in compose/pre-send; `ctrl+a` account cycling skips disabled accounts; useful for adding Gmail or other providers purely for sending without fetching thousands of emails; `:debug` shows "(imap disabled)" label 5 + 3 6 # 2026-04-28 4 7 - **Spy pixel blocking** — neomd automatically detects and blocks tracking pixels in emails using a two-layer approach (same as HEY): (1) a curated denylist of 150+ tracking services sourced from [Simplify](https://github.com/leggett/simplify-trackers) (BSD-3-Clause), [LeaveMeAlone](https://github.com/leavemealone-app/email-trackers) (CC-BY 3.0), and [DHH's original HEY list](https://gist.github.com/dhh/360f4dc7ddbce786f8e82b97cdad9d20) (MIT) — matches are attributed by service name (e.g. "Mailchimp", "HubSpot", "SendGrid"); (2) a generic 1×1 pixel heuristic (empty alt + tiny dimensions or CSS hiding) catches custom/branded tracking domains not on the list; `°` indicator in the inbox list for emails with tracking pixels; reader header shows `° N spy pixel(s) blocked (ServiceName)` with tracker attribution; senders cannot tell if you read their email in the TUI since glamour never fetches remote resources 5 8 - **Spy pixel scan (`<space>S` / `:scan-spy-pixels`)** — scan all emails in the current folder for tracking pixels in the background; fetches full UID list from IMAP server, skips already-scanned emails, uses IMAP PEEK (won't mark as read); results cached in `~/.cache/neomd/spy_pixels` and persist across restarts; both positive and negative scan results are cached so repeat scans are instant
+16
CLAUDE.md
··· 72 72 - `internal/oauth2/` — OAuth2 flow for Gmail/Office365 73 73 - `internal/integration_test.go` — integration tests (live IMAP/SMTP); lives at package level, not in a sub-package 74 74 75 + **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). 76 + 77 + **IMAP connection resilience** (`internal/imap/client.go`): 78 + - `withConn()` — no retry, for mutating operations (MOVE, APPEND, STORE) 79 + - `withConnRetry()` — one automatic retry on network error, for read-only operations (FETCH, SEARCH, STATUS) 80 + - NOOP health probe after 2+ minutes of inactivity (handles laptop suspend/resume) 81 + - Charset support: `_ "github.com/emersion/go-message/charset"` blank import registers ISO-8859-1, Windows-1252, etc. 82 + 83 + **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`. 84 + 85 + **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()`). 86 + 87 + **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. 88 + 89 + **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. 90 + 75 91 **CI:** GitHub Actions runs `go test ./...` + `go vet ./...` on every PR. 76 92 77 93 ## Project-Specific Conventions
+5 -1
cmd/neomd/main.go
··· 59 59 os.Exit(1) 60 60 } 61 61 62 - // Build one IMAP client per account. 62 + // Build one IMAP client per account (nil for imap_disabled accounts). 63 63 imapClients := make([]*goIMAP.Client, 0, len(accounts)) 64 64 for _, acc := range accounts { 65 + if acc.IMAPDisabled { 66 + imapClients = append(imapClients, nil) 67 + continue 68 + } 65 69 h, p := splitAddr(acc.IMAP) 66 70 // Determine TLS/STARTTLS: respect explicit user config, otherwise infer from port. 67 71 // Security: non-standard ports default to TLS (e.g., Proton Mail Bridge on 1143).
+18
docs/content/docs/configuration.md
··· 17 17 from = "Me <me@example.com>" 18 18 starttls = false # optional: force STARTTLS (see TLS/STARTTLS section below) 19 19 tls_cert_file = "" # optional PEM cert/CA for self-signed local bridges 20 + imap_disabled = false # set true for send-only accounts (no IMAP connection) 20 21 21 22 # OAuth2 authenticated accounts are supported, it just need the relevant fields. Note that the password field is not required. 22 23 [[accounts]] ··· 176 177 ```toml 177 178 store_sent_drafts_in_sending_account = true 178 179 ``` 180 + 181 + ## Send-Only Accounts (`imap_disabled`) 182 + 183 + Set `imap_disabled = true` on an account to use it only for sending. Neomd will skip IMAP connection, folder fetching, and screening for that account. The account remains available as a From address via `ctrl+f` in compose/pre-send. 184 + 185 + ```toml 186 + [[accounts]] 187 + name = "Gmail" 188 + imap = "imap.gmail.com:993" 189 + smtp = "smtp.gmail.com:587" 190 + user = "me@gmail.com" 191 + password = "$GMAIL_APP_PASSWORD" 192 + from = "Me <me@gmail.com>" 193 + imap_disabled = true 194 + ``` 195 + 196 + `ctrl+a` account cycling skips IMAP-disabled accounts. Useful for adding a provider purely for sending without fetching its emails. 179 197 180 198 ## Sending and Discarding 181 199
+9 -5
internal/config/config.go
··· 34 34 STARTTLS bool `toml:"starttls"` 35 35 TLSCertFile string `toml:"tls_cert_file"` // optional PEM CA/cert for self-signed local bridges 36 36 37 + IMAPDisabled bool `toml:"imap_disabled"` // skip IMAP connection; account is send-only 38 + 37 39 // OAuth2 fields — only used when auth_type = "oauth2". 38 40 AuthType string `toml:"auth_type"` // "plain" (default) | "oauth2" 39 41 OAuth2ClientID string `toml:"oauth2_client_id"` ··· 387 389 if label == "" { 388 390 label = fmt.Sprintf("accounts[%d]", i) 389 391 } 390 - if a.IMAP == "" { 391 - return fmt.Errorf("account %q: imap address is required", label) 392 + if !a.IMAPDisabled { 393 + if a.IMAP == "" { 394 + return fmt.Errorf("account %q: imap address is required", label) 395 + } 396 + if err := validateHostPort(a.IMAP, label, "imap"); err != nil { 397 + return err 398 + } 392 399 } 393 400 if a.SMTP == "" { 394 401 return fmt.Errorf("account %q: smtp address is required", label) 395 - } 396 - if err := validateHostPort(a.IMAP, label, "imap"); err != nil { 397 - return err 398 402 } 399 403 if err := validateHostPort(a.SMTP, label, "smtp"); err != nil { 400 404 return err
+10 -1
internal/ui/model.go
··· 317 317 if i == m.accountI { 318 318 active = " (active)" 319 319 } 320 + if a.IMAPDisabled { 321 + active += " (imap disabled)" 322 + } 320 323 b.WriteString(fmt.Sprintf("- **%s**%s\n", a.Name, active)) 321 324 b.WriteString(fmt.Sprintf(" - IMAP: `%s`\n", a.IMAP)) 322 325 b.WriteString(fmt.Sprintf(" - SMTP: `%s`\n", a.SMTP)) ··· 2750 2753 2751 2754 case "ctrl+a": 2752 2755 if len(m.clients) > 1 { 2753 - m.accountI = (m.accountI + 1) % len(m.clients) 2756 + // Skip IMAP-disabled accounts (nil clients). 2757 + for range m.clients { 2758 + m.accountI = (m.accountI + 1) % len(m.clients) 2759 + if m.clients[m.accountI] != nil { 2760 + break 2761 + } 2762 + } 2754 2763 m.activeFolderI = 0 2755 2764 m.loading = true 2756 2765 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder()))