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.

update spyfixel view, color for attachments, block of suspicious attchments (magic go)

+144 -35
+7
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 3 # 2026-04-27 4 + - **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 5 + - **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 6 + - **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 7 + - **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 8 + - **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 9 + - **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 10 + - **Colored attachments in reader** — attachment filenames in the reader header are now rendered in waveAqua2 color instead of dim gray for better visibility 4 11 - **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 5 12 6 13 # 2026-04-24
+2 -2
README.md
··· 146 146 - **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) 147 147 - **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) 148 148 - **Glamour reading** — incoming emails rendered as styled Markdown in the terminal [more](https://ssp-data.github.io/neomd/docs/reading/) 149 - - **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/) 149 + - **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/) 150 150 - **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut [more](https://ssp-data.github.io/neomd/docs/keybindings/#folders) 151 151 - **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) 152 - - **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) 152 + - **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) 153 153 - **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) 154 154 - **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) 155 155 - **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
··· 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 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 - - The reader header shows `⊙ N spy pixel(s) blocked (domain.com, ...)` with the tracker domains. 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 + - The reader header shows `° N spy pixel(s) blocked (domain.com, ...)` with the tracker domains. 91 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. 92 92 93 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. 94 94 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 + **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()` 96 96 97 97 --- 98 98 99 99 ## Attachment safety 100 100 101 - 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. 101 + Attachments are saved to `~/Downloads/` and opened with `xdg-open`. Two layers of protection prevent accidental execution of malicious files: 102 102 103 - **Code:** [`internal/ui/model.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/model.go) — `dangerousExts`, `downloadOpenAttachmentCmd()` 103 + 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. 104 + 105 + 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. 106 + 107 + | Scenario | Extension | Magic bytes | Result | 108 + |---|---|---|---| 109 + | `malware.sh` | `.sh` → blocked | — | Saved, not opened | 110 + | `malware.sh` → `photo.png` | `.png` → safe | `text/plain` ≠ `image/` | Saved, not opened | 111 + | Real `photo.png` | `.png` → safe | `image/png` ✓ | Opened normally | 112 + 113 + **Code:** [`internal/ui/model.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/model.go) — `dangerousExts`, `isMimeMismatch()`, `downloadOpenAttachmentCmd()` 114 + 115 + --- 116 + 117 + ## Screener as a security layer 118 + 119 + 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. 120 + 121 + 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. 122 + 123 + 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. 124 + 125 + **Code:** [`internal/screener/screener.go`](https://github.com/ssp-data/neomd/blob/main/internal/screener/screener.go) 104 126 105 127 --- 106 128
+2 -2
docs/content/docs/_index.md
··· 149 149 - **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) 150 150 - **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) 151 151 - **Glamour reading** — incoming emails rendered as styled Markdown in the terminal [more](https://ssp-data.github.io/neomd/docs/reading/) 152 - - **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/) 152 + - **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/) 153 153 - **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut [more](https://ssp-data.github.io/neomd/docs/keybindings/#folders) 154 154 - **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) 155 - - **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) 155 + - **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) 156 156 - **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) 157 157 - **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) 158 158 - **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
··· 39 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 40 41 41 When tracking pixels are detected, neomd shows: 42 - - `⊙` indicator in the inbox list (orange, next to the attachment `@` column) 43 - - `⊙ N spy pixel(s) blocked (domain.com)` in the reader header with tracker domains 42 + - `°` indicator in the inbox list (orange, next to the attachment `@` column) 43 + - `° N spy pixel(s) blocked (domain.com)` in the reader header with tracker domains 44 44 45 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. 46 46 ··· 104 104 - `·╰ ` reply indicator within a thread 105 105 - `│` connects thread members (newest on top) 106 106 - `╰` marks the root/oldest email at the bottom of each thread 107 - - `⊙` spy pixel indicator — tracking pixels were detected and blocked (shown after first read) 107 + - `°` spy pixel indicator — tracking pixels were detected and blocked (shown after first read) 108 108 - Non-threaded emails show no connector (clean, no visual noise) 109 109 - Threads are sorted by their most recent email, so active conversations float to the top 110 110
+8
docs/content/docs/screener.md
··· 54 54 55 55 `: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. 56 56 57 + ## Screener as phishing defense 58 + 59 + 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. 60 + 61 + 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. 62 + 63 + **Practical rule:** treat ToScreen as a quarantine. Verify the sender address before approving. Press `$` to mark spam. 64 + 57 65 ## Screening happens once 58 66 59 67 Emails 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.
+4 -1
docs/hugo.yaml
··· 60 60 - name: Changelog 61 61 url: "https://github.com/ssp-data/neomd/blob/main/CHANGELOG.md" 62 62 weight: 101 63 + - name: Security 64 + url: "https://github.com/ssp-data/neomd/blob/main/SECURITY.md" 65 + weight: 110 63 66 - name: Roadmap 64 67 url: "https://www.ssp.sh/brain/neomd#roadmap" 65 - weight: 102 68 + weight: 120 66 69 67 70 params: 68 71 description: "A fast, keyboard-first TUI email client with smart screening"
+40 -3
internal/imap/client.go
··· 1344 1344 spy.Count++ 1345 1345 src := reSpyPixel.FindStringSubmatch(tag) 1346 1346 if len(src) >= 2 { 1347 - if d := extractDomain(src[1]); d != "" && !seen[d] { 1348 - seen[d] = true 1349 - spy.Domains = append(spy.Domains, d) 1347 + if label := domainPathLabel(src[1]); label != "" && !seen[label] { 1348 + seen[label] = true 1349 + spy.Domains = append(spy.Domains, label) 1350 1350 } 1351 1351 } 1352 1352 } ··· 1412 1412 after = after[:i] 1413 1413 } 1414 1414 return after 1415 + } 1416 + 1417 + // domainPathLabel returns "domain/../lastSegment" for a URL, e.g. 1418 + // "https://Sync.us7.list-manage.com/track/open.php?u=..." → "Sync.us7.list-manage.com/../open.php" 1419 + func domainPathLabel(rawURL string) string { 1420 + domain := extractDomain(rawURL) 1421 + if domain == "" { 1422 + return "" 1423 + } 1424 + // Extract path: everything after domain, before query. 1425 + after := rawURL 1426 + if i := strings.Index(rawURL, "://"); i >= 0 { 1427 + after = rawURL[i+3:] 1428 + } 1429 + path := "" 1430 + if i := strings.IndexByte(after, '/'); i >= 0 { 1431 + path = after[i:] 1432 + } 1433 + if i := strings.IndexByte(path, '?'); i >= 0 { 1434 + path = path[:i] 1435 + } 1436 + // Get last meaningful path segment. 1437 + path = strings.TrimRight(path, "/") 1438 + if path == "" { 1439 + return domain 1440 + } 1441 + last := path 1442 + if i := strings.LastIndexByte(path, '/'); i >= 0 { 1443 + last = path[i+1:] 1444 + } 1445 + if last == "" { 1446 + return domain 1447 + } 1448 + if len(last) > 10 { 1449 + last = "…" + last[len(last)-10:] 1450 + } 1451 + return domain + "/../" + last 1415 1452 } 1416 1453 1417 1454 // normalizePlainText prepares a plain-text email body for glamour rendering.
+8 -6
internal/imap/client_test.go
··· 434 434 for _, d := range spy.Domains { 435 435 found[d] = true 436 436 } 437 - if !found["click.mailchimp.com"] { 438 - t.Errorf("expected domain click.mailchimp.com in spy.Domains, got %v", spy.Domains) 437 + if !found["click.mailchimp.com/../open.php"] { 438 + t.Errorf("expected click.mailchimp.com/../open.php in spy.Domains, got %v", spy.Domains) 439 439 } 440 - if !found["pixel.sendinblue.com"] { 441 - t.Errorf("expected domain pixel.sendinblue.com in spy.Domains, got %v", spy.Domains) 440 + if !found["pixel.sendinblue.com/../open"] { 441 + t.Errorf("expected pixel.sendinblue.com/../open in spy.Domains, got %v", spy.Domains) 442 442 } 443 - if found["cdn.example.com"] { 444 - t.Errorf("decorative image cdn.example.com should NOT be counted as spy pixel, got %v", spy.Domains) 443 + for _, d := range spy.Domains { 444 + if strings.Contains(d, "cdn.example.com") { 445 + t.Errorf("decorative image should NOT be counted, got %v", spy.Domains) 446 + } 445 447 } 446 448 } 447 449
+2 -2
internal/ui/inbox.go
··· 47 47 colThreadWidth = 2 // "│ " or "╰ " or " " 48 48 colDateWidth = 7 // "Feb 03 " 49 49 colAttachWidth = 2 // "@ " or " " 50 - colSpyWidth = 2 // "⊙ " or " " — spy pixel indicator 50 + colSpyWidth = 2 // "°" or " " — spy pixel indicator 51 51 colSizeWidth = 7 // "(38.2K)" 52 52 ) 53 53 ··· 94 94 } 95 95 spyStr := " " 96 96 if e.hasSpyPixel { 97 - spyStr = "⊙ " 97 + spyStr = "° " 98 98 } 99 99 sizeStr := fmtSize(e.email.Size) 100 100
+37 -8
internal/ui/model.go
··· 3 3 4 4 import ( 5 5 "fmt" 6 + "net/http" 6 7 "os" 7 8 "os/exec" 8 9 "path/filepath" ··· 3274 3275 return m, m.downloadOpenAttachmentCmd(m.openAttachments[idx]) 3275 3276 } 3276 3277 case " ": 3277 - if len(m.openLinks) > 0 { 3278 - m.readerPending = " " 3279 - if len(m.openLinks) > 10 { 3280 - m.status = "open link: 1-0 (links 1-10), l11-99 (links 11+)" 3281 - } else { 3282 - m.status = "open link: 1-0" 3283 - } 3284 - return m, nil 3278 + m.readerPending = " " 3279 + var hints []string 3280 + if len(m.openLinks) > 10 { 3281 + hints = append(hints, "1-0 links", "l11-99 links 11+") 3282 + } else if len(m.openLinks) > 0 { 3283 + hints = append(hints, "1-0 links") 3285 3284 } 3285 + hints = append(hints, "d download .eml") 3286 + m.status = "space: " + strings.Join(hints, " · ") 3287 + return m, nil 3286 3288 case "g": 3287 3289 m.readerPending = "g" 3288 3290 return m, nil ··· 3460 3462 } 3461 3463 } 3462 3464 3465 + // expectedMimePrefix maps common file extensions to expected MIME type prefixes. 3466 + // If magic-byte detection returns something outside the expected prefix, the file is suspicious. 3467 + var expectedMimePrefix = map[string]string{ 3468 + ".png": "image/", ".jpg": "image/", ".jpeg": "image/", ".gif": "image/", 3469 + ".webp": "image/", ".svg": "image/", ".bmp": "image/", ".ico": "image/", 3470 + ".pdf": "application/pdf", 3471 + ".zip": "application/zip", ".gz": "application/", 3472 + ".doc": "application/", ".docx": "application/", ".xls": "application/", ".xlsx": "application/", 3473 + ".mp3": "audio/", ".wav": "audio/", ".ogg": "audio/", 3474 + ".mp4": "video/", ".webm": "video/", ".avi": "video/", 3475 + } 3476 + 3477 + // isMimeMismatch returns true if the file extension claims to be a safe type 3478 + // but magic-byte detection says otherwise (e.g. a script disguised as .png). 3479 + func isMimeMismatch(ext, detected string) bool { 3480 + expected, ok := expectedMimePrefix[ext] 3481 + if !ok { 3482 + return false // unknown extension — can't validate, let it through 3483 + } 3484 + return !strings.HasPrefix(detected, expected) 3485 + } 3486 + 3463 3487 // dangerousExts lists file extensions that should not be auto-opened with xdg-open 3464 3488 // because they could execute arbitrary code. 3465 3489 var dangerousExts = map[string]bool{ ··· 3501 3525 } 3502 3526 ext := strings.ToLower(filepath.Ext(base)) 3503 3527 if dangerousExts[ext] { 3528 + return attachOpenDoneMsg{path: dst, dangerous: true} 3529 + } 3530 + // Magic-byte check: detect actual content type from file bytes. 3531 + // Flags mismatches like a .sh disguised as .png (detected as text/plain, not image/png). 3532 + if detected := http.DetectContentType(a.Data); isMimeMismatch(ext, detected) { 3504 3533 return attachOpenDoneMsg{path: dst, dangerous: true} 3505 3534 } 3506 3535 _ = exec.Command("xdg-open", dst).Start()
+4 -3
internal/ui/reader.go
··· 119 119 styleDate.Render("Date: ")+fmtDateFull(e.Date), 120 120 ) 121 121 122 + attachStyle := lipgloss.NewStyle().Foreground(colorSubjectRead) // waveAqua2 — visible but not dominant 122 123 if len(attachments) > 0 { 123 124 var parts []string 124 125 for i, a := range attachments { 125 - parts = append(parts, fmt.Sprintf("[%d] %s", i+1, a.Filename)) 126 + parts = append(parts, attachStyle.Render(fmt.Sprintf("[%d] %s", i+1, a.Filename))) 126 127 } 127 - lines = append(lines, styleHelp.Render("Attach: ")+strings.Join(parts, " ")) 128 + lines = append(lines, styleDate.Render("Attach: ")+strings.Join(parts, " ")) 128 129 } 129 130 130 131 if spyPixels.Count > 0 { ··· 133 134 if len(spyPixels.Domains) > 0 { 134 135 label += " (" + strings.Join(spyPixels.Domains, ", ") + ")" 135 136 } 136 - lines = append(lines, spyStyle.Render("⊙ "+label)) 137 + lines = append(lines, spyStyle.Render("° "+label)) 137 138 } 138 139 139 140 content := strings.Join(lines, "\n")