···11# Changelog
2233+# 2026-04-30
44+- **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
55+36# 2026-04-28
47- **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
58- **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
···7272- `internal/oauth2/` — OAuth2 flow for Gmail/Office365
7373- `internal/integration_test.go` — integration tests (live IMAP/SMTP); lives at package level, not in a sub-package
74747575+**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).
7676+7777+**IMAP connection resilience** (`internal/imap/client.go`):
7878+- `withConn()` — no retry, for mutating operations (MOVE, APPEND, STORE)
7979+- `withConnRetry()` — one automatic retry on network error, for read-only operations (FETCH, SEARCH, STATUS)
8080+- NOOP health probe after 2+ minutes of inactivity (handles laptop suspend/resume)
8181+- Charset support: `_ "github.com/emersion/go-message/charset"` blank import registers ISO-8859-1, Windows-1252, etc.
8282+8383+**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`.
8484+8585+**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()`).
8686+8787+**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.
8888+8989+**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.
9090+7591**CI:** GitHub Actions runs `go test ./...` + `go vet ./...` on every PR.
76927793## Project-Specific Conventions
+5-1
cmd/neomd/main.go
···5959 os.Exit(1)
6060 }
61616262- // Build one IMAP client per account.
6262+ // Build one IMAP client per account (nil for imap_disabled accounts).
6363 imapClients := make([]*goIMAP.Client, 0, len(accounts))
6464 for _, acc := range accounts {
6565+ if acc.IMAPDisabled {
6666+ imapClients = append(imapClients, nil)
6767+ continue
6868+ }
6569 h, p := splitAddr(acc.IMAP)
6670 // Determine TLS/STARTTLS: respect explicit user config, otherwise infer from port.
6771 // Security: non-standard ports default to TLS (e.g., Proton Mail Bridge on 1143).
+18
docs/content/docs/configuration.md
···1717from = "Me <me@example.com>"
1818starttls = false # optional: force STARTTLS (see TLS/STARTTLS section below)
1919tls_cert_file = "" # optional PEM cert/CA for self-signed local bridges
2020+imap_disabled = false # set true for send-only accounts (no IMAP connection)
20212122# OAuth2 authenticated accounts are supported, it just need the relevant fields. Note that the password field is not required.
2223[[accounts]]
···176177```toml
177178store_sent_drafts_in_sending_account = true
178179```
180180+181181+## Send-Only Accounts (`imap_disabled`)
182182+183183+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.
184184+185185+```toml
186186+[[accounts]]
187187+name = "Gmail"
188188+imap = "imap.gmail.com:993"
189189+smtp = "smtp.gmail.com:587"
190190+user = "me@gmail.com"
191191+password = "$GMAIL_APP_PASSWORD"
192192+from = "Me <me@gmail.com>"
193193+imap_disabled = true
194194+```
195195+196196+`ctrl+a` account cycling skips IMAP-disabled accounts. Useful for adding a provider purely for sending without fetching its emails.
179197180198## Sending and Discarding
181199
+9-5
internal/config/config.go
···3434 STARTTLS bool `toml:"starttls"`
3535 TLSCertFile string `toml:"tls_cert_file"` // optional PEM CA/cert for self-signed local bridges
36363737+ IMAPDisabled bool `toml:"imap_disabled"` // skip IMAP connection; account is send-only
3838+3739 // OAuth2 fields — only used when auth_type = "oauth2".
3840 AuthType string `toml:"auth_type"` // "plain" (default) | "oauth2"
3941 OAuth2ClientID string `toml:"oauth2_client_id"`
···387389 if label == "" {
388390 label = fmt.Sprintf("accounts[%d]", i)
389391 }
390390- if a.IMAP == "" {
391391- return fmt.Errorf("account %q: imap address is required", label)
392392+ if !a.IMAPDisabled {
393393+ if a.IMAP == "" {
394394+ return fmt.Errorf("account %q: imap address is required", label)
395395+ }
396396+ if err := validateHostPort(a.IMAP, label, "imap"); err != nil {
397397+ return err
398398+ }
392399 }
393400 if a.SMTP == "" {
394401 return fmt.Errorf("account %q: smtp address is required", label)
395395- }
396396- if err := validateHostPort(a.IMAP, label, "imap"); err != nil {
397397- return err
398402 }
399403 if err := validateHostPort(a.SMTP, label, "smtp"); err != nil {
400404 return err
+10-1
internal/ui/model.go
···317317 if i == m.accountI {
318318 active = " (active)"
319319 }
320320+ if a.IMAPDisabled {
321321+ active += " (imap disabled)"
322322+ }
320323 b.WriteString(fmt.Sprintf("- **%s**%s\n", a.Name, active))
321324 b.WriteString(fmt.Sprintf(" - IMAP: `%s`\n", a.IMAP))
322325 b.WriteString(fmt.Sprintf(" - SMTP: `%s`\n", a.SMTP))
···2750275327512754 case "ctrl+a":
27522755 if len(m.clients) > 1 {
27532753- m.accountI = (m.accountI + 1) % len(m.clients)
27562756+ // Skip IMAP-disabled accounts (nil clients).
27572757+ for range m.clients {
27582758+ m.accountI = (m.accountI + 1) % len(m.clients)
27592759+ if m.clients[m.accountI] != nil {
27602760+ break
27612761+ }
27622762+ }
27542763 m.activeFolderI = 0
27552764 m.loading = true
27562765 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder()))