···146146- **Threaded inbox** — related emails are grouped together in the inbox list with a vertical connector line (`│`/`╰`), Twitter-style; threads are detected via `In-Reply-To`/`Message-ID` headers with a subject+participant fallback; newest reply on top, root at bottom; `·` reply indicator shows which emails you've answered [more](https://ssp-data.github.io/neomd/docs/reading/#threaded-inbox)
147147- **Conversation view** — `T` or `:thread` shows the full conversation across folders (Inbox, Sent, Archive, etc.) in a temporary tab with `[Folder]` prefix; see your replies alongside received emails [more](https://ssp-data.github.io/neomd/docs/reading/#conversation-view)
148148- **Glamour reading** — incoming emails rendered as styled Markdown in the terminal [more](https://ssp-data.github.io/neomd/docs/reading/)
149149-- **HEY-style screener** — unknown senders land in `ToScreen`; press `I/O/F/P` to approve, block, mark as Feed, or mark as PaperTrail; reuses your existing `screened_in.txt` lists from neomutt; 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/)
149149+- **HEY-style screener** — unknown senders land in `ToScreen`; press `I/O/F/P` to approve, block, mark as Feed, or mark as PaperTrail; reuses your existing `screened_in.txt` lists from neomutt; also acts as a **phishing defense** — impersonation emails from senders you've already approved land in ToScreen instead of Inbox, making them immediately suspicious [more](https://ssp-data.github.io/neomd/docs/screener/)
150150- **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut [more](https://ssp-data.github.io/neomd/docs/keybindings/#folders)
151151- **Emoji reactions** — press `ctrl+e` from inbox or reader to react with emoji (👍 ❤️ 😂 🎉 🙏 💯 👀 ✅); instant send with proper threading and quoted message history, no editor needed; reactions appear in conversation threads with neomd branding [more](https://ssp-data.github.io/neomd/docs/sending/#emoji-reactions)
152152- **Spy pixel blocking** — tracking pixels from newsletter services (Mailchimp, SendGrid, HubSpot, etc.) are automatically detected, counted, and stripped; `°` indicator in the inbox and tracker domains in the reader header; browser view (`O`) blocks remote images via CSP — senders cannot tell if you read their email, similar to [HEY's spy pixel blocker](https://www.hey.com/features/spy-pixel-blocker/) [more](https://ssp-data.github.io/neomd/docs/reading/#spy-pixel-blocking)
+1-1
SECURITY.md
···116116117117## Screener as a security layer
118118119119-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.
119119+The [HEY-style screener](https://ssp-data.github.io/neomd/docs/screener/) is primarily a productivity workflow, but it doubles as a **phishing defense**. Unknown senders never reach your Inbox — they land in `ToScreen` first, where you decide whether to approve them.
120120121121This matters because **an email in ToScreen from a sender you already screened in is immediately suspicious**. If you've approved `info@sbb.ch` (Swiss train service), but a new email from `info@sbb-tickets.fake.com` arrives in ToScreen, you know it's an impersonation attempt before you even open it. Without the screener, that phishing email would sit alongside legitimate SBB emails in your Inbox with no visual distinction.
122122
+1-1
docs/content/docs/_index.md
···149149- **Threaded inbox** — related emails are grouped together in the inbox list with a vertical connector line (`│`/`╰`), Twitter-style; threads are detected via `In-Reply-To`/`Message-ID` headers with a subject+participant fallback; newest reply on top, root at bottom; `·` reply indicator shows which emails you've answered [more](https://ssp-data.github.io/neomd/docs/reading/#threaded-inbox)
150150- **Conversation view** — `T` or `:thread` shows the full conversation across folders (Inbox, Sent, Archive, etc.) in a temporary tab with `[Folder]` prefix; see your replies alongside received emails [more](https://ssp-data.github.io/neomd/docs/reading/#conversation-view)
151151- **Glamour reading** — incoming emails rendered as styled Markdown in the terminal [more](https://ssp-data.github.io/neomd/docs/reading/)
152152-- **HEY-style screener** — unknown senders land in `ToScreen`; press `I/O/F/P` to approve, block, mark as Feed, or mark as PaperTrail; reuses your existing `screened_in.txt` lists from neomutt; 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/)
152152+- **HEY-style screener** — unknown senders land in `ToScreen`; press `I/O/F/P` to approve, block, mark as Feed, or mark as PaperTrail; reuses your existing `screened_in.txt` lists from neomutt; also acts as a **phishing defense** — impersonation emails from senders you've already approved land in ToScreen instead of Inbox, making them immediately suspicious [more](https://ssp-data.github.io/neomd/docs/screener/)
153153- **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut [more](https://ssp-data.github.io/neomd/docs/keybindings/#folders)
154154- **Emoji reactions** — press `ctrl+e` from inbox or reader to react with emoji (👍 ❤️ 😂 🎉 🙏 💯 👀 ✅); instant send with proper threading and quoted message history, no editor needed; reactions appear in conversation threads with neomd branding [more](https://ssp-data.github.io/neomd/docs/sending/#emoji-reactions)
155155- **Spy pixel blocking** — tracking pixels from newsletter services (Mailchimp, SendGrid, HubSpot, etc.) are automatically detected, counted, and stripped; `°` indicator in the inbox and tracker domains in the reader header; browser view (`O`) blocks remote images via CSP — senders cannot tell if you read their email, similar to [HEY's spy pixel blocker](https://www.hey.com/features/spy-pixel-blocker/) [more](https://ssp-data.github.io/neomd/docs/reading/#spy-pixel-blocking)
+157
internal/integration_test.go
···1111import (
1212 "context"
1313 "fmt"
1414+ "net/http"
1415 "os"
1516 "path/filepath"
1617 "strings"
···926927}
927928928929// --- Helpers ---
930930+931931+// TestIntegration_SecurityFeatures sends an email with a real attachment, a
932932+// disguised script (.sh content saved as .png), a callout, and an HTML signature.
933933+// The email arrives in the test inbox so you can verify attachment safety live in neomd.
934934+func TestIntegration_SecurityFeatures(t *testing.T) {
935935+ env := loadEnv(t)
936936+ cli := env.imapClient()
937937+ defer cli.Close()
938938+939939+ subject := uniqueSubject("security-attach-callout")
940940+941941+ // Create temp dir for attachments
942942+ dir := t.TempDir()
943943+944944+ // 1. Real text attachment
945945+ realDoc := filepath.Join(dir, "meeting-notes.txt")
946946+ 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 {
947947+ t.Fatal(err)
948948+ }
949949+950950+ // 2. Disguised script: bash content saved as .png
951951+ fakeImg := filepath.Join(dir, "totally-legit-photo.png")
952952+ if err := os.WriteFile(fakeImg, []byte("#!/bin/bash\necho 'this is not a real image'\n"), 0600); err != nil {
953953+ t.Fatal(err)
954954+ }
955955+956956+ // Body with callout
957957+ body := `# Security Features Test
958958+959959+This email tests neomd's security features.
960960+961961+> [!warning] Attachment Safety Test
962962+> This email contains a disguised script (bash content saved as .png) alongside
963963+> a real text document. neomd should block the fake image from auto-opening.
964964+965965+## Attachments included:
966966+1. **meeting-notes.txt** — real text file (safe)
967967+2. **totally-legit-photo.png** — actually a bash script (should be blocked by magic-byte check)
968968+969969+*sent from [neomd](https://neomd.ssp.sh)*`
970970+971971+ err := smtp.Send(env.smtpConfig(), env.user+", simon@ssp.sh", "", "", subject, body, []string{realDoc, fakeImg})
972972+ if err != nil {
973973+ t.Fatalf("Send: %v", err)
974974+ }
975975+976976+ email := waitForEmail(t, cli, "INBOX", subject, 60*time.Second)
977977+ defer cleanupEmail(t, cli, "INBOX", email.UID)
978978+979979+ // Fetch and verify
980980+ markdown, rawHTML, _, attachments, _, spyPixels, err := cli.FetchBody(context.Background(), "INBOX", email.UID)
981981+ if err != nil {
982982+ t.Fatalf("FetchBody: %v", err)
983983+ }
984984+985985+ // Verify callout rendered in HTML
986986+ if !strings.Contains(rawHTML, "callout") || !strings.Contains(rawHTML, "warning") {
987987+ t.Logf("HTML (truncated): %s", truncate(rawHTML, 300))
988988+ t.Error("expected callout markup in HTML body")
989989+ }
990990+991991+ // Verify at least 2 attachments arrived
992992+ if len(attachments) < 2 {
993993+ t.Errorf("expected at least 2 attachments, got %d", len(attachments))
994994+ }
995995+ for _, a := range attachments {
996996+ t.Logf("Attachment: %s (%s, %d bytes)", a.Filename, a.ContentType, len(a.Data))
997997+ }
998998+999999+ // Verify the disguised script would be caught by magic-byte check
10001000+ for _, a := range attachments {
10011001+ if strings.Contains(a.Filename, "totally-legit") {
10021002+ detected := http.DetectContentType(a.Data)
10031003+ if strings.HasPrefix(detected, "image/") {
10041004+ t.Errorf("disguised script detected as image — magic bytes failed: %s", detected)
10051005+ } else {
10061006+ t.Logf("Correctly detected disguised script as: %s (not image/)", detected)
10071007+ }
10081008+ }
10091009+ }
10101010+10111011+ t.Logf("Spy pixels: %d (expected 0 for self-sent)", spyPixels.Count)
10121012+ t.Logf("Markdown preview: %s", truncate(markdown, 200))
10131013+}
10141014+10151015+// TestIntegration_BrowserSanitization sends an email with inline script tags,
10161016+// an iframe, and an event handler to verify that SanitizeForBrowser blocks them
10171017+// when opened with O in neomd. Also sent to simon@ssp.sh for live inspection.
10181018+func TestIntegration_BrowserSanitization(t *testing.T) {
10191019+ env := loadEnv(t)
10201020+ cli := env.imapClient()
10211021+ defer cli.Close()
10221022+10231023+ subject := uniqueSubject("browser-csp-test")
10241024+10251025+ body := `# Browser Sanitization Test
10261026+10271027+Open this email with **O** in neomd to test CSP protection.
10281028+10291029+## What to check in the browser:
10301030+10311031+1. The **script alert should NOT fire** — if you see a popup saying "XSS worked", the CSP failed
10321032+2. The **iframe should NOT load** — you should see an empty space, not an embedded page
10331033+3. The **image should load normally** — the neomd logo below should be visible
10341034+4. The **onload handler should NOT fire** — no "event handler" alert
10351035+10361036+If everything works: you see the image, no popups, no iframe content.
10371037+10381038+
10391039+10401040+*sent from [neomd](https://neomd.ssp.sh) — CSP test*`
10411041+10421042+ // Send normally — the HTML will contain the markdown-rendered content.
10431043+ // To also test raw HTML injection, we build a custom message with injected tags.
10441044+ raw, err := smtp.BuildMessage(env.from, env.user+", simon@ssp.sh", "", subject, body, nil, "")
10451045+ if err != nil {
10461046+ t.Fatalf("BuildMessage: %v", err)
10471047+ }
10481048+10491049+ // Inject malicious HTML into the raw MIME before sending.
10501050+ // These should all be blocked by the CSP when opened with O.
10511051+ injection := `<script>alert('XSS worked! CSP is broken!')</script>` +
10521052+ `<iframe src="https://example.com" width="400" height="200"></iframe>` +
10531053+ `<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">` +
10541054+ `<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>`
10551055+10561056+ // Insert injection before </body> in the HTML part
10571057+ rawStr := string(raw)
10581058+ if idx := strings.LastIndex(rawStr, "</body>"); idx >= 0 {
10591059+ rawStr = rawStr[:idx] + injection + rawStr[idx:]
10601060+ }
10611061+10621062+ allRecipients := []string{env.user, "simon@ssp.sh"}
10631063+ if err := smtp.SendRaw(env.smtpConfig(), allRecipients, []byte(rawStr)); err != nil {
10641064+ t.Fatalf("SendRaw: %v", err)
10651065+ }
10661066+10671067+ email := waitForEmail(t, cli, "INBOX", subject, 60*time.Second)
10681068+ defer cleanupEmail(t, cli, "INBOX", email.UID)
10691069+10701070+ _, rawHTML, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID)
10711071+ if err != nil {
10721072+ t.Fatalf("FetchBody: %v", err)
10731073+ }
10741074+10751075+ // Verify the malicious content is present in raw HTML (it should be — CSP blocks execution, not content)
10761076+ if !strings.Contains(rawHTML, "<script>") {
10771077+ t.Error("expected <script> tag in raw HTML (CSP should block execution, not strip content)")
10781078+ }
10791079+ if !strings.Contains(rawHTML, "<iframe") {
10801080+ t.Error("expected <iframe> tag in raw HTML (CSP should block loading, not strip content)")
10811081+ }
10821082+10831083+ t.Log("Email sent with script/iframe/onload injection.")
10841084+ t.Log("Open with O in neomd — you should see the image and green text, but NO popups and NO iframe content.")
10851085+}
92910869301087func extractUser(from string) string {
9311088 if i := strings.Index(from, "<"); i >= 0 {
+4
internal/render/html.go
···6868// SanitizeForBrowser injects a restrictive CSP into raw HTML to block scripts
6969// and frames while allowing images. For use when opening untrusted email HTML.
7070func SanitizeForBrowser(html string) string {
7171+ // Skip if CSP already present (e.g. from htmlTemplate via ToHTML).
7272+ if strings.Contains(html, "Content-Security-Policy") {
7373+ return html
7474+ }
7175 // Insert after <head> if present, otherwise prepend.
7276 lower := strings.ToLower(html)
7377 if idx := strings.Index(lower, "<head>"); idx >= 0 {
+10-4
internal/ui/model.go
···142142 attachOpenDoneMsg struct {
143143 path string
144144 err error
145145- dangerous bool // true if file was saved but NOT auto-opened due to risky extension
145145+ dangerous bool // true if file was saved but NOT auto-opened
146146+ reason string // why it was flagged (shown in status bar)
146147 }
147148 emlDownloadedMsg struct {
148149 path string
···19081909 m.status = "Attachment error: " + msg.err.Error()
19091910 m.isError = true
19101911 } else if msg.dangerous {
19111911- m.status = "Saved to " + msg.path + " — not auto-opened (dangerous file type)"
19121912+ m.status = "Saved to " + msg.path + " — not auto-opened (" + msg.reason + ")"
19121913 m.isError = true
19131914 } else {
19141915 m.status = "Saved to " + msg.path + " — opening…"
···35253526 }
35263527 ext := strings.ToLower(filepath.Ext(base))
35273528 if dangerousExts[ext] {
35283528- return attachOpenDoneMsg{path: dst, dangerous: true}
35293529+ return attachOpenDoneMsg{path: dst, dangerous: true, reason: fmt.Sprintf("executable extension %s", ext)}
35293530 }
35303531 // Magic-byte check: detect actual content type from file bytes.
35313532 // Flags mismatches like a .sh disguised as .png (detected as text/plain, not image/png).
35323533 if detected := http.DetectContentType(a.Data); isMimeMismatch(ext, detected) {
35333533- return attachOpenDoneMsg{path: dst, dangerous: true}
35343534+ // Extract just the MIME type without params for the message
35353535+ mimeType := detected
35363536+ if i := strings.IndexByte(mimeType, ';'); i >= 0 {
35373537+ mimeType = strings.TrimSpace(mimeType[:i])
35383538+ }
35393539+ return attachOpenDoneMsg{path: dst, dangerous: true, reason: fmt.Sprintf("content is %s, not %s", mimeType, ext)}
35343540 }
35353541 _ = exec.Command("xdg-open", dst).Start()
35363542 return attachOpenDoneMsg{path: dst}