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 spy-scan over all tabs

sspaeti 1ddfa168 704f2396

+182 -5
+1
docs/content/docs/keybindings.md
··· 96 96 |-----|--------| 97 97 | `<space>1 … <space>9` | jump to folder tab by number (Inbox=1, ToScreen=2, …) | 98 98 | `<space>/` | IMAP search ALL emails on server (From + Subject) | 99 + | `<space>S` | scan current folder for spy pixels (skips already scanned) | 99 100 | `<space>d (reader)` | download raw email source (.eml) to ~/Downloads | 100 101 | `<space>w` | show welcome screen | 101 102
+4
docs/content/docs/reading.md
··· 36 36 37 37 neomd automatically detects and blocks tracking pixels (1x1 invisible images used by newsletter services like Mailchimp, HubSpot, and SendGrid to track email opens). Since the TUI renders emails as styled Markdown, remote images are never fetched — senders cannot tell if you read their email. 38 38 39 + **Detection method:** neomd scans the raw HTML for `<img>` tags with empty `alt` attributes AND at least one of: tiny dimensions (width/height 0 or 1), CSS hiding (`display:none`), or known tracker URL patterns (`/track/open`, `/pixel`, `/beacon`, etc.). Legitimate decorative images with empty alt text but normal dimensions are not flagged. 40 + 39 41 When tracking pixels are detected, neomd shows: 40 42 - `⊙` indicator in the inbox list (orange, next to the attachment `@` column) 41 43 - `⊙ N spy pixel(s) blocked (domain.com)` in the reader header with tracker domains 44 + 45 + **Scanning:** Spy pixels are detected when you read an email. To scan all emails in the current folder at once, press `<space>S` or run `:scan-spy-pixels` (alias `:ssp`). The scan runs in the background, skips already-scanned emails, and uses IMAP PEEK (won't mark emails as read). Results are cached in `~/.cache/neomd/spy_pixels` and persist across restarts. 42 46 43 47 When you press `O` to open in the browser, remote images load normally — you're explicitly choosing to see the full email. Tracking pixel blocking is a TUI-level protection. 44 48
+10
internal/config/config.go
··· 271 271 return p 272 272 } 273 273 274 + // SpyPixelCachePath returns the path for the spy pixel cache file. 275 + func SpyPixelCachePath() string { 276 + if dir, err := os.UserCacheDir(); err == nil { 277 + p := filepath.Join(dir, cacheDirName) 278 + _ = os.MkdirAll(p, 0700) 279 + return filepath.Join(p, "spy_pixels") 280 + } 281 + return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_spy_pixels", os.Getuid())) 282 + } 283 + 274 284 // welcomePath returns the path of the first-run marker file. 275 285 func welcomePath() string { 276 286 if dir, err := os.UserCacheDir(); err == nil {
+56
internal/imap/client.go
··· 759 759 return markdown, rawHTML, webURL, attachments, references, spyPixels, err 760 760 } 761 761 762 + // ScanSpyPixels fetches the body (with Peek) and returns only spy pixel info. 763 + // Lighter than FetchBody — skips markdown conversion and attachment extraction. 764 + func (c *Client) ScanSpyPixels(ctx context.Context, folder string, uid uint32) (SpyPixelInfo, error) { 765 + if ctx == nil { 766 + ctx = context.Background() 767 + } 768 + var spy SpyPixelInfo 769 + err := c.withConn(ctx, func(conn *imapclient.Client) error { 770 + if err := c.selectMailbox(folder); err != nil { 771 + return err 772 + } 773 + var fetchSet imap.UIDSet 774 + fetchSet.AddNum(imap.UID(uid)) 775 + msgs, err := conn.Fetch(fetchSet, &imap.FetchOptions{ 776 + UID: true, 777 + BodySection: []*imap.FetchItemBodySection{{Peek: true}}, 778 + }).Collect() 779 + if err != nil { 780 + return fmt.Errorf("FETCH spy-scan uid=%d: %w", uid, err) 781 + } 782 + if len(msgs) == 0 || len(msgs[0].BodySection) == 0 { 783 + return nil 784 + } 785 + // Extract only the HTML part for spy pixel detection. 786 + htmlText := extractHTMLPart(msgs[0].BodySection[0].Bytes) 787 + if htmlText != "" { 788 + spy = detectSpyPixels(htmlText) 789 + } 790 + return nil 791 + }) 792 + return spy, err 793 + } 794 + 795 + // extractHTMLPart pulls just the text/html content from raw MIME bytes. 796 + func extractHTMLPart(raw []byte) string { 797 + e, err := message.Read(bytes.NewReader(raw)) 798 + if err != nil && !message.IsUnknownCharset(err) { 799 + return "" 800 + } 801 + mr := mail.NewReader(e) 802 + for { 803 + p, err := mr.NextPart() 804 + if err != nil { 805 + break 806 + } 807 + if h, ok := p.Header.(*mail.InlineHeader); ok { 808 + ct, _, _ := h.ContentType() 809 + if ct == "text/html" { 810 + data, _ := io.ReadAll(p.Body) 811 + return string(data) 812 + } 813 + } 814 + } 815 + return "" 816 + } 817 + 762 818 // FetchRaw fetches the full raw MIME source (EML) for a single message. 763 819 func (c *Client) FetchRaw(ctx context.Context, folder string, uid uint32) ([]byte, error) { 764 820 if ctx == nil {
+9
internal/ui/cmdline.go
··· 62 62 }, 63 63 }, 64 64 { 65 + name: "scan-spy-pixels", 66 + aliases: []string{"ssp"}, 67 + desc: "scan current folder for tracking pixels (background, skips already scanned)", 68 + run: func(m *Model) (tea.Model, tea.Cmd) { 69 + m.status = "Scanning for spy pixels…" 70 + return m, m.spyScanCmd() 71 + }, 72 + }, 73 + { 65 74 name: "reload", 66 75 aliases: []string{"r", "re"}, 67 76 desc: "reload / refresh the current folder",
+3 -3
internal/ui/inbox.go
··· 47 47 colThreadWidth = 2 // "│ " or "╰ " or " " 48 48 colDateWidth = 7 // "Feb 03 " 49 49 colAttachWidth = 2 // "@ " or " " 50 - colSpyWidth = 1 // "⊙" or " " — spy pixel indicator 50 + colSpyWidth = 2 // "⊙ " or " " — spy pixel indicator 51 51 colSizeWidth = 7 // "(38.2K)" 52 52 ) 53 53 ··· 92 92 if e.email.HasAttachment { 93 93 attachStr = "@ " 94 94 } 95 - spyStr := " " 95 + spyStr := " " 96 96 if e.hasSpyPixel { 97 - spyStr = "⊙" 97 + spyStr = "⊙ " 98 98 } 99 99 sizeStr := fmtSize(e.email.Size) 100 100
+1
internal/ui/keys.go
··· 71 71 {"Leader Key Mappings (space prefix)", [][2]string{ 72 72 {"<space>1 … <space>9", "jump to folder tab by number (Inbox=1, ToScreen=2, …)"}, 73 73 {"<space>/", "IMAP search ALL emails on server (From + Subject)"}, 74 + {"<space>S", "scan current folder for spy pixels (skips already scanned)"}, 74 75 {"<space>d (reader)", "download raw email source (.eml) to ~/Downloads"}, 75 76 {"<space>w", "show welcome screen"}, 76 77 }},
+98 -2
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. 88 + spyScanProgressMsg struct { 89 + scanned int 90 + total int 91 + found int 92 + done bool 93 + err error 94 + } 87 95 // folderCountsMsg carries unseen counts for watched folder tabs. 88 96 folderCountsMsg struct{ counts map[string]int } 89 97 // deleteAllReadyMsg carries UIDs to permanently delete after y/n confirm. ··· 603 611 compose: compose, 604 612 spinner: sp, 605 613 markedUIDs: make(map[uint32]bool), 606 - spyPixelKeys: make(map[string]bool), 614 + spyPixelKeys: loadSpyPixelCache(), 607 615 startupNotice: detectStartupNotice(), 608 616 sortField: "date", 609 617 sortReverse: true, // newest first ··· 1386 1394 } 1387 1395 } 1388 1396 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. 1399 + func (m Model) spyScanCmd() tea.Cmd { 1400 + 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 1406 + } 1407 + key := spyPixelKey(folder, e.UID) 1408 + if !m.spyPixelKeys[key] { 1409 + uids = append(uids, e.UID) 1410 + } 1411 + } 1412 + if len(uids) == 0 { 1413 + return func() tea.Msg { 1414 + return spyScanProgressMsg{done: true} 1415 + } 1416 + } 1417 + total := len(uids) 1418 + cli := m.imapCli() 1419 + spyKeys := m.spyPixelKeys // shared ref, only written from main goroutine via messages 1420 + 1421 + return func() tea.Msg { 1422 + found := 0 1423 + for i, uid := range uids { 1424 + spy, err := cli.ScanSpyPixels(nil, folder, uid) 1425 + if err != nil { 1426 + return spyScanProgressMsg{err: err, scanned: i, total: total, found: found} 1427 + } 1428 + if spy.Count > 0 { 1429 + 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 1437 + } 1438 + } 1439 + saveSpyPixelCache(spyKeys) 1440 + return spyScanProgressMsg{scanned: total, total: total, found: found, done: true} 1441 + } 1442 + } 1443 + 1389 1444 // resetToScreenSearchCmd is phase 1: just count UIDs in ToScreen so we can 1390 1445 // show the user a confirmation before moving anything. 1391 1446 func (m Model) resetToScreenSearchCmd() tea.Cmd { ··· 1736 1791 // Track spy pixel presence for inbox indicator 1737 1792 if msg.spyPixels.Count > 0 && msg.email != nil { 1738 1793 m.spyPixelKeys[spyPixelKey(msg.email.Folder, msg.email.UID)] = true 1794 + go saveSpyPixelCache(m.spyPixelKeys) 1739 1795 } 1740 1796 // Store References header in the email struct for threading 1741 1797 if msg.email != nil { ··· 1970 2026 m.loading = true 1971 2027 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 1972 2028 2029 + case spyScanProgressMsg: 2030 + if msg.err != nil { 2031 + m.status = "Spy scan error: " + msg.err.Error() 2032 + m.isError = true 2033 + } else if msg.done { 2034 + m.status = fmt.Sprintf("Spy scan complete: %d/%d emails had tracking pixels", msg.found, msg.scanned) 2035 + m.isError = false 2036 + } 2037 + // Rebuild inbox list to show newly discovered ⊙ indicators 2038 + return m, m.applyFilter() 2039 + 1973 2040 case deepScreenCountMsg: 1974 2041 if err := m.validateScreenerSafety(); err != nil { 1975 2042 m.loading = false ··· 2388 2455 2389 2456 case " ": // leader key — wait for digit or shortcut 2390 2457 m.pendingKey = " " 2391 - m.status = "leader: 1-9 folder tab / IMAP search w welcome (esc to cancel)" 2458 + m.status = "leader: 1-9 folder tab / IMAP search S scan spy pixels w welcome (esc to cancel)" 2392 2459 return m, nil 2393 2460 2394 2461 case "M": ··· 2793 2860 _ = os.WriteFile(path, []byte(content), 0600) 2794 2861 } 2795 2862 2863 + // loadSpyPixelCache reads the spy pixel cache from disk. 2864 + // Each line is "folder\x00uid". 2865 + func loadSpyPixelCache() map[string]bool { 2866 + data, err := os.ReadFile(config.SpyPixelCachePath()) 2867 + if err != nil { 2868 + return make(map[string]bool) 2869 + } 2870 + m := make(map[string]bool) 2871 + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { 2872 + if line != "" { 2873 + m[line] = true 2874 + } 2875 + } 2876 + return m 2877 + } 2878 + 2879 + // saveSpyPixelCache writes the spy pixel cache to disk. 2880 + func saveSpyPixelCache(keys map[string]bool) { 2881 + var lines []string 2882 + for k := range keys { 2883 + lines = append(lines, k) 2884 + } 2885 + _ = os.WriteFile(config.SpyPixelCachePath(), []byte(strings.Join(lines, "\n")+"\n"), 0600) 2886 + } 2887 + 2796 2888 func (m Model) shouldPrefixFolderInSubject() bool { 2797 2889 switch m.offTabFolder { 2798 2890 case "Search", "Everything", "Thread": ··· 2867 2959 if key == "w" { 2868 2960 m.state = stateWelcome 2869 2961 return m, nil 2962 + } 2963 + if key == "S" { 2964 + m.status = "Scanning for spy pixels…" 2965 + return m, m.spyScanCmd() 2870 2966 } 2871 2967 if len(key) == 1 && key >= "1" && key <= "9" { 2872 2968 idx := int(key[0] - '1') // 0-based