···85858686**How it works:**
8787- The TUI renders emails as styled Markdown via glamour — **no HTTP requests** are made during rendering, so tracking servers are never contacted. Senders cannot tell if you read their email.
8888-- `cleanMarkdown()` detects empty-alt-text images (the signature of tracking pixels), counts them, extracts the tracker domains, and strips them from the rendered output.
8989-- The inbox list shows a `⊙` indicator (orange) for emails that contained tracking pixels, visible after first read.
8888+- `detectSpyPixels()` scans raw HTML for `<img>` tags with empty alt AND at least one of: tiny dimensions (width/height 0–1), CSS hiding, or known tracker URL patterns. This runs before markdown conversion so size/style info is preserved.
8989+- The inbox list shows a `⊙` indicator (orange) for emails that contained tracking pixels, visible after first read or after running `<space>S` / `:scan-spy-pixels`.
9090- The reader header shows `⊙ N spy pixel(s) blocked (domain.com, ...)` with the tracker domains.
9191+- Scan results are cached in `~/.cache/neomd/spy_pixels` and persist across restarts. Both positive (has tracker) and negative (scanned clean) results are cached so repeat scans are instant.
91929292-**Browser view (`O`):** When you explicitly open an email in the browser, remote images are loaded — this is intentional, as you're choosing to see the full email. Tracking pixels are only blocked in the TUI.
9393+**Browser view (`O`):** When you open an email in the browser, a Content-Security-Policy is injected that blocks JavaScript, iframes, and embedded objects (`script-src 'none'; frame-src 'none'; object-src 'none'`). Remote images are intentionally allowed — you're choosing to see the full email. This prevents script execution while preserving the visual experience.
93949494-**Code:** [`internal/imap/client.go`](https://github.com/ssp-data/neomd/blob/main/internal/imap/client.go) — `detectSpyPixels()`, `SpyPixelInfo` · [`internal/ui/inbox.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/inbox.go) — `⊙` indicator · [`internal/ui/reader.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/reader.go) — `renderEmailHeader()`
9595+**Code:** [`internal/imap/client.go`](https://github.com/ssp-data/neomd/blob/main/internal/imap/client.go) — `detectSpyPixels()`, `ScanSpyPixels()` · [`internal/render/html.go`](https://github.com/ssp-data/neomd/blob/main/internal/render/html.go) — `SanitizeForBrowser()` · [`internal/ui/inbox.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/inbox.go) — `⊙` indicator · [`internal/ui/reader.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/reader.go) — `renderEmailHeader()`
95969697---
9798
+112-57
internal/ui/model.go
···8484 }
8585 // resetToScreenReadyMsg is returned once we know how many emails are in ToScreen.
8686 resetToScreenReadyMsg struct{ uids []uint32 }
8787- // spyScanProgressMsg reports progress during :scan-spy-pixels.
8787+ // spyScanProgressMsg reports results of :scan-spy-pixels.
8888 spyScanProgressMsg struct {
8989- scanned int
9090- total int
9191- found int
9292- done bool
9393- err error
8989+ spyKeys []string // keys where spy pixels were found
9090+ scannedKeys []string // all keys that were scanned (for cache)
9191+ scanned int
9292+ total int
9393+ found int
9494+ done bool
9595+ err error
9496 }
9597 // folderCountsMsg carries unseen counts for watched folder tabs.
9698 folderCountsMsg struct{ counts map[string]int }
···512514 // Populated on body load, used to show ⊙ indicator in inbox list.
513515 // Keyed by folder+UID to avoid collisions across mailboxes (UIDs are only unique per folder).
514516 spyPixelKeys map[string]bool
517517+ // spyScannedKeys tracks which emails have been scanned (positive or negative).
518518+ // Used to skip already-scanned emails in :scan-spy-pixels.
519519+ spyScannedKeys map[string]bool
515520516521 // Undo stack: each entry is a batch of moves that can be reversed with u.
517522 // Screener operations (I/O/F/P/$) are not undoable — they also modify .txt files.
···597602 mp = mailto[0]
598603 }
599604605605+ spyKeys, scannedKeys := loadSpyPixelCache()
600606 return Model{
601607 cfg: cfg,
602608 accounts: cfg.ActiveAccounts(),
···608614 cmdHistory: loadCmdHistory(config.HistoryPath()),
609615 cmdHistI: -1,
610616 // Note: Spam is intentionally excluded from tabs — use :go-spam to visit.
611611- compose: compose,
612612- spinner: sp,
613613- markedUIDs: make(map[uint32]bool),
614614- spyPixelKeys: loadSpyPixelCache(),
615615- startupNotice: detectStartupNotice(),
616616- sortField: "date",
617617- sortReverse: true, // newest first
618618- mailto: mp,
617617+ compose: compose,
618618+ spinner: sp,
619619+ markedUIDs: make(map[uint32]bool),
620620+ spyPixelKeys: spyKeys,
621621+ spyScannedKeys: scannedKeys,
622622+ startupNotice: detectStartupNotice(),
623623+ sortField: "date",
624624+ sortReverse: true, // newest first
625625+ mailto: mp,
619626 }
620627}
621628···13941401 }
13951402}
1396140313971397-// spyScanCmd scans emails in the current folder for spy pixels in the background.
13981398-// Skips UIDs already in the cache. Uses Peek so read status is unchanged.
14041404+// spyScanCmd scans all emails in the current folder for spy pixels.
14051405+// Fetches the full UID list from the server, skips already-scanned UIDs,
14061406+// and returns results via message for the Update loop to merge.
13991407func (m Model) spyScanCmd() tea.Cmd {
14001408 folder := m.activeFolder()
14011401- // Collect UIDs to scan: all emails in current view minus already-cached ones.
14021402- var uids []uint32
14031403- for _, e := range m.emails {
14041404- if e.Folder != "" && e.Folder != folder {
14051405- continue
14091409+ cli := m.imapCli()
14101410+ // Copy scanned set to avoid concurrent read.
14111411+ alreadyScanned := make(map[string]bool, len(m.spyScannedKeys))
14121412+ for k, v := range m.spyScannedKeys {
14131413+ alreadyScanned[k] = v
14141414+ }
14151415+14161416+ return func() tea.Msg {
14171417+ // Fetch all UIDs in the folder from the server.
14181418+ allUIDs, err := cli.SearchUIDs(nil, folder)
14191419+ if err != nil {
14201420+ return spyScanProgressMsg{err: err}
14061421 }
14071407- key := spyPixelKey(folder, e.UID)
14081408- if !m.spyPixelKeys[key] {
14091409- uids = append(uids, e.UID)
14221422+ // Filter to only unscanned UIDs.
14231423+ var uids []uint32
14241424+ for _, uid := range allUIDs {
14251425+ if !alreadyScanned[spyPixelKey(folder, uid)] {
14261426+ uids = append(uids, uid)
14271427+ }
14101428 }
14111411- }
14121412- if len(uids) == 0 {
14131413- return func() tea.Msg {
14291429+ if len(uids) == 0 {
14141430 return spyScanProgressMsg{done: true}
14151431 }
14161416- }
14171417- total := len(uids)
14181418- cli := m.imapCli()
14191419- spyKeys := m.spyPixelKeys // shared ref, only written from main goroutine via messages
1420143214211421- return func() tea.Msg {
14331433+ total := len(uids)
14341434+ var spyFound []string
14351435+ var allScanned []string
14221436 found := 0
14231437 for i, uid := range uids {
14241438 spy, err := cli.ScanSpyPixels(nil, folder, uid)
14251439 if err != nil {
14261426- return spyScanProgressMsg{err: err, scanned: i, total: total, found: found}
14401440+ return spyScanProgressMsg{err: err, scanned: i, total: total, found: found,
14411441+ spyKeys: spyFound, scannedKeys: allScanned}
14271442 }
14431443+ key := spyPixelKey(folder, uid)
14441444+ allScanned = append(allScanned, key)
14281445 if spy.Count > 0 {
14291446 found++
14301430- spyKeys[spyPixelKey(folder, uid)] = true
14311431- }
14321432- // Report progress every 10 emails
14331433- if (i+1)%10 == 0 {
14341434- // We can't send intermediate messages from a single Cmd,
14351435- // so we just let it run and report at the end.
14361436- _ = i
14471447+ spyFound = append(spyFound, key)
14371448 }
14381449 }
14391439- saveSpyPixelCache(spyKeys)
14401440- return spyScanProgressMsg{scanned: total, total: total, found: found, done: true}
14501450+ return spyScanProgressMsg{
14511451+ scanned: total, total: total, found: found, done: true,
14521452+ spyKeys: spyFound, scannedKeys: allScanned,
14531453+ }
14411454 }
14421455}
14431456···17891802 m.openAttachments = msg.attachments
17901803 m.openSpyPixels = msg.spyPixels
17911804 // Track spy pixel presence for inbox indicator
17921792- if msg.spyPixels.Count > 0 && msg.email != nil {
17931793- m.spyPixelKeys[spyPixelKey(msg.email.Folder, msg.email.UID)] = true
17941794- go saveSpyPixelCache(m.spyPixelKeys)
18051805+ if msg.email != nil {
18061806+ key := spyPixelKey(msg.email.Folder, msg.email.UID)
18071807+ if msg.spyPixels.Count > 0 {
18081808+ m.spyPixelKeys[key] = true
18091809+ }
18101810+ if !m.spyScannedKeys[key] {
18111811+ m.spyScannedKeys[key] = true
18121812+ go saveSpyPixelCache(copyMap(m.spyPixelKeys), copyMap(m.spyScannedKeys))
18131813+ }
17951814 }
17961815 // Store References header in the email struct for threading
17971816 if msg.email != nil {
···20272046 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder()))
2028204720292048 case spyScanProgressMsg:
20492049+ // Merge results into maps on the main goroutine (no concurrency).
20502050+ for _, k := range msg.spyKeys {
20512051+ m.spyPixelKeys[k] = true
20522052+ }
20532053+ for _, k := range msg.scannedKeys {
20542054+ m.spyScannedKeys[k] = true
20552055+ }
20302056 if msg.err != nil {
20312057 m.status = "Spy scan error: " + msg.err.Error()
20322058 m.isError = true
20592059+ } else if msg.done && msg.total == 0 {
20602060+ m.status = "Spy scan: all emails already scanned"
20612061+ m.isError = false
20332062 } else if msg.done {
20342063 m.status = fmt.Sprintf("Spy scan complete: %d/%d emails had tracking pixels", msg.found, msg.scanned)
20352064 m.isError = false
20362065 }
20372037- // Rebuild inbox list to show newly discovered ⊙ indicators
20662066+ // Save cache and rebuild inbox on the main goroutine.
20672067+ if len(msg.scannedKeys) > 0 {
20682068+ go saveSpyPixelCache(copyMap(m.spyPixelKeys), copyMap(m.spyScannedKeys))
20692069+ }
20382070 return m, m.applyFilter()
2039207120402072 case deepScreenCountMsg:
···28612893}
2862289428632895// loadSpyPixelCache reads the spy pixel cache from disk.
28642864-// Each line is "folder\x00uid".
28652865-func loadSpyPixelCache() map[string]bool {
28962896+// Lines prefixed with "+" have spy pixels, "-" were scanned clean.
28972897+func loadSpyPixelCache() (spyKeys, scannedKeys map[string]bool) {
28982898+ spyKeys = make(map[string]bool)
28992899+ scannedKeys = make(map[string]bool)
28662900 data, err := os.ReadFile(config.SpyPixelCachePath())
28672901 if err != nil {
28682868- return make(map[string]bool)
29022902+ return
28692903 }
28702870- m := make(map[string]bool)
28712904 for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
28722872- if line != "" {
28732873- m[line] = true
29052905+ if len(line) < 2 {
29062906+ continue
29072907+ }
29082908+ key := line[1:]
29092909+ switch line[0] {
29102910+ case '+':
29112911+ spyKeys[key] = true
29122912+ scannedKeys[key] = true
29132913+ case '-':
29142914+ scannedKeys[key] = true
28742915 }
28752916 }
28762876- return m
29172917+ return
28772918}
2878291928792879-// saveSpyPixelCache writes the spy pixel cache to disk.
28802880-func saveSpyPixelCache(keys map[string]bool) {
29202920+// saveSpyPixelCache writes a snapshot of the spy pixel cache to disk.
29212921+// Takes copied maps to avoid concurrent access.
29222922+func saveSpyPixelCache(spyKeys, scannedKeys map[string]bool) {
28812923 var lines []string
28822882- for k := range keys {
28832883- lines = append(lines, k)
29242924+ for k := range scannedKeys {
29252925+ if spyKeys[k] {
29262926+ lines = append(lines, "+"+k)
29272927+ } else {
29282928+ lines = append(lines, "-"+k)
29292929+ }
28842930 }
28852931 _ = os.WriteFile(config.SpyPixelCachePath(), []byte(strings.Join(lines, "\n")+"\n"), 0600)
29322932+}
29332933+29342934+// copyMap returns a shallow copy of a map, safe for passing to goroutines.
29352935+func copyMap(m map[string]bool) map[string]bool {
29362936+ c := make(map[string]bool, len(m))
29372937+ for k, v := range m {
29382938+ c[k] = v
29392939+ }
29402940+ return c
28862941}
2887294228882943func (m Model) shouldPrefixFolderInSubject() bool {