···149149- **HEY-style screener** — unknown senders land in `ToScreen`; press `I/O/F/P` to approve, block, mark as Feed, or mark as PaperTrail; reuses your existing `screened_in.txt` lists from neomutt [more](https://ssp-data.github.io/neomd/docs/screener/)
150150- **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut [more](https://ssp-data.github.io/neomd/docs/keybindings/#folders)
151151- **Emoji reactions** — press `ctrl+e` from inbox or reader to react with emoji (👍 ❤️ 😂 🎉 🙏 💯 👀 ✅); instant send with proper threading and quoted message history, no editor needed; reactions appear in conversation threads with neomd branding [more](https://ssp-data.github.io/neomd/docs/sending/#emoji-reactions)
152152+- **Spy pixel blocking** — tracking pixels from newsletter services (Mailchimp, SendGrid, HubSpot, etc.) are automatically detected, counted, and stripped; `⊙` indicator in the inbox and tracker domains in the reader header; browser view (`O`) blocks remote images via CSP — senders cannot tell if you read their email, similar to [HEY's spy pixel blocker](https://www.hey.com/features/spy-pixel-blocker/) [more](https://ssp-data.github.io/neomd/docs/reading/#spy-pixel-blocking)
152153- **GitHub/Obsidian-style callouts in emails** — compose emails with callout syntax `> [!note]`, `> [!tip]`, `> [!warning]` for styled alert boxes in HTML emails; rendered with colored left borders, subtle backgrounds, and emoji icons [more](https://ssp-data.github.io/neomd/docs/sending/#callouts-admonition)
153154- **Multi-select** — `m` marks emails, then batch-delete, move, or screen them all at once [more](https://ssp-data.github.io/neomd/docs/keybindings/#multi-select--undo)
154155- **Auto-screen on load** — screener runs automatically every time the Inbox loads (startup, `R`); keeps your inbox clean without pressing `S` (configurable, on by default) [more](https://ssp-data.github.io/neomd/docs/screener/#auto-screen-and-background-sync)
+27-2
SECURITY.md
···73737474## URL handling
75757676-Email-extracted URLs (from `ctrl+o` / `List-Post` header) are validated before being passed to the browser: only `http://` and `https://` schemes are allowed. URLs with any other scheme (e.g. `javascript:`) are blocked and shown as an error in the status bar.
7676+All email-extracted URLs — both numbered inline links (`space+digit`) and `ctrl+o` / `List-Post` web version links — are validated before being passed to the browser. Only `http://`, `https://`, and `mailto:` schemes are allowed. URLs with any other scheme (e.g. `javascript:`, `data:`) are blocked and shown as an error in the status bar.
7777+7878+**Code:** [`internal/ui/model.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/model.go) — `openLinkCmd()`, `openWebVersion()`
7979+8080+---
8181+8282+## Spy pixel blocking
8383+8484+neomd automatically detects and blocks tracking pixels (1x1 invisible images embedded by newsletter services like Mailchimp, HubSpot, and SendGrid to track email opens).
8585+8686+**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.
9090+- The reader header shows `⊙ N spy pixel(s) blocked (domain.com, ...)` with the tracker domains.
9191+9292+**Browser view (`O`) protection:**
9393+- The HTML template includes a `Content-Security-Policy` meta tag that restricts image sources to `file:`, `data:`, and `cid:` only. Remote images (including tracking pixels) are blocked even when viewing the full HTML version in a browser.
9494+9595+**Code:** [`internal/imap/client.go`](https://github.com/ssp-data/neomd/blob/main/internal/imap/client.go) — `cleanMarkdown()`, `SpyPixelInfo` · [`internal/render/html.go`](https://github.com/ssp-data/neomd/blob/main/internal/render/html.go) — `htmlTemplate` (CSP) · [`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()`
77967878-**Code:** [`internal/ui/model.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/model.go) — `openWebVersion()`
9797+---
9898+9999+## Attachment safety
100100+101101+Attachments are saved to `~/Downloads/` and opened with `xdg-open`. To prevent accidental execution of malicious files, neomd maintains a blocklist of dangerous file extensions (`.sh`, `.exe`, `.desktop`, `.bat`, `.py`, `.jar`, etc.). Files with these extensions are **saved but not auto-opened** — the status bar warns that the file type is dangerous and the user must open it manually.
102102+103103+**Code:** [`internal/ui/model.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/model.go) — `dangerousExts`, `downloadOpenAttachmentCmd()`
7910480105---
81106
+1
docs/content/docs/_index.md
···149149- **Threaded inbox** — related emails are grouped together in the inbox list with a vertical connector line (`│`/`╰`), Twitter-style; threads are detected via `In-Reply-To`/`Message-ID` headers with a subject+participant fallback; newest reply on top, root at bottom; `·` reply indicator shows which emails you've answered [more](https://ssp-data.github.io/neomd/docs/reading/#threaded-inbox)
150150- **Conversation view** — `T` or `:thread` shows the full conversation across folders (Inbox, Sent, Archive, etc.) in a temporary tab with `[Folder]` prefix; see your replies alongside received emails [more](https://ssp-data.github.io/neomd/docs/reading/#conversation-view)
151151- **Glamour reading** — incoming emails rendered as styled Markdown in the terminal [more](https://ssp-data.github.io/neomd/docs/reading/)
152152+- **Spy pixel blocking** — tracking pixels from newsletter services (Mailchimp, SendGrid, HubSpot, etc.) are automatically detected, counted, and stripped; `⊙` indicator in the inbox and tracker domains in the reader header; browser view (`O`) blocks remote images via CSP — senders cannot tell if you read their email, similar to [HEY's spy pixel blocker](https://www.hey.com/features/spy-pixel-blocker/) [more](https://ssp-data.github.io/neomd/docs/reading/#spy-pixel-blocking)
152153- **HEY-style screener** — unknown senders land in `ToScreen`; press `I/O/F/P` to approve, block, mark as Feed, or mark as PaperTrail; reuses your existing `screened_in.txt` lists from neomutt [more](https://ssp-data.github.io/neomd/docs/screener/)
153154- **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut [more](https://ssp-data.github.io/neomd/docs/keybindings/#folders)
154155- **Emoji reactions** — press `ctrl+e` from inbox or reader to react with emoji (👍 ❤️ 😂 🎉 🙏 💯 👀 ✅); instant send with proper threading and quoted message history, no editor needed; reactions appear in conversation threads with neomd branding [more](https://ssp-data.github.io/neomd/docs/sending/#emoji-reactions)
+20
docs/content/docs/reading.md
···32323333When you press `O` to open in the browser, inline images are extracted from the email and saved to temp files. The HTML `cid:` references are rewritten to `file://` paths so the browser renders them — including images sent by other people (not just your own).
34343535+## Spy Pixel Blocking
3636+3737+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.
3838+3939+When tracking pixels are detected, neomd shows:
4040+- `⊙` indicator in the inbox list (orange, next to the attachment `@` column)
4141+- `⊙ N spy pixel(s) blocked (domain.com)` in the reader header with tracker domains
4242+4343+The browser view (`O`) also blocks remote images via a Content-Security-Policy header, so tracking pixels are blocked even when viewing the full HTML version.
4444+4545+### This is how it looks:
4646+4747+In overview:
4848+
4949+5050+And within an email open:
5151+5252+
5353+3554## Links
36553756Links in emails are automatically numbered inline where they appear in the body. A link like `Check out our blog` renders as `Check out our blog [1]` in the terminal.
···81100- `·╰ ` reply indicator within a thread
82101- `│` connects thread members (newest on top)
83102- `╰` marks the root/oldest email at the bottom of each thread
103103+- `⊙` spy pixel indicator — tracking pixels were detected and blocked (shown after first read)
84104- Non-threaded emails show no connector (clean, no visual noise)
85105- Threads are sorted by their most recent email, so active conversations float to the top
86106
docs/static/images/spy-pixel-mail.png
This is a binary file and will not be displayed.
docs/static/images/spy-pixel.png
This is a binary file and will not be displayed.
+60-17
internal/imap/client.go
···725725726726// FetchBody fetches the body of a single message.
727727// Returns (markdownBody, rawHTML, webURL, attachments, references, error).
728728-func (c *Client) FetchBody(ctx context.Context, folder string, uid uint32) (string, string, string, []Attachment, string, error) {
728728+func (c *Client) FetchBody(ctx context.Context, folder string, uid uint32) (string, string, string, []Attachment, string, SpyPixelInfo, error) {
729729 if ctx == nil {
730730 ctx = context.Background()
731731 }
732732 var markdown, rawHTML, webURL, references string
733733 var attachments []Attachment
734734+ var spyPixels SpyPixelInfo
734735 err := c.withConn(ctx, func(conn *imapclient.Client) error {
735736 if err := c.selectMailbox(folder); err != nil {
736737 return err
···751752 }
752753753754 if len(msgs[0].BodySection) > 0 {
754754- markdown, rawHTML, webURL, attachments, references = parseBody(msgs[0].BodySection[0].Bytes)
755755+ markdown, rawHTML, webURL, attachments, references, spyPixels = parseBody(msgs[0].BodySection[0].Bytes)
755756 }
756757 return nil
757758 })
758758- return markdown, rawHTML, webURL, attachments, references, err
759759+ return markdown, rawHTML, webURL, attachments, references, spyPixels, err
759760}
760761761762// FetchRaw fetches the full raw MIME source (EML) for a single message.
···10141015// - rawHTML: original HTML part verbatim (empty for plain-text emails)
10151016// - webURL: "view online" URL extracted from List-Post header or plain-text
10161017// preamble (e.g. Substack's "View this post on the web at https://…")
10171017-func parseBody(raw []byte) (markdown, rawHTML, webURL string, attachments []Attachment, references string) {
10181018+func parseBody(raw []byte) (markdown, rawHTML, webURL string, attachments []Attachment, references string, spyPixels SpyPixelInfo) {
10181019 e, err := message.Read(bytes.NewReader(raw))
10191020 if err != nil && !message.IsUnknownCharset(err) {
10201020- return string(raw), "", "", nil, ""
10211021+ return string(raw), "", "", nil, "", SpyPixelInfo{}
10211022 }
1022102310231024 // Check if this is a neomd-authored draft. Drafts use the X-Neomd-Draft header
···11301131 // text/plain part is typically a stripped dump with raw redirect URLs.
11311132 // Fall back to plain text for plain-text-only emails (e.g. direct replies).
11321133 if htmlText != "" {
11331133- return htmlToMarkdown(htmlText), htmlText, webURL, attachments, references
11341134+ md, spy := htmlToMarkdown(htmlText)
11351135+ return md, htmlText, webURL, attachments, references, spy
11341136 }
11351137 if plainText != "" {
11361138 // For neomd drafts, return the raw markdown without normalization.
11371139 // Normalization adds trailing spaces for hard line breaks, which would
11381140 // mutate the draft content on each save/reopen cycle.
11391141 if isDraft {
11401140- return plainText, "", webURL, attachments, references
11421142+ return plainText, "", webURL, attachments, references, SpyPixelInfo{}
11411143 }
11421142- return normalizePlainText(plainText), "", webURL, attachments, references
11441144+ return normalizePlainText(plainText), "", webURL, attachments, references, SpyPixelInfo{}
11431145 }
11441144- return "(no body)", "", webURL, attachments, references
11461146+ return "(no body)", "", webURL, attachments, references, SpyPixelInfo{}
11451147}
1146114811471149// extractPlainTextWebURL looks for a "View … on the web at https://…" line
···11981200// htmlToMarkdown converts an HTML email body to Markdown so glamour can render
11991201// it with proper formatting: bold, italic, links, headings, lists, and image
12001202// placeholders ( → [Image: alt] in the terminal).
12011201-func htmlToMarkdown(h string) string {
12031203+func htmlToMarkdown(h string) (string, SpyPixelInfo) {
12021204 // Remove <wbr> tags and join newlines inside href/src attribute values.
12031205 // Newsletter services (Substack, Mailchimp) insert line breaks inside URLs
12041206 // for HTML rendering; html-to-markdown preserves them, breaking link syntax.
···12171219 converter := htmlmd.NewConverter("", true, nil)
12181220 result, err := converter.ConvertString(h)
12191221 if err != nil {
12201220- return stripHTMLFallback(h)
12221222+ return stripHTMLFallback(h), SpyPixelInfo{}
12211223 }
12221224 return cleanMarkdown(strings.TrimSpace(result))
12231225}
1224122612271227+// SpyPixelInfo holds the results of tracking pixel detection.
12281228+type SpyPixelInfo struct {
12291229+ Count int // number of tracking pixels stripped
12301230+ Domains []string // unique tracker domains extracted from pixel URLs
12311231+}
12321232+12331233+// reEmptyImg matches empty markdown image tags produced from tracking pixels.
12341234+var reEmptyImg = regexp.MustCompile(`!\[\s*\]\(([^)]*)\)`)
12351235+12251236// cleanMarkdown post-processes html-to-markdown output to remove newsletter
12261237// noise: invisible Unicode spacers, tracking pixels, bare URL lines, and
12271227-// excessive blank lines.
12281228-func cleanMarkdown(s string) string {
12381238+// excessive blank lines. Returns the cleaned string and spy pixel info.
12391239+func cleanMarkdown(s string) (string, SpyPixelInfo) {
12291240 // 1. Strip invisible Unicode characters used as email preheader spacers:
12301241 // U+034F COMBINING GRAPHEME JOINER, U+00AD SOFT HYPHEN,
12311242 // U+200B ZERO WIDTH SPACE, U+200C/D ZWNJ/ZWJ, U+FEFF BOM
12321243 reInvis := regexp.MustCompile(`[\x{034F}\x{00AD}\x{200B}\x{200C}\x{200D}\x{FEFF}]+`)
12331244 s = reInvis.ReplaceAllString(s, "")
1234124512351235- // 2. Remove empty image tags (tracking pixels):  or 
12361236- reEmptyImg := regexp.MustCompile(`!\[\s*\]\([^)]*\)`)
12371237- s = reEmptyImg.ReplaceAllString(s, "")
12461246+ // 2. Detect and remove empty image tags (tracking pixels):  or 
12471247+ var spy SpyPixelInfo
12481248+ matches := reEmptyImg.FindAllStringSubmatch(s, -1)
12491249+ spy.Count = len(matches)
12501250+ if spy.Count > 0 {
12511251+ seen := make(map[string]bool)
12521252+ for _, m := range matches {
12531253+ if d := extractDomain(m[1]); d != "" && !seen[d] {
12541254+ seen[d] = true
12551255+ spy.Domains = append(spy.Domains, d)
12561256+ }
12571257+ }
12581258+ s = reEmptyImg.ReplaceAllString(s, "")
12591259+ }
1238126012391261 // 3. Remove empty link anchors left behind when image-only links are cleaned:
12401262 // [](url) or [ ](url)
···12561278 reExcessBlank := regexp.MustCompile(`\n{4,}`)
12571279 s = reExcessBlank.ReplaceAllString(s, "\n\n\n")
1258128012591259- return strings.TrimSpace(s)
12811281+ return strings.TrimSpace(s), spy
12821282+}
12831283+12841284+// extractDomain pulls the hostname from a URL string, returning "" on failure.
12851285+func extractDomain(rawURL string) string {
12861286+ rawURL = strings.TrimSpace(rawURL)
12871287+ if !strings.HasPrefix(rawURL, "http") {
12881288+ return ""
12891289+ }
12901290+ // Simple extraction: skip past "://" and take until next "/" or end.
12911291+ after := rawURL
12921292+ if i := strings.Index(rawURL, "://"); i >= 0 {
12931293+ after = rawURL[i+3:]
12941294+ }
12951295+ if i := strings.IndexByte(after, '/'); i >= 0 {
12961296+ after = after[:i]
12971297+ }
12981298+ // Strip port if present.
12991299+ if i := strings.LastIndexByte(after, ':'); i >= 0 {
13001300+ after = after[:i]
13011301+ }
13021302+ return after
12601303}
1261130412621305// normalizePlainText prepares a plain-text email body for glamour rendering.
+50-6
internal/imap/client_test.go
···228228 "iVBORw0KGgo=\r\n" +
229229 "--" + boundary + "--\r\n"
230230231231- _, _, _, attachments, _ := parseBody([]byte(raw))
231231+ _, _, _, attachments, _, _ := parseBody([]byte(raw))
232232233233 if len(attachments) == 0 {
234234 t.Fatal("expected at least 1 attachment, got 0")
···273273 "JVBERi0=\r\n" +
274274 "--" + boundary + "--\r\n"
275275276276- _, _, _, attachments, _ := parseBody([]byte(raw))
276276+ _, _, _, attachments, _, _ := parseBody([]byte(raw))
277277278278 if len(attachments) == 0 {
279279 t.Fatal("expected at least 1 attachment, got 0")
···334334 originalBody
335335336336 // First parse (simulating draft reopen)
337337- body1, _, _, _, _ := parseBody([]byte(draftMIME))
337337+ body1, _, _, _, _, _ := parseBody([]byte(draftMIME))
338338339339 // Verify the body matches exactly (no trailing spaces added)
340340 if body1 != originalBody {
···351351 "\r\n" +
352352 body1 // Use the result from first parse
353353354354- body2, _, _, _, _ := parseBody([]byte(draftMIME2))
354354+ body2, _, _, _, _, _ := parseBody([]byte(draftMIME2))
355355356356 // Verify still matches exactly (no accumulation of trailing spaces)
357357 if body2 != originalBody {
···378378 "\r\n" +
379379 originalBody
380380381381- body, _, _, _, _ := parseBody([]byte(regularMIME))
381381+ body, _, _, _, _, _ := parseBody([]byte(regularMIME))
382382383383 // Normalization should add two trailing spaces before the newline
384384 expectedNormalized := "Line 1 \nLine 2"
···399399 "\r\n" +
400400 "Test body"
401401402402- _, _, _, _, references := parseBody([]byte(raw))
402402+ _, _, _, _, references, _ := parseBody([]byte(raw))
403403404404 wantReferences := "<msg1@example.com> <msg2@example.com>"
405405 if references != wantReferences {
406406 t.Errorf("References = %q, want %q", references, wantReferences)
407407+ }
408408+}
409409+410410+func TestSpyPixelDetection(t *testing.T) {
411411+ // HTML email with 2 tracking pixels from different domains.
412412+ raw := "MIME-Version: 1.0\r\n" +
413413+ "Content-Type: text/html; charset=utf-8\r\n" +
414414+ "\r\n" +
415415+ `<html><body>` +
416416+ `<p>Hello world</p>` +
417417+ `<img src="https://open.mailchimp.com/track/abc123" alt="" width="1" height="1">` +
418418+ `<img src="https://pixel.sendinblue.com/log/open?id=xyz" alt="">` +
419419+ `<img src="cid:logo" alt="Company Logo">` +
420420+ `</body></html>`
421421+422422+ _, _, _, _, _, spy := parseBody([]byte(raw))
423423+424424+ if spy.Count < 2 {
425425+ t.Errorf("SpyPixelInfo.Count = %d, want >= 2", spy.Count)
426426+ }
427427+ // Check that domains were extracted
428428+ found := make(map[string]bool)
429429+ for _, d := range spy.Domains {
430430+ found[d] = true
431431+ }
432432+ if !found["open.mailchimp.com"] {
433433+ t.Errorf("expected domain open.mailchimp.com in spy.Domains, got %v", spy.Domains)
434434+ }
435435+ if !found["pixel.sendinblue.com"] {
436436+ t.Errorf("expected domain pixel.sendinblue.com in spy.Domains, got %v", spy.Domains)
437437+ }
438438+}
439439+440440+func TestSpyPixelPlainTextEmail(t *testing.T) {
441441+ // Plain-text emails should never report spy pixels.
442442+ raw := "MIME-Version: 1.0\r\n" +
443443+ "Content-Type: text/plain; charset=utf-8\r\n" +
444444+ "\r\n" +
445445+ "Just a normal text email."
446446+447447+ _, _, _, _, _, spy := parseBody([]byte(raw))
448448+449449+ if spy.Count != 0 {
450450+ t.Errorf("plain-text email SpyPixelInfo.Count = %d, want 0", spy.Count)
407451 }
408452}
409453