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.

preview inline images that have been sent to me (not only the one I send)

sspaeti 8c870adb ea3b45a1

+103 -1
+2
CHANGELOG.md
··· 15 15 - **`\Answered` flag on reply** — after sending a reply, the original email is automatically marked as `\Answered` on the IMAP server 16 16 - **Conversation thread view (`T` / `:thread`)** — press `T` from inbox list or reader to see the full conversation across folders (Inbox, Sent, Archive, Waiting, Work, etc.); searches by normalized subject + participant overlap; displays in a temporary "Thread" tab with `[Folder]` prefix and `│`/`╰` threading connectors; esc returns to previous view 17 17 - **Custom folder support (`work`)** — optional `work = "Work"` in `[folders]` config; add `"work"` to `tab_order` to show as a tab; `gb` to go, `Mb` to move; auto-created on first run if configured; included in Everything, Search, and conversation views 18 + - **Inline images in browser preview** — pressing `O` to open an email in the browser now shows inline images from other senders; `cid:` references are rewritten to temp files so the browser can display them; previously only your own sent emails rendered images correctly 19 + - **`compose_editor` config option** — optional `compose_editor` in `[ui]` to use a different editor for compose/reply/forward (e.g. `"nvim --appname nvim-wp"`); defaults to `$EDITOR` / `nvim` 18 20 19 21 # 2026-04-05 20 22 - **OAuth2 authentication** ([#3](https://github.com/ssp-data/neomd/pull/3), thanks [@notthatjesus](https://github.com/notthatjesus)) — accounts can set `auth_type = "oauth2"` with `oauth2_client_id`, `oauth2_client_secret`, `oauth2_issuer_url`, and `oauth2_scopes` instead of a password; on first launch neomd opens the browser for the authorization code flow, persists the token to `~/.config/neomd/tokens/<account>.json`, and refreshes it automatically; works with Gmail, Office365, and any OIDC-discoverable provider via XOAUTH2 over IMAP and SMTP; password auth paths unchanged for existing accounts
+2
docs/reading.md
··· 27 27 28 28 **Inline / attached images** (e.g. screenshots pasted into an email) are listed in the reader header: `Attach: [1] screenshot.png [2] report.pdf`. Press `1`–`9` to download to `~/Downloads/` and open with `xdg-open`. Inline images also show `[Image: filename.png]` placeholders at their position in the body text. 29 29 30 + 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). 31 + 30 32 ## Links 31 33 32 34 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.
+4 -1
internal/imap/client.go
··· 28 28 type Attachment struct { 29 29 Filename string // from Content-Disposition filename or Content-Type name param 30 30 ContentType string // e.g. "application/pdf" 31 + ContentID string // Content-ID without angle brackets (for inline cid: references) 31 32 Data []byte 32 33 } 33 34 ··· 986 987 } 987 988 } 988 989 // Map Content-ID → filename so we can inject alt text into the HTML 989 - if cid := strings.Trim(h.Get("Content-ID"), "<>"); cid != "" { 990 + cid := strings.Trim(h.Get("Content-ID"), "<>") 991 + if cid != "" { 990 992 cidToName[cid] = filename 991 993 } 992 994 data, _ := io.ReadAll(p.Body) 993 995 attachments = append(attachments, Attachment{ 994 996 Filename: filename, 995 997 ContentType: ct, 998 + ContentID: cid, 996 999 Data: data, 997 1000 }) 998 1001 continue
+76
internal/imap/client_test.go
··· 209 209 } 210 210 } 211 211 212 + func TestParseBody_InlineImageContentID(t *testing.T) { 213 + // Construct a minimal multipart/related MIME message with an inline image. 214 + boundary := "----=_Part_123" 215 + raw := "MIME-Version: 1.0\r\n" + 216 + "Content-Type: multipart/related; boundary=\"" + boundary + "\"\r\n" + 217 + "\r\n" + 218 + "--" + boundary + "\r\n" + 219 + "Content-Type: text/html; charset=utf-8\r\n" + 220 + "\r\n" + 221 + "<html><body><p>Hello</p><img src=\"cid:img001@neomd\"></body></html>\r\n" + 222 + "--" + boundary + "\r\n" + 223 + "Content-Type: image/png; name=\"photo.png\"\r\n" + 224 + "Content-Disposition: inline; filename=\"photo.png\"\r\n" + 225 + "Content-ID: <img001@neomd>\r\n" + 226 + "Content-Transfer-Encoding: base64\r\n" + 227 + "\r\n" + 228 + "iVBORw0KGgo=\r\n" + 229 + "--" + boundary + "--\r\n" 230 + 231 + _, _, _, attachments := parseBody([]byte(raw)) 232 + 233 + if len(attachments) == 0 { 234 + t.Fatal("expected at least 1 attachment, got 0") 235 + } 236 + 237 + found := false 238 + for _, a := range attachments { 239 + if a.ContentID == "img001@neomd" { 240 + found = true 241 + if a.Filename != "photo.png" { 242 + t.Errorf("Filename = %q, want %q", a.Filename, "photo.png") 243 + } 244 + if !strings.HasPrefix(a.ContentType, "image/") { 245 + t.Errorf("ContentType = %q, want image/*", a.ContentType) 246 + } 247 + } 248 + } 249 + if !found { 250 + cids := make([]string, len(attachments)) 251 + for i, a := range attachments { 252 + cids[i] = a.ContentID 253 + } 254 + t.Errorf("no attachment with ContentID 'img001@neomd', got CIDs: %v", cids) 255 + } 256 + } 257 + 258 + func TestParseBody_NoContentID(t *testing.T) { 259 + // Regular attachment without Content-ID should have empty ContentID. 260 + boundary := "----=_Part_456" 261 + raw := "MIME-Version: 1.0\r\n" + 262 + "Content-Type: multipart/mixed; boundary=\"" + boundary + "\"\r\n" + 263 + "\r\n" + 264 + "--" + boundary + "\r\n" + 265 + "Content-Type: text/plain; charset=utf-8\r\n" + 266 + "\r\n" + 267 + "Hello world\r\n" + 268 + "--" + boundary + "\r\n" + 269 + "Content-Type: application/pdf; name=\"doc.pdf\"\r\n" + 270 + "Content-Disposition: attachment; filename=\"doc.pdf\"\r\n" + 271 + "Content-Transfer-Encoding: base64\r\n" + 272 + "\r\n" + 273 + "JVBERi0=\r\n" + 274 + "--" + boundary + "--\r\n" 275 + 276 + _, _, _, attachments := parseBody([]byte(raw)) 277 + 278 + if len(attachments) == 0 { 279 + t.Fatal("expected at least 1 attachment, got 0") 280 + } 281 + for _, a := range attachments { 282 + if a.Filename == "doc.pdf" && a.ContentID != "" { 283 + t.Errorf("regular attachment should have empty ContentID, got %q", a.ContentID) 284 + } 285 + } 286 + } 287 + 212 288 func TestConnect_RefusesUnencrypted(t *testing.T) { 213 289 c := &Client{ 214 290 cfg: Config{
+19
internal/ui/model.go
··· 2398 2398 } 2399 2399 } 2400 2400 2401 + // Save inline image attachments (Content-ID) to temp files and rewrite 2402 + // cid: references to file:// URLs so the browser can display them. 2403 + var tmpImages []string 2404 + for _, a := range m.openAttachments { 2405 + if a.ContentID == "" || len(a.Data) == 0 { 2406 + continue 2407 + } 2408 + imgPath := filepath.Join(neomdTempDir(), "cid-"+a.ContentID+"-"+a.Filename) 2409 + if err := os.WriteFile(imgPath, a.Data, 0600); err != nil { 2410 + continue 2411 + } 2412 + tmpImages = append(tmpImages, imgPath) 2413 + // Replace cid:XYZ with file:///path (case-insensitive) 2414 + htmlBody = strings.ReplaceAll(htmlBody, "cid:"+a.ContentID, "file://"+imgPath) 2415 + } 2416 + 2401 2417 f, err := os.CreateTemp(neomdTempDir(), "neomd-view-*.html") 2402 2418 if err != nil { 2403 2419 m.status = "open: " + err.Error() ··· 2422 2438 go func() { 2423 2439 time.Sleep(15 * time.Second) 2424 2440 os.Remove(tmpPath) 2441 + for _, p := range tmpImages { 2442 + os.Remove(p) 2443 + } 2425 2444 }() 2426 2445 return nil 2427 2446 }