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.

add tests for new security attachment tests

+174 -7
+1 -1
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; 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/) 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 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)
+1 -1
SECURITY.md
··· 116 116 117 117 ## Screener as a security layer 118 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. 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 120 121 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 122
+1 -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 - - **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/) 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 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)
+157
internal/integration_test.go
··· 11 11 import ( 12 12 "context" 13 13 "fmt" 14 + "net/http" 14 15 "os" 15 16 "path/filepath" 16 17 "strings" ··· 926 927 } 927 928 928 929 // --- Helpers --- 930 + 931 + // TestIntegration_SecurityFeatures sends an email with a real attachment, a 932 + // disguised script (.sh content saved as .png), a callout, and an HTML signature. 933 + // The email arrives in the test inbox so you can verify attachment safety live in neomd. 934 + func TestIntegration_SecurityFeatures(t *testing.T) { 935 + env := loadEnv(t) 936 + cli := env.imapClient() 937 + defer cli.Close() 938 + 939 + subject := uniqueSubject("security-attach-callout") 940 + 941 + // Create temp dir for attachments 942 + dir := t.TempDir() 943 + 944 + // 1. Real text attachment 945 + realDoc := filepath.Join(dir, "meeting-notes.txt") 946 + if err := os.WriteFile(realDoc, []byte("Meeting notes from 2026-04-28.\n\n- Discussed spy pixel blocking\n- Reviewed attachment safety"), 0600); err != nil { 947 + t.Fatal(err) 948 + } 949 + 950 + // 2. Disguised script: bash content saved as .png 951 + fakeImg := filepath.Join(dir, "totally-legit-photo.png") 952 + if err := os.WriteFile(fakeImg, []byte("#!/bin/bash\necho 'this is not a real image'\n"), 0600); err != nil { 953 + t.Fatal(err) 954 + } 955 + 956 + // Body with callout 957 + body := `# Security Features Test 958 + 959 + This email tests neomd's security features. 960 + 961 + > [!warning] Attachment Safety Test 962 + > This email contains a disguised script (bash content saved as .png) alongside 963 + > a real text document. neomd should block the fake image from auto-opening. 964 + 965 + ## Attachments included: 966 + 1. **meeting-notes.txt** — real text file (safe) 967 + 2. **totally-legit-photo.png** — actually a bash script (should be blocked by magic-byte check) 968 + 969 + *sent from [neomd](https://neomd.ssp.sh)*` 970 + 971 + err := smtp.Send(env.smtpConfig(), env.user+", simon@ssp.sh", "", "", subject, body, []string{realDoc, fakeImg}) 972 + if err != nil { 973 + t.Fatalf("Send: %v", err) 974 + } 975 + 976 + email := waitForEmail(t, cli, "INBOX", subject, 60*time.Second) 977 + defer cleanupEmail(t, cli, "INBOX", email.UID) 978 + 979 + // Fetch and verify 980 + markdown, rawHTML, _, attachments, _, spyPixels, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 981 + if err != nil { 982 + t.Fatalf("FetchBody: %v", err) 983 + } 984 + 985 + // Verify callout rendered in HTML 986 + if !strings.Contains(rawHTML, "callout") || !strings.Contains(rawHTML, "warning") { 987 + t.Logf("HTML (truncated): %s", truncate(rawHTML, 300)) 988 + t.Error("expected callout markup in HTML body") 989 + } 990 + 991 + // Verify at least 2 attachments arrived 992 + if len(attachments) < 2 { 993 + t.Errorf("expected at least 2 attachments, got %d", len(attachments)) 994 + } 995 + for _, a := range attachments { 996 + t.Logf("Attachment: %s (%s, %d bytes)", a.Filename, a.ContentType, len(a.Data)) 997 + } 998 + 999 + // Verify the disguised script would be caught by magic-byte check 1000 + for _, a := range attachments { 1001 + if strings.Contains(a.Filename, "totally-legit") { 1002 + detected := http.DetectContentType(a.Data) 1003 + if strings.HasPrefix(detected, "image/") { 1004 + t.Errorf("disguised script detected as image — magic bytes failed: %s", detected) 1005 + } else { 1006 + t.Logf("Correctly detected disguised script as: %s (not image/)", detected) 1007 + } 1008 + } 1009 + } 1010 + 1011 + t.Logf("Spy pixels: %d (expected 0 for self-sent)", spyPixels.Count) 1012 + t.Logf("Markdown preview: %s", truncate(markdown, 200)) 1013 + } 1014 + 1015 + // TestIntegration_BrowserSanitization sends an email with inline script tags, 1016 + // an iframe, and an event handler to verify that SanitizeForBrowser blocks them 1017 + // when opened with O in neomd. Also sent to simon@ssp.sh for live inspection. 1018 + func TestIntegration_BrowserSanitization(t *testing.T) { 1019 + env := loadEnv(t) 1020 + cli := env.imapClient() 1021 + defer cli.Close() 1022 + 1023 + subject := uniqueSubject("browser-csp-test") 1024 + 1025 + body := `# Browser Sanitization Test 1026 + 1027 + Open this email with **O** in neomd to test CSP protection. 1028 + 1029 + ## What to check in the browser: 1030 + 1031 + 1. The **script alert should NOT fire** — if you see a popup saying "XSS worked", the CSP failed 1032 + 2. The **iframe should NOT load** — you should see an empty space, not an embedded page 1033 + 3. The **image should load normally** — the neomd logo below should be visible 1034 + 4. The **onload handler should NOT fire** — no "event handler" alert 1035 + 1036 + If everything works: you see the image, no popups, no iframe content. 1037 + 1038 + ![neomd logo](https://raw.githubusercontent.com/ssp-data/neomd/main/docs/static/images/overview-email-feed.png) 1039 + 1040 + *sent from [neomd](https://neomd.ssp.sh) — CSP test*` 1041 + 1042 + // Send normally — the HTML will contain the markdown-rendered content. 1043 + // To also test raw HTML injection, we build a custom message with injected tags. 1044 + raw, err := smtp.BuildMessage(env.from, env.user+", simon@ssp.sh", "", subject, body, nil, "") 1045 + if err != nil { 1046 + t.Fatalf("BuildMessage: %v", err) 1047 + } 1048 + 1049 + // Inject malicious HTML into the raw MIME before sending. 1050 + // These should all be blocked by the CSP when opened with O. 1051 + injection := `<script>alert('XSS worked! CSP is broken!')</script>` + 1052 + `<iframe src="https://example.com" width="400" height="200"></iframe>` + 1053 + `<img src="https://raw.githubusercontent.com/ssp-data/neomd/main/docs/static/images/overview-email-feed.png" onload="alert('event handler fired! CSP broken!')" alt="test image">` + 1054 + `<p style="color:green;font-size:20px;font-weight:bold;">If you see this text but NO popups and NO iframe, the CSP is working correctly.</p>` 1055 + 1056 + // Insert injection before </body> in the HTML part 1057 + rawStr := string(raw) 1058 + if idx := strings.LastIndex(rawStr, "</body>"); idx >= 0 { 1059 + rawStr = rawStr[:idx] + injection + rawStr[idx:] 1060 + } 1061 + 1062 + allRecipients := []string{env.user, "simon@ssp.sh"} 1063 + if err := smtp.SendRaw(env.smtpConfig(), allRecipients, []byte(rawStr)); err != nil { 1064 + t.Fatalf("SendRaw: %v", err) 1065 + } 1066 + 1067 + email := waitForEmail(t, cli, "INBOX", subject, 60*time.Second) 1068 + defer cleanupEmail(t, cli, "INBOX", email.UID) 1069 + 1070 + _, rawHTML, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 1071 + if err != nil { 1072 + t.Fatalf("FetchBody: %v", err) 1073 + } 1074 + 1075 + // Verify the malicious content is present in raw HTML (it should be — CSP blocks execution, not content) 1076 + if !strings.Contains(rawHTML, "<script>") { 1077 + t.Error("expected <script> tag in raw HTML (CSP should block execution, not strip content)") 1078 + } 1079 + if !strings.Contains(rawHTML, "<iframe") { 1080 + t.Error("expected <iframe> tag in raw HTML (CSP should block loading, not strip content)") 1081 + } 1082 + 1083 + t.Log("Email sent with script/iframe/onload injection.") 1084 + t.Log("Open with O in neomd — you should see the image and green text, but NO popups and NO iframe content.") 1085 + } 929 1086 930 1087 func extractUser(from string) string { 931 1088 if i := strings.Index(from, "<"); i >= 0 {
+4
internal/render/html.go
··· 68 68 // SanitizeForBrowser injects a restrictive CSP into raw HTML to block scripts 69 69 // and frames while allowing images. For use when opening untrusted email HTML. 70 70 func SanitizeForBrowser(html string) string { 71 + // Skip if CSP already present (e.g. from htmlTemplate via ToHTML). 72 + if strings.Contains(html, "Content-Security-Policy") { 73 + return html 74 + } 71 75 // Insert after <head> if present, otherwise prepend. 72 76 lower := strings.ToLower(html) 73 77 if idx := strings.Index(lower, "<head>"); idx >= 0 {
+10 -4
internal/ui/model.go
··· 142 142 attachOpenDoneMsg struct { 143 143 path string 144 144 err error 145 - dangerous bool // true if file was saved but NOT auto-opened due to risky extension 145 + dangerous bool // true if file was saved but NOT auto-opened 146 + reason string // why it was flagged (shown in status bar) 146 147 } 147 148 emlDownloadedMsg struct { 148 149 path string ··· 1908 1909 m.status = "Attachment error: " + msg.err.Error() 1909 1910 m.isError = true 1910 1911 } else if msg.dangerous { 1911 - m.status = "Saved to " + msg.path + " — not auto-opened (dangerous file type)" 1912 + m.status = "Saved to " + msg.path + " — not auto-opened (" + msg.reason + ")" 1912 1913 m.isError = true 1913 1914 } else { 1914 1915 m.status = "Saved to " + msg.path + " — opening…" ··· 3525 3526 } 3526 3527 ext := strings.ToLower(filepath.Ext(base)) 3527 3528 if dangerousExts[ext] { 3528 - return attachOpenDoneMsg{path: dst, dangerous: true} 3529 + return attachOpenDoneMsg{path: dst, dangerous: true, reason: fmt.Sprintf("executable extension %s", ext)} 3529 3530 } 3530 3531 // Magic-byte check: detect actual content type from file bytes. 3531 3532 // Flags mismatches like a .sh disguised as .png (detected as text/plain, not image/png). 3532 3533 if detected := http.DetectContentType(a.Data); isMimeMismatch(ext, detected) { 3533 - return attachOpenDoneMsg{path: dst, dangerous: true} 3534 + // Extract just the MIME type without params for the message 3535 + mimeType := detected 3536 + if i := strings.IndexByte(mimeType, ';'); i >= 0 { 3537 + mimeType = strings.TrimSpace(mimeType[:i]) 3538 + } 3539 + return attachOpenDoneMsg{path: dst, dangerous: true, reason: fmt.Sprintf("content is %s, not %s", mimeType, ext)} 3534 3540 } 3535 3541 _ = exec.Command("xdg-open", dst).Start() 3536 3542 return attachOpenDoneMsg{path: dst}