A minimal email TUI where you read with Markdown and write in Neovim.
neomd.ssp.sh/docs
email
markdown
neovim
tui
1# Security
2
3neomd handles IMAP/SMTP credentials and email content. This document explains what is stored, where, and how it is protected. Links to the relevant source files are included so you can verify the implementation directly.
4
5---
6
7## Credentials
8
9| What | Where | Permission |
10|------|-------|-----------|
11| IMAP/SMTP password, username | `~/.config/neomd/config.toml` | `0600` (user-readable only) |
12| Config directory | `~/.config/neomd/` | `0700` |
13
14- The config file is **created once** at `0600` and **never written back** after that — neomd only reads it at startup.
15- Passwords can be stored as plain strings or as environment variable references (`$MY_PASS` / `${MY_PASS}`), so they never need to appear in the file at all.
16- Credentials **never appear** in error messages, the status bar, or any log output.
17
18**Code:** [`internal/config/config.go`](https://github.com/ssp-data/neomd/blob/main/internal/config/config.go) — `Load()`, `writeDefault()`, `expandEnv()`
19
20---
21
22## Network connections
23
24| Protocol | Port | How |
25|----------|------|-----|
26| IMAP | 993 | `imapclient.DialTLS` — TLS enforced |
27| IMAP | 143 | `imapclient.DialStartTLS` — STARTTLS negotiated |
28| Any other port | — | **Refused** — neomd errors out rather than connect unencrypted |
29| SMTP | 465 | Explicit `tls.Dial` before any auth |
30| SMTP | 587 | Go stdlib `PlainAuth` guarantee — refuses credentials over non-TLS (except localhost); note: this is a stdlib property, not enforced by neomd code |
31
32**Code:** [`internal/imap/client.go`](https://github.com/ssp-data/neomd/blob/main/internal/imap/client.go) — `connect()` · [`internal/smtp/sender.go`](https://github.com/ssp-data/neomd/blob/main/internal/smtp/sender.go) — `Send()`
33
34---
35
36## Screener lists (sensitive email addresses)
37
38The five screener lists — `screened_in.txt`, `screened_out.txt`, `feed.txt`, `papertrail.txt`, `spam.txt` — contain sender email addresses you have explicitly classified. These are **stored outside neomd**, at paths you configure:
39
40```toml
41[screener]
42screened_in = "~/.dotfiles/neomd/.lists/screened_in.txt"
43screened_out = "~/.dotfiles/neomd/.lists/screened_out.txt"
44feed = "~/.dotfiles/neomd/.lists/feed.txt"
45papertrail = "~/.dotfiles/neomd/.lists/papertrail.txt"
46spam = "~/.dotfiles/neomd/.lists/spam.txt"
47```
48
49neomd never chooses these paths — you control them. When neomd appends or rewrites a list file, it always uses mode `0600`. This means:
50- The lists can live alongside an existing neomutt/mutt setup and be shared with it.
51- They are under your own dotfiles/version control — or not — entirely your choice.
52- neomd has no server, no sync, and no telemetry; the files never leave your machine.
53
54**Code:** [`internal/screener/screener.go`](https://github.com/ssp-data/neomd/blob/main/internal/screener/screener.go) — `appendLine()`, `removeFromList()`
55
56---
57
58## Temporary files
59
60When composing, reading in w3m, or opening in a browser, neomd writes a temporary file via `os.CreateTemp` (default mode `0600`) and registers a `defer os.Remove` so it is deleted immediately after use. The compose temp file (`neomd-*.md`) is deleted whether you send or abort.
61
62**Code:** [`internal/ui/model.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/model.go) — `openInBrowser()`, `openInW3m()`, `openInEditor()` · [`internal/editor/editor.go`](https://github.com/ssp-data/neomd/blob/main/internal/editor/editor.go) — `Compose()`
63
64---
65
66## Command history
67
68The `:` command history is written to `~/.cache/neomd/cmd_history` (cache dir, `0600`). It stores **command names only** (e.g. `screen`, `reload`) — never email addresses, subjects, or credentials. The cache directory is intentionally outside `~/.config` so it is never picked up by dotfile version control.
69
70**Code:** [`internal/config/config.go`](https://github.com/ssp-data/neomd/blob/main/internal/config/config.go) — `HistoryPath()`
71
72---
73
74## URL handling
75
76All 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
84neomd automatically detects and blocks tracking pixels using the same two-layer approach as [HEY](https://www.hey.com/features/spy-pixel-blocker/).
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- **Layer 1 — Curated denylist:** 150+ tracking services with URL pattern matching (`internal/imap/tracker_list.go`). Sourced from [Simplify](https://github.com/leggett/simplify-trackers) (BSD-3-Clause), [LeaveMeAlone](https://github.com/leavemealone-app/email-trackers) (CC-BY 3.0), and [DHH's original HEY list](https://gist.github.com/dhh/360f4dc7ddbce786f8e82b97cdad9d20) (MIT). Covers major ESPs (Mailchimp, HubSpot, SendGrid, ConvertKit, Substack), sales trackers (Superhuman, Streak, Yesware), and brand trackers (Amazon, Apple, Facebook, LinkedIn, Google, GitHub). When matched, the service name is attributed in the UI.
89- **Layer 2 — Generic 1×1 pixel heuristic:** catches custom/branded tracking domains not on the denylist by detecting `<img>` tags with empty `alt` AND both dimensions 0–1, or CSS hiding (`display:none`). Layout spacers (e.g. 1×50) are not flagged.
90- 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`.
91- The reader header shows `° N spy pixel(s) blocked (ServiceName)` with tracker attribution.
92- 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.
93
94**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.
95
96**Code:** [`internal/imap/tracker_list.go`](https://github.com/ssp-data/neomd/blob/main/internal/imap/tracker_list.go) — denylist (150+ services) · [`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
97
98---
99
100## Attachment safety
101
102Attachments are saved to `~/Downloads/` and opened with `xdg-open`. Two layers of protection prevent accidental execution of malicious files:
103
1041. **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.
105
1062. **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.
107
108| Scenario | Extension | Magic bytes | Result |
109|---|---|---|---|
110| `malware.sh` | `.sh` → blocked | — | Saved, not opened |
111| `malware.sh` → `photo.png` | `.png` → safe | `text/plain` ≠ `image/` | Saved, not opened |
112| Real `photo.png` | `.png` → safe | `image/png` ✓ | Opened normally |
113
114**Code:** [`internal/ui/model.go`](https://github.com/ssp-data/neomd/blob/main/internal/ui/model.go) — `dangerousExts`, `isMimeMismatch()`, `downloadOpenAttachmentCmd()`
115
116---
117
118## Screener as a security layer
119
120The [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.
121
122This 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.
123
124In 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.
125
126**Code:** [`internal/screener/screener.go`](https://github.com/ssp-data/neomd/blob/main/internal/screener/screener.go)
127
128---
129
130## Reporting a vulnerability
131
132Open a [GitHub issue](https://github.com/ssp-data/neomd/issues) or email the maintainer directly (address in the commit history). neomd is a personal tool with no release SLA, but security reports are taken seriously and addressed promptly.
133
134## Disclaimer
135This security audit is instructed by me, checking all relevant folders, but executed by Claude Code.