···11# Changelog
2233# 2026-04-27
44+- **Spy pixel blocking** — neomd automatically detects and blocks tracking pixels in emails; detection runs on raw HTML before markdown conversion using heuristics (1x1 dimensions, CSS hiding, known tracker URL patterns like `/track/open`, `/pixel`, `/beacon`); `°` indicator in the inbox list (orange) for emails with tracking pixels; reader header shows `° N spy pixel(s) blocked (domain/../path)` with tracker domains and path hints; senders cannot tell if you read their email in the TUI since glamour never fetches remote resources
55+- **Spy pixel scan (`<space>S` / `:scan-spy-pixels`)** — scan all emails in the current folder for tracking pixels in the background; fetches full UID list from IMAP server, skips already-scanned emails, uses IMAP PEEK (won't mark as read); results cached in `~/.cache/neomd/spy_pixels` and persist across restarts; both positive and negative scan results are cached so repeat scans are instant
66+- **URL scheme whitelist** — email links opened via `space+digit` are now validated; only `http://`, `https://`, and `mailto:` schemes are allowed; `javascript:`, `data:`, and other dangerous schemes are blocked with an error in the status bar
77+- **Dangerous attachment warning** — two layers of protection: (1) files with executable extensions (`.sh`, `.exe`, `.desktop`, `.bat`, `.py`, `.jar`, etc.) are saved but not auto-opened; (2) magic-byte verification using Go's `net/http.DetectContentType()` catches disguised files (e.g. a script renamed to `.png` is detected as `text/plain` and blocked); status bar warns about dangerous or suspicious file types
88+- **Browser view sanitization** — pressing `O` to open email in browser now injects a Content-Security-Policy that blocks JavaScript, iframes, and embedded objects (`script-src 'none'; frame-src 'none'; object-src 'none'`) while allowing remote images
99+- **Reader space chord hints** — pressing `space` in the reader now shows all available actions (`1-0 links`, `d download .eml`, `l11-99 links 11+`) instead of only link info; `space+d` for EML download now works even when no links are present
1010+- **Colored attachments in reader** — attachment filenames in the reader header are now rendered in waveAqua2 color instead of dim gray for better visibility
411- **Mailto handler (`--mailto` / positional URI)** — neomd can now be used as the system default `mailto:` handler; clicking a `mailto:` link in any browser opens a foot terminal with neomd in compose mode, pre-filled with To, CC, BCC, Subject, and Body from the URI; supports both `neomd --mailto "mailto:user@example.com?subject=Hello"` and `neomd "mailto:..."` (positional, for `.desktop` integration); registered via `xdg-mime` with a `neomd-mailto.desktop` file; after sending or cancelling, neomd continues as normal
512613# 2026-04-24
+2-2
README.md
···146146- **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)
147147- **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)
148148- **Glamour reading** — incoming emails rendered as styled Markdown in the terminal [more](https://ssp-data.github.io/neomd/docs/reading/)
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/)
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; also acts as a phishing defense — impersonation emails from senders you've already approved land in ToScreen instead of Inbox, making them immediately suspicious [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)
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)
153153- **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)
154154- **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)
155155- **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-5
SECURITY.md
···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- `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.
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.
92929393**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.
94949595-**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()`
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()`
96969797---
98989999## Attachment safety
100100101101-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.
101101+Attachments are saved to `~/Downloads/` and opened with `xdg-open`. Two layers of protection prevent accidental execution of malicious files:
102102103103-**Code:** [`internal/ui/model.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/model.go) — `dangerousExts`, `downloadOpenAttachmentCmd()`
103103+1. **Extension blocklist** — files with dangerous extensions (`.sh`, `.exe`, `.desktop`, `.bat`, `.py`, `.jar`, etc.) are saved but **not auto-opened**. The status bar warns about the dangerous file type.
104104+105105+2. **Magic-byte verification** — before opening, neomd inspects the actual file content using Go's `net/http.DetectContentType()` (WHATWG MIME sniffing, first 512 bytes) and compares it against what the file extension claims. If there's a mismatch — e.g. a shell script disguised as `photo.png` (detected as `text/plain`, expected `image/`) — the file is saved but **not auto-opened**. This catches attackers who rename executable files to look like images, PDFs, or other safe types.
106106+107107+| Scenario | Extension | Magic bytes | Result |
108108+|---|---|---|---|
109109+| `malware.sh` | `.sh` → blocked | — | Saved, not opened |
110110+| `malware.sh` → `photo.png` | `.png` → safe | `text/plain` ≠ `image/` | Saved, not opened |
111111+| Real `photo.png` | `.png` → safe | `image/png` ✓ | Opened normally |
112112+113113+**Code:** [`internal/ui/model.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/model.go) — `dangerousExts`, `isMimeMismatch()`, `downloadOpenAttachmentCmd()`
114114+115115+---
116116+117117+## Screener as a security layer
118118+119119+The [HEY-style screener](https://ssp-data.github.io/neomd/docs/screener/) is primarily a productivity workflow, but it doubles as a phishing defense. Unknown senders never reach your Inbox — they land in `ToScreen` first, where you decide whether to approve them.
120120+121121+This matters because **an email in ToScreen from a sender you already screened in is immediately suspicious**. If you've approved `info@sbb.ch` (Swiss train service), but a new email from `info@sbb-tickets.fake.com` arrives in ToScreen, you know it's an impersonation attempt before you even open it. Without the screener, that phishing email would sit alongside legitimate SBB emails in your Inbox with no visual distinction.
122122+123123+In practice: everything in your Inbox is from senders you've explicitly trusted. ToScreen is your quarantine — treat it with suspicion by default, verify the sender address, and press `$` to mark spam.
124124+125125+**Code:** [`internal/screener/screener.go`](https://github.com/ssp-data/neomd/blob/main/internal/screener/screener.go)
104126105127---
106128
+2-2
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-- **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/)
152152+- **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; also acts as a phishing defense — impersonation emails from senders you've already approved land in ToScreen instead of Inbox, making them immediately suspicious [more](https://ssp-data.github.io/neomd/docs/screener/)
153153- **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut [more](https://ssp-data.github.io/neomd/docs/keybindings/#folders)
154154- **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)
155155-- **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)
155155+- **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)
156156- **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)
157157- **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)
158158- **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)
+3-3
docs/content/docs/reading.md
···3939**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.
40404141When tracking pixels are detected, neomd shows:
4242-- `⊙` indicator in the inbox list (orange, next to the attachment `@` column)
4343-- `⊙ N spy pixel(s) blocked (domain.com)` in the reader header with tracker domains
4242+- `°` indicator in the inbox list (orange, next to the attachment `@` column)
4343+- `° N spy pixel(s) blocked (domain.com)` in the reader header with tracker domains
44444545**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.
4646···104104- `·╰ ` reply indicator within a thread
105105- `│` connects thread members (newest on top)
106106- `╰` marks the root/oldest email at the bottom of each thread
107107-- `⊙` spy pixel indicator — tracking pixels were detected and blocked (shown after first read)
107107+- `°` spy pixel indicator — tracking pixels were detected and blocked (shown after first read)
108108- Non-threaded emails show no connector (clean, no visual noise)
109109- Threads are sorted by their most recent email, so active conversations float to the top
110110
+8
docs/content/docs/screener.md
···54545555`:screen-all` (alias `:sa`) scans every email in your Inbox — read and unread — and proposes moves for any sender that is now in a list. It does **not** touch emails already in Feed, PaperTrail, or other folders.
56565757+## Screener as phishing defense
5858+5959+The screener isn't just a productivity tool — it's a natural phishing filter. Since unknown senders always land in ToScreen, your Inbox only contains emails from senders you've explicitly approved.
6060+6161+This makes impersonation attempts easy to spot: if you've already screened in `info@sbb.ch` (Swiss train service), a phishing email from `info@sbb-tickets.fake.com` will land in ToScreen, not your Inbox. You'll immediately notice it's suspicious because the real sender is already approved. Without the screener, the fake would sit alongside legitimate emails with no visual distinction.
6262+6363+**Practical rule:** treat ToScreen as a quarantine. Verify the sender address before approving. Press `$` to mark spam.
6464+5765## Screening happens once
58665967Emails are only auto-screened while they are in the **Inbox**. Once moved to ToScreen (or any other folder), they are not re-classified automatically. This keeps the logic simple and predictable.
···13441344 spy.Count++
13451345 src := reSpyPixel.FindStringSubmatch(tag)
13461346 if len(src) >= 2 {
13471347- if d := extractDomain(src[1]); d != "" && !seen[d] {
13481348- seen[d] = true
13491349- spy.Domains = append(spy.Domains, d)
13471347+ if label := domainPathLabel(src[1]); label != "" && !seen[label] {
13481348+ seen[label] = true
13491349+ spy.Domains = append(spy.Domains, label)
13501350 }
13511351 }
13521352 }
···14121412 after = after[:i]
14131413 }
14141414 return after
14151415+}
14161416+14171417+// domainPathLabel returns "domain/../lastSegment" for a URL, e.g.
14181418+// "https://Sync.us7.list-manage.com/track/open.php?u=..." → "Sync.us7.list-manage.com/../open.php"
14191419+func domainPathLabel(rawURL string) string {
14201420+ domain := extractDomain(rawURL)
14211421+ if domain == "" {
14221422+ return ""
14231423+ }
14241424+ // Extract path: everything after domain, before query.
14251425+ after := rawURL
14261426+ if i := strings.Index(rawURL, "://"); i >= 0 {
14271427+ after = rawURL[i+3:]
14281428+ }
14291429+ path := ""
14301430+ if i := strings.IndexByte(after, '/'); i >= 0 {
14311431+ path = after[i:]
14321432+ }
14331433+ if i := strings.IndexByte(path, '?'); i >= 0 {
14341434+ path = path[:i]
14351435+ }
14361436+ // Get last meaningful path segment.
14371437+ path = strings.TrimRight(path, "/")
14381438+ if path == "" {
14391439+ return domain
14401440+ }
14411441+ last := path
14421442+ if i := strings.LastIndexByte(path, '/'); i >= 0 {
14431443+ last = path[i+1:]
14441444+ }
14451445+ if last == "" {
14461446+ return domain
14471447+ }
14481448+ if len(last) > 10 {
14491449+ last = "…" + last[len(last)-10:]
14501450+ }
14511451+ return domain + "/../" + last
14151452}
1416145314171454// normalizePlainText prepares a plain-text email body for glamour rendering.
+8-6
internal/imap/client_test.go
···434434 for _, d := range spy.Domains {
435435 found[d] = true
436436 }
437437- if !found["click.mailchimp.com"] {
438438- t.Errorf("expected domain click.mailchimp.com in spy.Domains, got %v", spy.Domains)
437437+ if !found["click.mailchimp.com/../open.php"] {
438438+ t.Errorf("expected click.mailchimp.com/../open.php in spy.Domains, got %v", spy.Domains)
439439 }
440440- if !found["pixel.sendinblue.com"] {
441441- t.Errorf("expected domain pixel.sendinblue.com in spy.Domains, got %v", spy.Domains)
440440+ if !found["pixel.sendinblue.com/../open"] {
441441+ t.Errorf("expected pixel.sendinblue.com/../open in spy.Domains, got %v", spy.Domains)
442442 }
443443- if found["cdn.example.com"] {
444444- t.Errorf("decorative image cdn.example.com should NOT be counted as spy pixel, got %v", spy.Domains)
443443+ for _, d := range spy.Domains {
444444+ if strings.Contains(d, "cdn.example.com") {
445445+ t.Errorf("decorative image should NOT be counted, got %v", spy.Domains)
446446+ }
445447 }
446448}
447449