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.

added spy pixel to email once opened, and increase security with not open directly and update content-security-policy

+263 -52
+1
README.md
··· 149 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/) 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 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) 153 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) 154 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 -2
SECURITY.md
··· 73 73 74 74 ## URL handling 75 75 76 - 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. 76 + 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. 77 + 78 + **Code:** [`internal/ui/model.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/model.go) — `openLinkCmd()`, `openWebVersion()` 79 + 80 + --- 81 + 82 + ## Spy pixel blocking 83 + 84 + neomd automatically detects and blocks tracking pixels (1x1 invisible images embedded by newsletter services like Mailchimp, HubSpot, and SendGrid to track email opens). 85 + 86 + **How it works:** 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 + - `cleanMarkdown()` detects empty-alt-text images (the signature of tracking pixels), counts them, extracts the tracker domains, and strips them from the rendered output. 89 + - The inbox list shows a `⊙` indicator (orange) for emails that contained tracking pixels, visible after first read. 90 + - The reader header shows `⊙ N spy pixel(s) blocked (domain.com, ...)` with the tracker domains. 91 + 92 + **Browser view (`O`) protection:** 93 + - 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. 94 + 95 + **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()` 77 96 78 - **Code:** [`internal/ui/model.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/model.go) — `openWebVersion()` 97 + --- 98 + 99 + ## Attachment safety 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. 102 + 103 + **Code:** [`internal/ui/model.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/model.go) — `dangerousExts`, `downloadOpenAttachmentCmd()` 79 104 80 105 --- 81 106
+1
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 + - **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 153 - **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/) 153 154 - **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut [more](https://ssp-data.github.io/neomd/docs/keybindings/#folders) 154 155 - **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
··· 32 32 33 33 When 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). 34 34 35 + ## Spy Pixel Blocking 36 + 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 + 39 + When tracking pixels are detected, neomd shows: 40 + - `⊙` indicator in the inbox list (orange, next to the attachment `@` column) 41 + - `⊙ N spy pixel(s) blocked (domain.com)` in the reader header with tracker domains 42 + 43 + 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. 44 + 45 + ### This is how it looks: 46 + 47 + In overview: 48 + ![spy](/images/spy-pixel.png) 49 + 50 + And within an email open: 51 + 52 + ![spy](/images/spy-pixel-mail.png) 53 + 35 54 ## Links 36 55 37 56 Links 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. ··· 81 100 - `·╰ ` reply indicator within a thread 82 101 - `│` connects thread members (newest on top) 83 102 - `╰` marks the root/oldest email at the bottom of each thread 103 + - `⊙` spy pixel indicator — tracking pixels were detected and blocked (shown after first read) 84 104 - Non-threaded emails show no connector (clean, no visual noise) 85 105 - Threads are sorted by their most recent email, so active conversations float to the top 86 106
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
··· 725 725 726 726 // FetchBody fetches the body of a single message. 727 727 // Returns (markdownBody, rawHTML, webURL, attachments, references, error). 728 - func (c *Client) FetchBody(ctx context.Context, folder string, uid uint32) (string, string, string, []Attachment, string, error) { 728 + func (c *Client) FetchBody(ctx context.Context, folder string, uid uint32) (string, string, string, []Attachment, string, SpyPixelInfo, error) { 729 729 if ctx == nil { 730 730 ctx = context.Background() 731 731 } 732 732 var markdown, rawHTML, webURL, references string 733 733 var attachments []Attachment 734 + var spyPixels SpyPixelInfo 734 735 err := c.withConn(ctx, func(conn *imapclient.Client) error { 735 736 if err := c.selectMailbox(folder); err != nil { 736 737 return err ··· 751 752 } 752 753 753 754 if len(msgs[0].BodySection) > 0 { 754 - markdown, rawHTML, webURL, attachments, references = parseBody(msgs[0].BodySection[0].Bytes) 755 + markdown, rawHTML, webURL, attachments, references, spyPixels = parseBody(msgs[0].BodySection[0].Bytes) 755 756 } 756 757 return nil 757 758 }) 758 - return markdown, rawHTML, webURL, attachments, references, err 759 + return markdown, rawHTML, webURL, attachments, references, spyPixels, err 759 760 } 760 761 761 762 // FetchRaw fetches the full raw MIME source (EML) for a single message. ··· 1014 1015 // - rawHTML: original HTML part verbatim (empty for plain-text emails) 1015 1016 // - webURL: "view online" URL extracted from List-Post header or plain-text 1016 1017 // preamble (e.g. Substack's "View this post on the web at https://…") 1017 - func parseBody(raw []byte) (markdown, rawHTML, webURL string, attachments []Attachment, references string) { 1018 + func parseBody(raw []byte) (markdown, rawHTML, webURL string, attachments []Attachment, references string, spyPixels SpyPixelInfo) { 1018 1019 e, err := message.Read(bytes.NewReader(raw)) 1019 1020 if err != nil && !message.IsUnknownCharset(err) { 1020 - return string(raw), "", "", nil, "" 1021 + return string(raw), "", "", nil, "", SpyPixelInfo{} 1021 1022 } 1022 1023 1023 1024 // Check if this is a neomd-authored draft. Drafts use the X-Neomd-Draft header ··· 1130 1131 // text/plain part is typically a stripped dump with raw redirect URLs. 1131 1132 // Fall back to plain text for plain-text-only emails (e.g. direct replies). 1132 1133 if htmlText != "" { 1133 - return htmlToMarkdown(htmlText), htmlText, webURL, attachments, references 1134 + md, spy := htmlToMarkdown(htmlText) 1135 + return md, htmlText, webURL, attachments, references, spy 1134 1136 } 1135 1137 if plainText != "" { 1136 1138 // For neomd drafts, return the raw markdown without normalization. 1137 1139 // Normalization adds trailing spaces for hard line breaks, which would 1138 1140 // mutate the draft content on each save/reopen cycle. 1139 1141 if isDraft { 1140 - return plainText, "", webURL, attachments, references 1142 + return plainText, "", webURL, attachments, references, SpyPixelInfo{} 1141 1143 } 1142 - return normalizePlainText(plainText), "", webURL, attachments, references 1144 + return normalizePlainText(plainText), "", webURL, attachments, references, SpyPixelInfo{} 1143 1145 } 1144 - return "(no body)", "", webURL, attachments, references 1146 + return "(no body)", "", webURL, attachments, references, SpyPixelInfo{} 1145 1147 } 1146 1148 1147 1149 // extractPlainTextWebURL looks for a "View … on the web at https://…" line ··· 1198 1200 // htmlToMarkdown converts an HTML email body to Markdown so glamour can render 1199 1201 // it with proper formatting: bold, italic, links, headings, lists, and image 1200 1202 // placeholders (![alt](url) → [Image: alt] in the terminal). 1201 - func htmlToMarkdown(h string) string { 1203 + func htmlToMarkdown(h string) (string, SpyPixelInfo) { 1202 1204 // Remove <wbr> tags and join newlines inside href/src attribute values. 1203 1205 // Newsletter services (Substack, Mailchimp) insert line breaks inside URLs 1204 1206 // for HTML rendering; html-to-markdown preserves them, breaking link syntax. ··· 1217 1219 converter := htmlmd.NewConverter("", true, nil) 1218 1220 result, err := converter.ConvertString(h) 1219 1221 if err != nil { 1220 - return stripHTMLFallback(h) 1222 + return stripHTMLFallback(h), SpyPixelInfo{} 1221 1223 } 1222 1224 return cleanMarkdown(strings.TrimSpace(result)) 1223 1225 } 1224 1226 1227 + // SpyPixelInfo holds the results of tracking pixel detection. 1228 + type SpyPixelInfo struct { 1229 + Count int // number of tracking pixels stripped 1230 + Domains []string // unique tracker domains extracted from pixel URLs 1231 + } 1232 + 1233 + // reEmptyImg matches empty markdown image tags produced from tracking pixels. 1234 + var reEmptyImg = regexp.MustCompile(`!\[\s*\]\(([^)]*)\)`) 1235 + 1225 1236 // cleanMarkdown post-processes html-to-markdown output to remove newsletter 1226 1237 // noise: invisible Unicode spacers, tracking pixels, bare URL lines, and 1227 - // excessive blank lines. 1228 - func cleanMarkdown(s string) string { 1238 + // excessive blank lines. Returns the cleaned string and spy pixel info. 1239 + func cleanMarkdown(s string) (string, SpyPixelInfo) { 1229 1240 // 1. Strip invisible Unicode characters used as email preheader spacers: 1230 1241 // U+034F COMBINING GRAPHEME JOINER, U+00AD SOFT HYPHEN, 1231 1242 // U+200B ZERO WIDTH SPACE, U+200C/D ZWNJ/ZWJ, U+FEFF BOM 1232 1243 reInvis := regexp.MustCompile(`[\x{034F}\x{00AD}\x{200B}\x{200C}\x{200D}\x{FEFF}]+`) 1233 1244 s = reInvis.ReplaceAllString(s, "") 1234 1245 1235 - // 2. Remove empty image tags (tracking pixels): ![](...) or ![ ](...) 1236 - reEmptyImg := regexp.MustCompile(`!\[\s*\]\([^)]*\)`) 1237 - s = reEmptyImg.ReplaceAllString(s, "") 1246 + // 2. Detect and remove empty image tags (tracking pixels): ![](...) or ![ ](...) 1247 + var spy SpyPixelInfo 1248 + matches := reEmptyImg.FindAllStringSubmatch(s, -1) 1249 + spy.Count = len(matches) 1250 + if spy.Count > 0 { 1251 + seen := make(map[string]bool) 1252 + for _, m := range matches { 1253 + if d := extractDomain(m[1]); d != "" && !seen[d] { 1254 + seen[d] = true 1255 + spy.Domains = append(spy.Domains, d) 1256 + } 1257 + } 1258 + s = reEmptyImg.ReplaceAllString(s, "") 1259 + } 1238 1260 1239 1261 // 3. Remove empty link anchors left behind when image-only links are cleaned: 1240 1262 // [](url) or [ ](url) ··· 1256 1278 reExcessBlank := regexp.MustCompile(`\n{4,}`) 1257 1279 s = reExcessBlank.ReplaceAllString(s, "\n\n\n") 1258 1280 1259 - return strings.TrimSpace(s) 1281 + return strings.TrimSpace(s), spy 1282 + } 1283 + 1284 + // extractDomain pulls the hostname from a URL string, returning "" on failure. 1285 + func extractDomain(rawURL string) string { 1286 + rawURL = strings.TrimSpace(rawURL) 1287 + if !strings.HasPrefix(rawURL, "http") { 1288 + return "" 1289 + } 1290 + // Simple extraction: skip past "://" and take until next "/" or end. 1291 + after := rawURL 1292 + if i := strings.Index(rawURL, "://"); i >= 0 { 1293 + after = rawURL[i+3:] 1294 + } 1295 + if i := strings.IndexByte(after, '/'); i >= 0 { 1296 + after = after[:i] 1297 + } 1298 + // Strip port if present. 1299 + if i := strings.LastIndexByte(after, ':'); i >= 0 { 1300 + after = after[:i] 1301 + } 1302 + return after 1260 1303 } 1261 1304 1262 1305 // normalizePlainText prepares a plain-text email body for glamour rendering.
+50 -6
internal/imap/client_test.go
··· 228 228 "iVBORw0KGgo=\r\n" + 229 229 "--" + boundary + "--\r\n" 230 230 231 - _, _, _, attachments, _ := parseBody([]byte(raw)) 231 + _, _, _, attachments, _, _ := parseBody([]byte(raw)) 232 232 233 233 if len(attachments) == 0 { 234 234 t.Fatal("expected at least 1 attachment, got 0") ··· 273 273 "JVBERi0=\r\n" + 274 274 "--" + boundary + "--\r\n" 275 275 276 - _, _, _, attachments, _ := parseBody([]byte(raw)) 276 + _, _, _, attachments, _, _ := parseBody([]byte(raw)) 277 277 278 278 if len(attachments) == 0 { 279 279 t.Fatal("expected at least 1 attachment, got 0") ··· 334 334 originalBody 335 335 336 336 // First parse (simulating draft reopen) 337 - body1, _, _, _, _ := parseBody([]byte(draftMIME)) 337 + body1, _, _, _, _, _ := parseBody([]byte(draftMIME)) 338 338 339 339 // Verify the body matches exactly (no trailing spaces added) 340 340 if body1 != originalBody { ··· 351 351 "\r\n" + 352 352 body1 // Use the result from first parse 353 353 354 - body2, _, _, _, _ := parseBody([]byte(draftMIME2)) 354 + body2, _, _, _, _, _ := parseBody([]byte(draftMIME2)) 355 355 356 356 // Verify still matches exactly (no accumulation of trailing spaces) 357 357 if body2 != originalBody { ··· 378 378 "\r\n" + 379 379 originalBody 380 380 381 - body, _, _, _, _ := parseBody([]byte(regularMIME)) 381 + body, _, _, _, _, _ := parseBody([]byte(regularMIME)) 382 382 383 383 // Normalization should add two trailing spaces before the newline 384 384 expectedNormalized := "Line 1 \nLine 2" ··· 399 399 "\r\n" + 400 400 "Test body" 401 401 402 - _, _, _, _, references := parseBody([]byte(raw)) 402 + _, _, _, _, references, _ := parseBody([]byte(raw)) 403 403 404 404 wantReferences := "<msg1@example.com> <msg2@example.com>" 405 405 if references != wantReferences { 406 406 t.Errorf("References = %q, want %q", references, wantReferences) 407 + } 408 + } 409 + 410 + func TestSpyPixelDetection(t *testing.T) { 411 + // HTML email with 2 tracking pixels from different domains. 412 + raw := "MIME-Version: 1.0\r\n" + 413 + "Content-Type: text/html; charset=utf-8\r\n" + 414 + "\r\n" + 415 + `<html><body>` + 416 + `<p>Hello world</p>` + 417 + `<img src="https://open.mailchimp.com/track/abc123" alt="" width="1" height="1">` + 418 + `<img src="https://pixel.sendinblue.com/log/open?id=xyz" alt="">` + 419 + `<img src="cid:logo" alt="Company Logo">` + 420 + `</body></html>` 421 + 422 + _, _, _, _, _, spy := parseBody([]byte(raw)) 423 + 424 + if spy.Count < 2 { 425 + t.Errorf("SpyPixelInfo.Count = %d, want >= 2", spy.Count) 426 + } 427 + // Check that domains were extracted 428 + found := make(map[string]bool) 429 + for _, d := range spy.Domains { 430 + found[d] = true 431 + } 432 + if !found["open.mailchimp.com"] { 433 + t.Errorf("expected domain open.mailchimp.com in spy.Domains, got %v", spy.Domains) 434 + } 435 + if !found["pixel.sendinblue.com"] { 436 + t.Errorf("expected domain pixel.sendinblue.com in spy.Domains, got %v", spy.Domains) 437 + } 438 + } 439 + 440 + func TestSpyPixelPlainTextEmail(t *testing.T) { 441 + // Plain-text emails should never report spy pixels. 442 + raw := "MIME-Version: 1.0\r\n" + 443 + "Content-Type: text/plain; charset=utf-8\r\n" + 444 + "\r\n" + 445 + "Just a normal text email." 446 + 447 + _, _, _, _, _, spy := parseBody([]byte(raw)) 448 + 449 + if spy.Count != 0 { 450 + t.Errorf("plain-text email SpyPixelInfo.Count = %d, want 0", spy.Count) 407 451 } 408 452 } 409 453
+6 -6
internal/integration_test.go
··· 178 178 } 179 179 180 180 // Fetch body and verify content 181 - markdown, rawHTML, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 181 + markdown, rawHTML, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 182 182 if err != nil { 183 183 t.Fatalf("FetchBody: %v", err) 184 184 } ··· 214 214 defer cleanupEmail(t, cli, "INBOX", email.UID) 215 215 216 216 // Fetch raw body to check CC header 217 - markdown, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 217 + markdown, _, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 218 218 if err != nil { 219 219 t.Fatalf("FetchBody: %v", err) 220 220 } ··· 249 249 defer cleanupEmail(t, cli, "INBOX", email.UID) 250 250 251 251 // Fetch body — attachments should be listed 252 - _, _, _, attachments, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 252 + _, _, _, attachments, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 253 253 if err != nil { 254 254 t.Fatalf("FetchBody: %v", err) 255 255 } ··· 426 426 defer cleanupEmail(t, cli, "INBOX", email.UID) 427 427 428 428 // Fetch body — inline image should appear as attachment with image content type 429 - _, rawHTML, _, attachments, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 429 + _, rawHTML, _, attachments, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 430 430 if err != nil { 431 431 t.Fatalf("FetchBody: %v", err) 432 432 } ··· 485 485 email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second) 486 486 defer cleanupEmail(t, cli, "INBOX", email.UID) 487 487 488 - markdown, rawHTML, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 488 + markdown, rawHTML, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 489 489 if err != nil { 490 490 t.Fatalf("FetchBody: %v", err) 491 491 } ··· 901 901 902 902 // Fetch body to verify content survived delivery 903 903 ctx := context.Background() 904 - markdown, rawHTML, _, _, _, err := cli.FetchBody(ctx, "INBOX", email.UID) 904 + markdown, rawHTML, _, _, _, _, err := cli.FetchBody(ctx, "INBOX", email.UID) 905 905 if err != nil { 906 906 t.Fatalf("FetchBody: %v", err) 907 907 }
+1
internal/render/html.go
··· 20 20 <head> 21 21 <meta charset="UTF-8"> 22 22 <meta name="viewport" content="width=device-width,initial-scale=1.0"> 23 + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src file: data: cid:; font-src 'none';"> 23 24 <style> 24 25 body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;line-height:1.6;color:#333;margin:0;padding:8px 16px;text-align:left} 25 26 a{color:#3150AA;text-decoration:underline}
+16 -5
internal/ui/inbox.go
··· 19 19 marked bool // selected for batch operation 20 20 displaySubj string // rendered subject (may include folder prefix in temporary views) 21 21 threadPrefix string // tree chars e.g. "┌─>" for threaded display 22 + hasSpyPixel bool // tracking pixels were detected when body was loaded 22 23 } 23 24 24 25 func (e emailItem) FilterValue() string { ··· 46 47 colThreadWidth = 2 // "│ " or "╰ " or " " 47 48 colDateWidth = 7 // "Feb 03 " 48 49 colAttachWidth = 2 // "@ " or " " 50 + colSpyWidth = 1 // "⊙" or " " — spy pixel indicator 49 51 colSizeWidth = 7 // "(38.2K)" 50 52 ) 51 53 ··· 89 91 attachStr := " " 90 92 if e.email.HasAttachment { 91 93 attachStr = "@ " 94 + } 95 + spyStr := " " 96 + if e.hasSpyPixel { 97 + spyStr = "⊙" 92 98 } 93 99 sizeStr := fmtSize(e.email.Size) 94 100 95 - fixed := colNumWidth + colFlagWidth + colReplyWidth + colThreadWidth + colDateWidth + colAttachWidth + colSizeWidth + 2 // 2 spaces padding 101 + fixed := colNumWidth + colFlagWidth + colReplyWidth + colThreadWidth + colDateWidth + colAttachWidth + colSpyWidth + colSizeWidth + 2 // 2 spaces padding 96 102 fromMax := 20 97 103 subjectMax := width - fixed - fromMax - 2 98 104 if subjectMax < 8 { ··· 113 119 subject := truncate(subjectText, subjectMax) 114 120 115 121 if isSelected { 116 - row := fmt.Sprintf("%s%s%s%s%s%s%-*s %-*s %s", 117 - num, flag, replyStr, threadStr, dateStr, attachStr, 122 + row := fmt.Sprintf("%s%s%s%s%s%s%s%-*s %-*s %s", 123 + num, flag, replyStr, threadStr, dateStr, attachStr, spyStr, 118 124 fromMax, from, 119 125 subjectMax, subject, 120 126 sizeStr, ··· 141 147 threadS := lipgloss.NewStyle().Foreground(colorBorder).Render(threadStr) 142 148 dateS := lipgloss.NewStyle().Foreground(colorDateCol).Render(dateStr) 143 149 attachS := lipgloss.NewStyle().Foreground(colorDateCol).Render(attachStr) 150 + spyS := lipgloss.NewStyle().Foreground(colorMuted).Render(spyStr) 151 + if e.hasSpyPixel { 152 + spyS = lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Render(spyStr) // orange warning 153 + } 144 154 145 155 fromStyle := lipgloss.NewStyle().Foreground(colorAuthorRead) 146 156 subStyle := lipgloss.NewStyle().Foreground(colorSubjectRead) ··· 152 162 subS := subStyle.Render(fmt.Sprintf("%-*s", subjectMax, subject)) 153 163 sizeS := lipgloss.NewStyle().Foreground(colorSizeCol).Render(sizeStr) 154 164 155 - fmt.Fprint(w, numS+flagS+replyS+threadS+dateS+attachS+fromS+" "+subS+" "+sizeS) 165 + fmt.Fprint(w, numS+flagS+replyS+threadS+dateS+attachS+spyS+fromS+" "+subS+" "+sizeS) 156 166 } 157 167 158 168 // cleanFrom strips the <addr> part when a display name is present. ··· 238 248 // It threads emails before display — grouped conversations appear together 239 249 // with tree-drawing prefixes (┌─>) on reply rows. 240 250 // Sorting respects the user's chosen sortField and sortReverse preferences. 241 - func setEmails(l *list.Model, emails []imap.Email, marked map[uint32]bool, prefixFolders bool, sortField string, sortReverse bool, disableThreading bool) tea.Cmd { 251 + func setEmails(l *list.Model, emails []imap.Email, marked, spyPixels map[uint32]bool, prefixFolders bool, sortField string, sortReverse bool, disableThreading bool) tea.Cmd { 242 252 var threaded []threadedEmail 243 253 if disableThreading { 244 254 threaded = flatEmails(emails, sortField, sortReverse) ··· 257 267 marked: marked[te.email.UID], 258 268 displaySubj: displaySubj, 259 269 threadPrefix: te.threadPrefix, 270 + hasSpyPixel: spyPixels[te.email.UID], 260 271 } 261 272 } 262 273 return l.SetItems(items)
+68 -13
internal/ui/model.go
··· 53 53 webURL string // canonical "view online" URL (List-Post header or plain-text preamble) 54 54 attachments []imap.Attachment 55 55 references string // References header for email threading 56 + spyPixels imap.SpyPixelInfo 56 57 } 57 58 sendDoneMsg struct { 58 59 err error ··· 128 129 } 129 130 saveDraftDoneMsg struct{ err error } 130 131 attachOpenDoneMsg struct { 131 - path string 132 - err error 132 + path string 133 + err error 134 + dangerous bool // true if file was saved but NOT auto-opened due to risky extension 133 135 } 134 136 emlDownloadedMsg struct { 135 137 path string ··· 468 470 openBody string // markdown body used by the TUI reader 469 471 openHTMLBody string // original HTML part; used by openInExternalViewer when available 470 472 openWebURL string // canonical "view online" URL for ctrl+o (may be empty) 471 - openAttachments []imap.Attachment // attachments of the currently open email 472 - openLinks []emailLink // extracted links from the email body 473 - readerPending string // chord prefix in reader (space for link open) 473 + openAttachments []imap.Attachment // attachments of the currently open email 474 + openLinks []emailLink // extracted links from the email body 475 + openSpyPixels imap.SpyPixelInfo // spy pixels detected in the currently open email 476 + readerPending string // chord prefix in reader (space for link open) 474 477 // Mark-as-read timer tracking 475 478 markAsReadUID uint32 // UID of email with pending mark-as-read timer 476 479 markAsReadFolder string // folder of email with pending mark-as-read timer ··· 496 499 497 500 // Marked emails for batch operations (UID → true) 498 501 markedUIDs map[uint32]bool 502 + 503 + // Spy pixel tracking: UID → true when email body contained tracking pixels. 504 + // Populated on body load, used to show ⊙ indicator in inbox list. 505 + spyPixelUIDs map[uint32]bool 499 506 500 507 // Undo stack: each entry is a batch of moves that can be reversed with u. 501 508 // Screener operations (I/O/F/P/$) are not undoable — they also modify .txt files. ··· 595 602 compose: compose, 596 603 spinner: sp, 597 604 markedUIDs: make(map[uint32]bool), 605 + spyPixelUIDs: make(map[uint32]bool), 598 606 startupNotice: detectStartupNotice(), 599 607 sortField: "date", 600 608 sortReverse: true, // newest first ··· 772 780 773 781 func (m Model) fetchBodyCmd(e *imap.Email) tea.Cmd { 774 782 return func() tea.Msg { 775 - body, rawHTML, webURL, attachments, references, err := m.imapCli().FetchBody(nil, e.Folder, e.UID) 783 + body, rawHTML, webURL, attachments, references, spyPixels, err := m.imapCli().FetchBody(nil, e.Folder, e.UID) 776 784 if err != nil { 777 785 return errMsg{err} 778 786 } 779 - return bodyLoadedMsg{email: e, body: body, rawHTML: rawHTML, webURL: webURL, attachments: attachments, references: references} 787 + return bodyLoadedMsg{email: e, body: body, rawHTML: rawHTML, webURL: webURL, attachments: attachments, references: references, spyPixels: spyPixels} 780 788 } 781 789 } 782 790 ··· 1723 1731 m.openHTMLBody = msg.rawHTML 1724 1732 m.openWebURL = msg.webURL 1725 1733 m.openAttachments = msg.attachments 1734 + m.openSpyPixels = msg.spyPixels 1735 + // Track spy pixel presence for inbox indicator 1736 + if msg.spyPixels.Count > 0 && msg.email != nil { 1737 + m.spyPixelUIDs[msg.email.UID] = true 1738 + } 1726 1739 // Store References header in the email struct for threading 1727 1740 if msg.email != nil { 1728 1741 msg.email.References = msg.references ··· 1772 1785 } 1773 1786 } 1774 1787 m.openLinks = extractLinks(msg.body) 1775 - _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.openLinks, m.cfg.UI.Theme, m.width) 1788 + _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.openSpyPixels, m.openLinks, m.cfg.UI.Theme, m.width) 1776 1789 m.state = stateReading 1777 1790 // Refresh inbox list if immediate mode, or start timer 1778 1791 if m.cfg.UI.MarkAsReadAfterSecs <= 0 { ··· 1817 1830 if msg.err != nil { 1818 1831 m.status = "Attachment error: " + msg.err.Error() 1819 1832 m.isError = true 1833 + } else if msg.dangerous { 1834 + m.status = "Saved to " + msg.path + " — not auto-opened (dangerous file type)" 1835 + m.isError = true 1820 1836 } else { 1821 1837 m.status = "Saved to " + msg.path + " — opening…" 1822 1838 m.isError = false ··· 2834 2850 } 2835 2851 2836 2852 noThread := len(m.folders) > 0 && m.activeFolder() == m.cfg.Folders.Sent 2837 - return setEmails(&m.inbox, filtered, m.markedUIDs, m.shouldPrefixFolderInSubject(), m.sortField, m.sortReverse, noThread) 2853 + return setEmails(&m.inbox, filtered, m.markedUIDs, m.spyPixelUIDs, m.shouldPrefixFolderInSubject(), m.sortField, m.sortReverse, noThread) 2838 2854 } 2839 2855 2840 2856 // handleChord dispatches two-key sequences (g<x>, M<x>, space<x>). ··· 3056 3072 // Clear mark-as-read timer state when exiting reader 3057 3073 m.markAsReadUID = 0 3058 3074 m.markAsReadFolder = "" 3075 + // Rebuild inbox list so ⊙ spy pixel indicator appears immediately 3076 + if m.openSpyPixels.Count > 0 { 3077 + return m, m.applyFilter() 3078 + } 3059 3079 return m, nil 3060 3080 case "e": 3061 3081 return m.openInNeovim() ··· 3124 3144 } 3125 3145 3126 3146 // openLinkCmd opens a URL in $BROWSER (or xdg-open). 3147 + // Only http://, https://, and mailto: schemes are allowed to prevent 3148 + // javascript:, data:, or other potentially dangerous URL schemes. 3127 3149 func (m Model) openLinkCmd(url string) tea.Cmd { 3150 + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "mailto:") { 3151 + return func() tea.Msg { 3152 + return errMsg{fmt.Errorf("blocked unsafe URL scheme: %s", url)} 3153 + } 3154 + } 3128 3155 browser := os.Getenv("BROWSER") 3129 3156 if browser == "" { 3130 3157 browser = "xdg-open" ··· 3281 3308 } 3282 3309 } 3283 3310 3311 + // dangerousExts lists file extensions that should not be auto-opened with xdg-open 3312 + // because they could execute arbitrary code. 3313 + var dangerousExts = map[string]bool{ 3314 + ".sh": true, ".bash": true, ".zsh": true, ".fish": true, 3315 + ".exe": true, ".bat": true, ".cmd": true, ".com": true, ".scr": true, ".msi": true, 3316 + ".desktop": true, ".app": true, ".command": true, ".action": true, 3317 + ".py": true, ".rb": true, ".pl": true, ".ps1": true, 3318 + ".jar": true, ".class": true, 3319 + } 3320 + 3284 3321 // downloadOpenAttachmentCmd saves the attachment to ~/Downloads and opens it 3285 3322 // with xdg-open (non-blocking — does not suspend the TUI). 3323 + // Dangerous file types are saved but NOT auto-opened. 3286 3324 func (m Model) downloadOpenAttachmentCmd(a imap.Attachment) tea.Cmd { 3287 3325 return func() tea.Msg { 3288 3326 home, err := os.UserHomeDir() ··· 3308 3346 } 3309 3347 if err := os.WriteFile(dst, a.Data, 0644); err != nil { 3310 3348 return attachOpenDoneMsg{err: fmt.Errorf("save attachment: %w", err)} 3349 + } 3350 + ext := strings.ToLower(filepath.Ext(base)) 3351 + if dangerousExts[ext] { 3352 + return attachOpenDoneMsg{path: dst, dangerous: true} 3311 3353 } 3312 3354 _ = exec.Command("xdg-open", dst).Start() 3313 3355 return attachOpenDoneMsg{path: dst} ··· 3821 3863 cc := m.compose.cc.Value() 3822 3864 bcc := m.compose.bcc.Value() 3823 3865 subject := m.compose.subject.Value() 3824 - prelude := editor.Prelude(to, cc, bcc, m.presendFrom(), subject, m.cfg.UI.TextSignature()) 3825 - 3826 3866 // Consume any mailto body (pre-filled from --mailto flag). 3827 - body := m.mailtoBody 3867 + mailtoBody := m.mailtoBody 3828 3868 m.mailtoBody = "" 3829 3869 3870 + // When a mailto body is present, insert it before the signature so 3871 + // the signature always appears at the bottom of the composed message. 3872 + sig := m.cfg.UI.TextSignature() 3873 + var prelude string 3874 + if mailtoBody != "" { 3875 + // Build headers without signature, append body, then signature. 3876 + prelude = editor.Prelude(to, cc, bcc, m.presendFrom(), subject, "") 3877 + prelude += mailtoBody 3878 + if sig != "" { 3879 + prelude += "\n\n-- \n" + sig + "\n" 3880 + } 3881 + } else { 3882 + prelude = editor.Prelude(to, cc, bcc, m.presendFrom(), subject, sig) 3883 + } 3884 + 3830 3885 // Write temp file 3831 3886 f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") 3832 3887 if err != nil { ··· 3836 3891 return m, nil 3837 3892 } 3838 3893 tmpPath := f.Name() 3839 - f.WriteString(prelude + body) //nolint 3894 + f.WriteString(prelude) //nolint 3840 3895 f.Close() 3841 3896 3842 3897 editorBin := os.Getenv("EDITOR")
+13 -3
internal/ui/reader.go
··· 6 6 "strings" 7 7 8 8 "github.com/charmbracelet/bubbles/viewport" 9 + "github.com/charmbracelet/lipgloss" 9 10 "github.com/sspaeti/neomd/internal/imap" 10 11 "github.com/sspaeti/neomd/internal/render" 11 12 ) ··· 82 83 } 83 84 84 85 // loadEmailIntoReader renders the email and sets the viewport content. 85 - func loadEmailIntoReader(vp *viewport.Model, email *imap.Email, body string, attachments []imap.Attachment, links []emailLink, theme string, width int) error { 86 - header := renderEmailHeader(email, attachments, width) 86 + func loadEmailIntoReader(vp *viewport.Model, email *imap.Email, body string, attachments []imap.Attachment, spyPixels imap.SpyPixelInfo, links []emailLink, theme string, width int) error { 87 + header := renderEmailHeader(email, attachments, spyPixels, width) 87 88 88 89 // Inject link numbers inline before glamour rendering 89 90 numbered := numberLinks(body, links) ··· 98 99 return nil 99 100 } 100 101 101 - func renderEmailHeader(e *imap.Email, attachments []imap.Attachment, width int) string { 102 + func renderEmailHeader(e *imap.Email, attachments []imap.Attachment, spyPixels imap.SpyPixelInfo, width int) string { 102 103 if e == nil { 103 104 return "" 104 105 } ··· 124 125 parts = append(parts, fmt.Sprintf("[%d] %s", i+1, a.Filename)) 125 126 } 126 127 lines = append(lines, styleHelp.Render("Attach: ")+strings.Join(parts, " ")) 128 + } 129 + 130 + if spyPixels.Count > 0 { 131 + spyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208")) // orange 132 + label := fmt.Sprintf("%d spy pixel(s) blocked", spyPixels.Count) 133 + if len(spyPixels.Domains) > 0 { 134 + label += " (" + strings.Join(spyPixels.Domains, ", ") + ")" 135 + } 136 + lines = append(lines, spyStyle.Render("⊙ "+label)) 127 137 } 128 138 129 139 content := strings.Join(lines, "\n")