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.

fix scan

+117 -61
+5 -4
SECURITY.md
··· 85 85 86 86 **How it works:** 87 87 - 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. 88 - - `cleanMarkdown()` detects empty-alt-text images (the signature of tracking pixels), counts them, extracts the tracker domains, and strips them from the rendered output. 89 - - The inbox list shows a `⊙` indicator (orange) for emails that contained tracking pixels, visible after first read. 88 + - `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. 89 + - 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`. 90 90 - The reader header shows `⊙ N spy pixel(s) blocked (domain.com, ...)` with the tracker domains. 91 + - 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. 91 92 92 - **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. 93 + **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. 93 94 94 - **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()` 95 + **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()` 95 96 96 97 --- 97 98
+112 -57
internal/ui/model.go
··· 84 84 } 85 85 // resetToScreenReadyMsg is returned once we know how many emails are in ToScreen. 86 86 resetToScreenReadyMsg struct{ uids []uint32 } 87 - // spyScanProgressMsg reports progress during :scan-spy-pixels. 87 + // spyScanProgressMsg reports results of :scan-spy-pixels. 88 88 spyScanProgressMsg struct { 89 - scanned int 90 - total int 91 - found int 92 - done bool 93 - err error 89 + spyKeys []string // keys where spy pixels were found 90 + scannedKeys []string // all keys that were scanned (for cache) 91 + scanned int 92 + total int 93 + found int 94 + done bool 95 + err error 94 96 } 95 97 // folderCountsMsg carries unseen counts for watched folder tabs. 96 98 folderCountsMsg struct{ counts map[string]int } ··· 512 514 // Populated on body load, used to show ⊙ indicator in inbox list. 513 515 // Keyed by folder+UID to avoid collisions across mailboxes (UIDs are only unique per folder). 514 516 spyPixelKeys map[string]bool 517 + // spyScannedKeys tracks which emails have been scanned (positive or negative). 518 + // Used to skip already-scanned emails in :scan-spy-pixels. 519 + spyScannedKeys map[string]bool 515 520 516 521 // Undo stack: each entry is a batch of moves that can be reversed with u. 517 522 // Screener operations (I/O/F/P/$) are not undoable — they also modify .txt files. ··· 597 602 mp = mailto[0] 598 603 } 599 604 605 + spyKeys, scannedKeys := loadSpyPixelCache() 600 606 return Model{ 601 607 cfg: cfg, 602 608 accounts: cfg.ActiveAccounts(), ··· 608 614 cmdHistory: loadCmdHistory(config.HistoryPath()), 609 615 cmdHistI: -1, 610 616 // Note: Spam is intentionally excluded from tabs — use :go-spam to visit. 611 - compose: compose, 612 - spinner: sp, 613 - markedUIDs: make(map[uint32]bool), 614 - spyPixelKeys: loadSpyPixelCache(), 615 - startupNotice: detectStartupNotice(), 616 - sortField: "date", 617 - sortReverse: true, // newest first 618 - mailto: mp, 617 + compose: compose, 618 + spinner: sp, 619 + markedUIDs: make(map[uint32]bool), 620 + spyPixelKeys: spyKeys, 621 + spyScannedKeys: scannedKeys, 622 + startupNotice: detectStartupNotice(), 623 + sortField: "date", 624 + sortReverse: true, // newest first 625 + mailto: mp, 619 626 } 620 627 } 621 628 ··· 1394 1401 } 1395 1402 } 1396 1403 1397 - // spyScanCmd scans emails in the current folder for spy pixels in the background. 1398 - // Skips UIDs already in the cache. Uses Peek so read status is unchanged. 1404 + // spyScanCmd scans all emails in the current folder for spy pixels. 1405 + // Fetches the full UID list from the server, skips already-scanned UIDs, 1406 + // and returns results via message for the Update loop to merge. 1399 1407 func (m Model) spyScanCmd() tea.Cmd { 1400 1408 folder := m.activeFolder() 1401 - // Collect UIDs to scan: all emails in current view minus already-cached ones. 1402 - var uids []uint32 1403 - for _, e := range m.emails { 1404 - if e.Folder != "" && e.Folder != folder { 1405 - continue 1409 + cli := m.imapCli() 1410 + // Copy scanned set to avoid concurrent read. 1411 + alreadyScanned := make(map[string]bool, len(m.spyScannedKeys)) 1412 + for k, v := range m.spyScannedKeys { 1413 + alreadyScanned[k] = v 1414 + } 1415 + 1416 + return func() tea.Msg { 1417 + // Fetch all UIDs in the folder from the server. 1418 + allUIDs, err := cli.SearchUIDs(nil, folder) 1419 + if err != nil { 1420 + return spyScanProgressMsg{err: err} 1406 1421 } 1407 - key := spyPixelKey(folder, e.UID) 1408 - if !m.spyPixelKeys[key] { 1409 - uids = append(uids, e.UID) 1422 + // Filter to only unscanned UIDs. 1423 + var uids []uint32 1424 + for _, uid := range allUIDs { 1425 + if !alreadyScanned[spyPixelKey(folder, uid)] { 1426 + uids = append(uids, uid) 1427 + } 1410 1428 } 1411 - } 1412 - if len(uids) == 0 { 1413 - return func() tea.Msg { 1429 + if len(uids) == 0 { 1414 1430 return spyScanProgressMsg{done: true} 1415 1431 } 1416 - } 1417 - total := len(uids) 1418 - cli := m.imapCli() 1419 - spyKeys := m.spyPixelKeys // shared ref, only written from main goroutine via messages 1420 1432 1421 - return func() tea.Msg { 1433 + total := len(uids) 1434 + var spyFound []string 1435 + var allScanned []string 1422 1436 found := 0 1423 1437 for i, uid := range uids { 1424 1438 spy, err := cli.ScanSpyPixels(nil, folder, uid) 1425 1439 if err != nil { 1426 - return spyScanProgressMsg{err: err, scanned: i, total: total, found: found} 1440 + return spyScanProgressMsg{err: err, scanned: i, total: total, found: found, 1441 + spyKeys: spyFound, scannedKeys: allScanned} 1427 1442 } 1443 + key := spyPixelKey(folder, uid) 1444 + allScanned = append(allScanned, key) 1428 1445 if spy.Count > 0 { 1429 1446 found++ 1430 - spyKeys[spyPixelKey(folder, uid)] = true 1431 - } 1432 - // Report progress every 10 emails 1433 - if (i+1)%10 == 0 { 1434 - // We can't send intermediate messages from a single Cmd, 1435 - // so we just let it run and report at the end. 1436 - _ = i 1447 + spyFound = append(spyFound, key) 1437 1448 } 1438 1449 } 1439 - saveSpyPixelCache(spyKeys) 1440 - return spyScanProgressMsg{scanned: total, total: total, found: found, done: true} 1450 + return spyScanProgressMsg{ 1451 + scanned: total, total: total, found: found, done: true, 1452 + spyKeys: spyFound, scannedKeys: allScanned, 1453 + } 1441 1454 } 1442 1455 } 1443 1456 ··· 1789 1802 m.openAttachments = msg.attachments 1790 1803 m.openSpyPixels = msg.spyPixels 1791 1804 // Track spy pixel presence for inbox indicator 1792 - if msg.spyPixels.Count > 0 && msg.email != nil { 1793 - m.spyPixelKeys[spyPixelKey(msg.email.Folder, msg.email.UID)] = true 1794 - go saveSpyPixelCache(m.spyPixelKeys) 1805 + if msg.email != nil { 1806 + key := spyPixelKey(msg.email.Folder, msg.email.UID) 1807 + if msg.spyPixels.Count > 0 { 1808 + m.spyPixelKeys[key] = true 1809 + } 1810 + if !m.spyScannedKeys[key] { 1811 + m.spyScannedKeys[key] = true 1812 + go saveSpyPixelCache(copyMap(m.spyPixelKeys), copyMap(m.spyScannedKeys)) 1813 + } 1795 1814 } 1796 1815 // Store References header in the email struct for threading 1797 1816 if msg.email != nil { ··· 2027 2046 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 2028 2047 2029 2048 case spyScanProgressMsg: 2049 + // Merge results into maps on the main goroutine (no concurrency). 2050 + for _, k := range msg.spyKeys { 2051 + m.spyPixelKeys[k] = true 2052 + } 2053 + for _, k := range msg.scannedKeys { 2054 + m.spyScannedKeys[k] = true 2055 + } 2030 2056 if msg.err != nil { 2031 2057 m.status = "Spy scan error: " + msg.err.Error() 2032 2058 m.isError = true 2059 + } else if msg.done && msg.total == 0 { 2060 + m.status = "Spy scan: all emails already scanned" 2061 + m.isError = false 2033 2062 } else if msg.done { 2034 2063 m.status = fmt.Sprintf("Spy scan complete: %d/%d emails had tracking pixels", msg.found, msg.scanned) 2035 2064 m.isError = false 2036 2065 } 2037 - // Rebuild inbox list to show newly discovered ⊙ indicators 2066 + // Save cache and rebuild inbox on the main goroutine. 2067 + if len(msg.scannedKeys) > 0 { 2068 + go saveSpyPixelCache(copyMap(m.spyPixelKeys), copyMap(m.spyScannedKeys)) 2069 + } 2038 2070 return m, m.applyFilter() 2039 2071 2040 2072 case deepScreenCountMsg: ··· 2861 2893 } 2862 2894 2863 2895 // loadSpyPixelCache reads the spy pixel cache from disk. 2864 - // Each line is "folder\x00uid". 2865 - func loadSpyPixelCache() map[string]bool { 2896 + // Lines prefixed with "+" have spy pixels, "-" were scanned clean. 2897 + func loadSpyPixelCache() (spyKeys, scannedKeys map[string]bool) { 2898 + spyKeys = make(map[string]bool) 2899 + scannedKeys = make(map[string]bool) 2866 2900 data, err := os.ReadFile(config.SpyPixelCachePath()) 2867 2901 if err != nil { 2868 - return make(map[string]bool) 2902 + return 2869 2903 } 2870 - m := make(map[string]bool) 2871 2904 for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { 2872 - if line != "" { 2873 - m[line] = true 2905 + if len(line) < 2 { 2906 + continue 2907 + } 2908 + key := line[1:] 2909 + switch line[0] { 2910 + case '+': 2911 + spyKeys[key] = true 2912 + scannedKeys[key] = true 2913 + case '-': 2914 + scannedKeys[key] = true 2874 2915 } 2875 2916 } 2876 - return m 2917 + return 2877 2918 } 2878 2919 2879 - // saveSpyPixelCache writes the spy pixel cache to disk. 2880 - func saveSpyPixelCache(keys map[string]bool) { 2920 + // saveSpyPixelCache writes a snapshot of the spy pixel cache to disk. 2921 + // Takes copied maps to avoid concurrent access. 2922 + func saveSpyPixelCache(spyKeys, scannedKeys map[string]bool) { 2881 2923 var lines []string 2882 - for k := range keys { 2883 - lines = append(lines, k) 2924 + for k := range scannedKeys { 2925 + if spyKeys[k] { 2926 + lines = append(lines, "+"+k) 2927 + } else { 2928 + lines = append(lines, "-"+k) 2929 + } 2884 2930 } 2885 2931 _ = os.WriteFile(config.SpyPixelCachePath(), []byte(strings.Join(lines, "\n")+"\n"), 0600) 2932 + } 2933 + 2934 + // copyMap returns a shallow copy of a map, safe for passing to goroutines. 2935 + func copyMap(m map[string]bool) map[string]bool { 2936 + c := make(map[string]bool, len(m)) 2937 + for k, v := range m { 2938 + c[k] = v 2939 + } 2940 + return c 2886 2941 } 2887 2942 2888 2943 func (m Model) shouldPrefixFolderInSubject() bool {