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.

fix review

sspaeti 4c59de13 f2c3a773

+127 -36
+76 -21
internal/imap/client.go
··· 1201 1201 // it with proper formatting: bold, italic, links, headings, lists, and image 1202 1202 // placeholders (![alt](url) → [Image: alt] in the terminal). 1203 1203 func htmlToMarkdown(h string) (string, SpyPixelInfo) { 1204 + // Detect spy pixels on raw HTML before conversion (size/visibility heuristics). 1205 + spy := detectSpyPixels(h) 1206 + 1204 1207 // Remove <wbr> tags and join newlines inside href/src attribute values. 1205 1208 // Newsletter services (Substack, Mailchimp) insert line breaks inside URLs 1206 1209 // for HTML rendering; html-to-markdown preserves them, breaking link syntax. ··· 1219 1222 converter := htmlmd.NewConverter("", true, nil) 1220 1223 result, err := converter.ConvertString(h) 1221 1224 if err != nil { 1222 - return stripHTMLFallback(h), SpyPixelInfo{} 1225 + return stripHTMLFallback(h), spy 1223 1226 } 1224 - return cleanMarkdown(strings.TrimSpace(result)) 1227 + return cleanMarkdown(strings.TrimSpace(result)), spy 1225 1228 } 1226 1229 1230 + // SpyPixelInfo holds the results of tracking pixel detection. 1227 1231 // SpyPixelInfo holds the results of tracking pixel detection. 1228 1232 type SpyPixelInfo struct { 1229 - Count int // number of tracking pixels stripped 1233 + Count int // number of tracking pixels detected 1230 1234 Domains []string // unique tracker domains extracted from pixel URLs 1231 1235 } 1232 1236 1237 + // reSpyPixel matches <img> tags that look like tracking pixels in raw HTML: 1238 + // - empty or whitespace-only alt attribute 1239 + // - AND at least one of: width/height of 0 or 1, display:none, visibility:hidden, 1240 + // or known tracker URL patterns (track/open, pixel, beacon). 1241 + // This avoids false positives on legitimate decorative images or image-only buttons. 1242 + var reSpyPixel = regexp.MustCompile(`(?i)<img\b[^>]*\bsrc="(https?://[^"]+)"[^>]*>`) 1243 + 1244 + // isSpyPixel checks if an <img> tag is a tracking pixel based on heuristics. 1245 + func isSpyPixel(tag string) bool { 1246 + // Must have empty or missing alt to be considered a tracker. 1247 + // Match alt="non-empty-content" — if present, it's a real image. 1248 + hasNonEmptyAlt := regexp.MustCompile(`(?i)\balt=["'][^"']+["']`).MatchString(tag) 1249 + if hasNonEmptyAlt { 1250 + return false 1251 + } 1252 + // Check size heuristics: width="1", height="1", width="0", height="0" 1253 + if regexp.MustCompile(`(?i)\b(?:width|height)=["']?[01](?:px)?["']?`).MatchString(tag) { 1254 + return true 1255 + } 1256 + // Check CSS hiding: display:none, visibility:hidden 1257 + if regexp.MustCompile(`(?i)(?:display\s*:\s*none|visibility\s*:\s*hidden)`).MatchString(tag) { 1258 + return true 1259 + } 1260 + // Check known tracker URL patterns in src 1261 + src := reSpyPixel.FindStringSubmatch(tag) 1262 + if len(src) >= 2 { 1263 + u := strings.ToLower(src[1]) 1264 + trackerPatterns := []string{ 1265 + "/track/open", "/track/click", "open.php", 1266 + "/pixel", "/beacon", "/wf/open", "/o.gif", 1267 + "list-manage.com/track", 1268 + } 1269 + for _, p := range trackerPatterns { 1270 + if strings.Contains(u, p) { 1271 + return true 1272 + } 1273 + } 1274 + } 1275 + return false 1276 + } 1277 + 1278 + // detectSpyPixels scans raw HTML for tracking pixel <img> tags. 1279 + func detectSpyPixels(html string) SpyPixelInfo { 1280 + var spy SpyPixelInfo 1281 + // Find all <img> tags 1282 + reImg := regexp.MustCompile(`(?i)<img\b[^>]*>`) 1283 + tags := reImg.FindAllString(html, -1) 1284 + seen := make(map[string]bool) 1285 + for _, tag := range tags { 1286 + if isSpyPixel(tag) { 1287 + spy.Count++ 1288 + src := reSpyPixel.FindStringSubmatch(tag) 1289 + if len(src) >= 2 { 1290 + if d := extractDomain(src[1]); d != "" && !seen[d] { 1291 + seen[d] = true 1292 + spy.Domains = append(spy.Domains, d) 1293 + } 1294 + } 1295 + } 1296 + } 1297 + return spy 1298 + } 1299 + 1233 1300 // reEmptyImg matches empty markdown image tags produced from tracking pixels. 1234 1301 var reEmptyImg = regexp.MustCompile(`!\[\s*\]\(([^)]*)\)`) 1235 1302 1236 1303 // cleanMarkdown post-processes html-to-markdown output to remove newsletter 1237 - // noise: invisible Unicode spacers, tracking pixels, bare URL lines, and 1238 - // excessive blank lines. Returns the cleaned string and spy pixel info. 1239 - func cleanMarkdown(s string) (string, SpyPixelInfo) { 1304 + // noise: invisible Unicode spacers, empty images, bare URL lines, and 1305 + // excessive blank lines. 1306 + func cleanMarkdown(s string) string { 1240 1307 // 1. Strip invisible Unicode characters used as email preheader spacers: 1241 1308 // U+034F COMBINING GRAPHEME JOINER, U+00AD SOFT HYPHEN, 1242 1309 // U+200B ZERO WIDTH SPACE, U+200C/D ZWNJ/ZWJ, U+FEFF BOM 1243 1310 reInvis := regexp.MustCompile(`[\x{034F}\x{00AD}\x{200B}\x{200C}\x{200D}\x{FEFF}]+`) 1244 1311 s = reInvis.ReplaceAllString(s, "") 1245 1312 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 - } 1313 + // 2. Remove empty image tags: ![](...) or ![ ](...) 1314 + s = reEmptyImg.ReplaceAllString(s, "") 1260 1315 1261 1316 // 3. Remove empty link anchors left behind when image-only links are cleaned: 1262 1317 // [](url) or [ ](url) ··· 1278 1333 reExcessBlank := regexp.MustCompile(`\n{4,}`) 1279 1334 s = reExcessBlank.ReplaceAllString(s, "\n\n\n") 1280 1335 1281 - return strings.TrimSpace(s), spy 1336 + return strings.TrimSpace(s) 1282 1337 } 1283 1338 1284 1339 // extractDomain pulls the hostname from a URL string, returning "" on failure.
+14 -6
internal/imap/client_test.go
··· 409 409 410 410 func TestSpyPixelDetection(t *testing.T) { 411 411 // HTML email with 2 tracking pixels from different domains. 412 + // First: detected by size heuristic (width="1" height="1") 413 + // Second: detected by URL pattern (/track/open) 414 + // Third: legitimate image with alt text — should NOT be counted 415 + // Fourth: decorative image with empty alt but normal size — should NOT be counted 412 416 raw := "MIME-Version: 1.0\r\n" + 413 417 "Content-Type: text/html; charset=utf-8\r\n" + 414 418 "\r\n" + 415 419 `<html><body>` + 416 420 `<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="">` + 421 + `<img src="https://click.mailchimp.com/track/open.php?id=abc" alt="" width="1" height="1">` + 422 + `<img src="https://pixel.sendinblue.com/beacon/track/open?id=xyz" alt="" height="0">` + 419 423 `<img src="cid:logo" alt="Company Logo">` + 424 + `<img src="https://cdn.example.com/button.png" alt="" width="200" height="50">` + 420 425 `</body></html>` 421 426 422 427 _, _, _, _, _, spy := parseBody([]byte(raw)) 423 428 424 - if spy.Count < 2 { 425 - t.Errorf("SpyPixelInfo.Count = %d, want >= 2", spy.Count) 429 + if spy.Count != 2 { 430 + t.Errorf("SpyPixelInfo.Count = %d, want 2", spy.Count) 426 431 } 427 432 // Check that domains were extracted 428 433 found := make(map[string]bool) 429 434 for _, d := range spy.Domains { 430 435 found[d] = true 431 436 } 432 - if !found["open.mailchimp.com"] { 433 - t.Errorf("expected domain open.mailchimp.com in spy.Domains, got %v", spy.Domains) 437 + if !found["click.mailchimp.com"] { 438 + t.Errorf("expected domain click.mailchimp.com in spy.Domains, got %v", spy.Domains) 434 439 } 435 440 if !found["pixel.sendinblue.com"] { 436 441 t.Errorf("expected domain pixel.sendinblue.com in spy.Domains, got %v", spy.Domains) 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) 437 445 } 438 446 } 439 447
+22
internal/render/html.go
··· 61 61 goldmark.WithRendererOptions(html.WithHardWraps()), 62 62 ) 63 63 64 + // cspMeta is the Content-Security-Policy meta tag injected into all browser views. 65 + // Blocks remote images (tracking pixels), scripts, and fonts. 66 + const cspMeta = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src file: data: cid:; font-src 'none';">` 67 + 68 + // InjectCSP inserts a CSP meta tag into an HTML document for safe browser viewing. 69 + // If the document has a <head>, the tag is inserted after it. Otherwise it's prepended. 70 + func InjectCSP(html string) string { 71 + if idx := strings.Index(strings.ToLower(html), "<head>"); idx >= 0 { 72 + insert := idx + len("<head>") 73 + return html[:insert] + "\n" + cspMeta + html[insert:] 74 + } 75 + if idx := strings.Index(strings.ToLower(html), "<html"); idx >= 0 { 76 + // Find the end of the <html...> tag 77 + end := strings.IndexByte(html[idx:], '>') 78 + if end >= 0 { 79 + insert := idx + end + 1 80 + return html[:insert] + "<head>" + cspMeta + "</head>" + html[insert:] 81 + } 82 + } 83 + return cspMeta + "\n" + html 84 + } 85 + 64 86 // ToHTML converts a Markdown string to a complete HTML email document. 65 87 func ToHTML(markdown string) (string, error) { 66 88 var fragment bytes.Buffer
+7 -2
internal/ui/inbox.go
··· 248 248 // It threads emails before display — grouped conversations appear together 249 249 // with tree-drawing prefixes (┌─>) on reply rows. 250 250 // Sorting respects the user's chosen sortField and sortReverse preferences. 251 - func setEmails(l *list.Model, emails []imap.Email, marked, spyPixels map[uint32]bool, prefixFolders bool, sortField string, sortReverse bool, disableThreading bool) tea.Cmd { 251 + // spyPixelKey returns a unique cache key for spy pixel tracking across folders. 252 + func spyPixelKey(folder string, uid uint32) string { 253 + return folder + "\x00" + fmt.Sprintf("%d", uid) 254 + } 255 + 256 + func setEmails(l *list.Model, emails []imap.Email, marked map[uint32]bool, spyPixels map[string]bool, prefixFolders bool, sortField string, sortReverse bool, disableThreading bool) tea.Cmd { 252 257 var threaded []threadedEmail 253 258 if disableThreading { 254 259 threaded = flatEmails(emails, sortField, sortReverse) ··· 267 272 marked: marked[te.email.UID], 268 273 displaySubj: displaySubj, 269 274 threadPrefix: te.threadPrefix, 270 - hasSpyPixel: spyPixels[te.email.UID], 275 + hasSpyPixel: spyPixels[spyPixelKey(te.email.Folder, te.email.UID)], 271 276 } 272 277 } 273 278 return l.SetItems(items)
+8 -7
internal/ui/model.go
··· 500 500 // Marked emails for batch operations (UID → true) 501 501 markedUIDs map[uint32]bool 502 502 503 - // Spy pixel tracking: UID → true when email body contained tracking pixels. 503 + // Spy pixel tracking: "folder\x00uid" → true when email body contained tracking pixels. 504 504 // Populated on body load, used to show ⊙ indicator in inbox list. 505 - spyPixelUIDs map[uint32]bool 505 + // Keyed by folder+UID to avoid collisions across mailboxes (UIDs are only unique per folder). 506 + spyPixelKeys map[string]bool 506 507 507 508 // Undo stack: each entry is a batch of moves that can be reversed with u. 508 509 // Screener operations (I/O/F/P/$) are not undoable — they also modify .txt files. ··· 602 603 compose: compose, 603 604 spinner: sp, 604 605 markedUIDs: make(map[uint32]bool), 605 - spyPixelUIDs: make(map[uint32]bool), 606 + spyPixelKeys: make(map[string]bool), 606 607 startupNotice: detectStartupNotice(), 607 608 sortField: "date", 608 609 sortReverse: true, // newest first ··· 1734 1735 m.openSpyPixels = msg.spyPixels 1735 1736 // Track spy pixel presence for inbox indicator 1736 1737 if msg.spyPixels.Count > 0 && msg.email != nil { 1737 - m.spyPixelUIDs[msg.email.UID] = true 1738 + m.spyPixelKeys[spyPixelKey(msg.email.Folder, msg.email.UID)] = true 1738 1739 } 1739 1740 // Store References header in the email struct for threading 1740 1741 if msg.email != nil { ··· 2850 2851 } 2851 2852 2852 2853 noThread := len(m.folders) > 0 && m.activeFolder() == m.cfg.Folders.Sent 2853 - return setEmails(&m.inbox, filtered, m.markedUIDs, m.spyPixelUIDs, m.shouldPrefixFolderInSubject(), m.sortField, m.sortReverse, noThread) 2854 + return setEmails(&m.inbox, filtered, m.markedUIDs, m.spyPixelKeys, m.shouldPrefixFolderInSubject(), m.sortField, m.sortReverse, noThread) 2854 2855 } 2855 2856 2856 2857 // handleChord dispatches two-key sequences (g<x>, M<x>, space<x>). ··· 3174 3175 3175 3176 var htmlBody string 3176 3177 if m.openHTMLBody != "" { 3177 - htmlBody = m.openHTMLBody 3178 + htmlBody = render.InjectCSP(m.openHTMLBody) 3178 3179 } else { 3179 3180 var err error 3180 3181 htmlBody, err = render.ToHTML(m.openBody) ··· 3251 3252 3252 3253 var htmlBody string 3253 3254 if m.openHTMLBody != "" { 3254 - htmlBody = m.openHTMLBody 3255 + htmlBody = render.InjectCSP(m.openHTMLBody) 3255 3256 } else { 3256 3257 var err error 3257 3258 htmlBody, err = render.ToHTML(m.openBody)