···9696|-----|--------|
9797| `<space>1 … <space>9` | jump to folder tab by number (Inbox=1, ToScreen=2, …) |
9898| `<space>/` | IMAP search ALL emails on server (From + Subject) |
9999+| `<space>S` | scan current folder for spy pixels (skips already scanned) |
99100| `<space>d (reader)` | download raw email source (.eml) to ~/Downloads |
100101| `<space>w` | show welcome screen |
101102
+4
docs/content/docs/reading.md
···36363737neomd 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.
38383939+**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.
4040+3941When tracking pixels are detected, neomd shows:
4042- `⊙` indicator in the inbox list (orange, next to the attachment `@` column)
4143- `⊙ N spy pixel(s) blocked (domain.com)` in the reader header with tracker domains
4444+4545+**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.
42464347When 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.
4448
+10
internal/config/config.go
···271271 return p
272272}
273273274274+// SpyPixelCachePath returns the path for the spy pixel cache file.
275275+func SpyPixelCachePath() string {
276276+ if dir, err := os.UserCacheDir(); err == nil {
277277+ p := filepath.Join(dir, cacheDirName)
278278+ _ = os.MkdirAll(p, 0700)
279279+ return filepath.Join(p, "spy_pixels")
280280+ }
281281+ return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_spy_pixels", os.Getuid()))
282282+}
283283+274284// welcomePath returns the path of the first-run marker file.
275285func welcomePath() string {
276286 if dir, err := os.UserCacheDir(); err == nil {
+56
internal/imap/client.go
···759759 return markdown, rawHTML, webURL, attachments, references, spyPixels, err
760760}
761761762762+// ScanSpyPixels fetches the body (with Peek) and returns only spy pixel info.
763763+// Lighter than FetchBody — skips markdown conversion and attachment extraction.
764764+func (c *Client) ScanSpyPixels(ctx context.Context, folder string, uid uint32) (SpyPixelInfo, error) {
765765+ if ctx == nil {
766766+ ctx = context.Background()
767767+ }
768768+ var spy SpyPixelInfo
769769+ err := c.withConn(ctx, func(conn *imapclient.Client) error {
770770+ if err := c.selectMailbox(folder); err != nil {
771771+ return err
772772+ }
773773+ var fetchSet imap.UIDSet
774774+ fetchSet.AddNum(imap.UID(uid))
775775+ msgs, err := conn.Fetch(fetchSet, &imap.FetchOptions{
776776+ UID: true,
777777+ BodySection: []*imap.FetchItemBodySection{{Peek: true}},
778778+ }).Collect()
779779+ if err != nil {
780780+ return fmt.Errorf("FETCH spy-scan uid=%d: %w", uid, err)
781781+ }
782782+ if len(msgs) == 0 || len(msgs[0].BodySection) == 0 {
783783+ return nil
784784+ }
785785+ // Extract only the HTML part for spy pixel detection.
786786+ htmlText := extractHTMLPart(msgs[0].BodySection[0].Bytes)
787787+ if htmlText != "" {
788788+ spy = detectSpyPixels(htmlText)
789789+ }
790790+ return nil
791791+ })
792792+ return spy, err
793793+}
794794+795795+// extractHTMLPart pulls just the text/html content from raw MIME bytes.
796796+func extractHTMLPart(raw []byte) string {
797797+ e, err := message.Read(bytes.NewReader(raw))
798798+ if err != nil && !message.IsUnknownCharset(err) {
799799+ return ""
800800+ }
801801+ mr := mail.NewReader(e)
802802+ for {
803803+ p, err := mr.NextPart()
804804+ if err != nil {
805805+ break
806806+ }
807807+ if h, ok := p.Header.(*mail.InlineHeader); ok {
808808+ ct, _, _ := h.ContentType()
809809+ if ct == "text/html" {
810810+ data, _ := io.ReadAll(p.Body)
811811+ return string(data)
812812+ }
813813+ }
814814+ }
815815+ return ""
816816+}
817817+762818// FetchRaw fetches the full raw MIME source (EML) for a single message.
763819func (c *Client) FetchRaw(ctx context.Context, folder string, uid uint32) ([]byte, error) {
764820 if ctx == nil {