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 feature to download emails as EML with leader+d

+124 -3
+1
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 3 # 2026-04-23 4 + - **Download raw email source (`space+d` in reader)** — saves the full raw MIME source as `.eml` to `~/Downloads/` with filename `neomd-YYYYMMDD-<subject>.eml`; useful for archiving, debugging email headers, or importing into other clients; status bar shows download progress and completion; filenames deduplicated automatically 4 5 - **Listmonk newsletter integration** — send newsletters to subscribers by composing an email to a virtual trigger address (e.g. `listmonk@ssp.sh`); neomd intercepts the send and creates a scheduled campaign in [Listmonk](https://listmonk.app) via its REST API instead of delivering via SMTP; configure multiple trigger addresses in `[[listmonk.triggers]]` to target different subscriber lists (newsletter, book, all); pre-send screen shows "Newsletter via Listmonk" with target list IDs and schedule delay; campaigns are created as draft then set to `scheduled` status with configurable delay (default 30 minutes); authentication via HTTP Basic Auth with environment variable expansion for API token; new self-contained `internal/listmonk/` package with full test coverage (httptest mocks); documented in `docs/integrations/listmonk.md` 5 6 6 7 # 2026-04-21
+1
docs/content/docs/keybindings.md
··· 96 96 |-----|--------| 97 97 | `<space>1 … <space>9` | jump to folder tab by number (Inbox=1, ToScreen=2, …) | 98 98 | `<space>/` | IMAP search ALL emails on server (From + Subject) | 99 + | `<space>d (reader)` | download raw email source (.eml) to ~/Downloads | 99 100 | `<space>w` | show welcome screen | 100 101 101 102
+6
docs/content/docs/reading.md
··· 52 52 53 53 Press `1`–`9` to download attachment N to `~/Downloads/` and open it with `xdg-open`. Filenames are deduplicated automatically if a file already exists. 54 54 55 + ## Download Raw Email Source 56 + 57 + Press `space` then `d` in the reader to download the full raw email source (`.eml` file) to `~/Downloads/`. The file is named `neomd-YYYYMMDD-<subject>.eml` using the email's date and sanitized subject line. 58 + 59 + This is useful for archiving emails, debugging headers, or importing into other email clients. The status bar shows a green confirmation when the download completes. 60 + 55 61 ## Threaded Inbox 56 62 57 63 Related emails are automatically grouped together in the inbox list. Threads are detected using a hybrid approach:
+33
internal/imap/client.go
··· 758 758 return markdown, rawHTML, webURL, attachments, references, err 759 759 } 760 760 761 + // FetchRaw fetches the full raw MIME source (EML) for a single message. 762 + func (c *Client) FetchRaw(ctx context.Context, folder string, uid uint32) ([]byte, error) { 763 + if ctx == nil { 764 + ctx = context.Background() 765 + } 766 + var raw []byte 767 + err := c.withConn(ctx, func(conn *imapclient.Client) error { 768 + if err := c.selectMailbox(folder); err != nil { 769 + return err 770 + } 771 + 772 + var fetchSet imap.UIDSet 773 + fetchSet.AddNum(imap.UID(uid)) 774 + 775 + msgs, err := conn.Fetch(fetchSet, &imap.FetchOptions{ 776 + UID: true, 777 + BodySection: []*imap.FetchItemBodySection{{Peek: true}}, 778 + }).Collect() 779 + if err != nil { 780 + return fmt.Errorf("FETCH raw uid=%d: %w", uid, err) 781 + } 782 + if len(msgs) == 0 { 783 + return fmt.Errorf("message uid=%d not found in %s", uid, folder) 784 + } 785 + 786 + if len(msgs[0].BodySection) > 0 { 787 + raw = msgs[0].BodySection[0].Bytes 788 + } 789 + return nil 790 + }) 791 + return raw, err 792 + } 793 + 761 794 // MoveMessage moves uid from src to dst using the IMAP MOVE command (RFC 6851). 762 795 // Returns the UID assigned at the destination (may differ from src UID on some 763 796 // servers). Falls back to the original uid if the server does not report UIDPLUS
+1
internal/ui/keys.go
··· 71 71 {"Leader Key Mappings (space prefix)", [][2]string{ 72 72 {"<space>1 … <space>9", "jump to folder tab by number (Inbox=1, ToScreen=2, …)"}, 73 73 {"<space>/", "IMAP search ALL emails on server (From + Subject)"}, 74 + {"<space>d (reader)", "download raw email source (.eml) to ~/Downloads"}, 74 75 {"<space>w", "show welcome screen"}, 75 76 }}, 76 77 {"Sort (, prefix)", [][2]string{
+80 -1
internal/ui/model.go
··· 131 131 path string 132 132 err error 133 133 } 134 + emlDownloadedMsg struct { 135 + path string 136 + err error 137 + } 134 138 editorDoneMsg struct { 135 139 to, cc, bcc, from, subject, body string 136 140 err error ··· 1770 1774 } 1771 1775 return m, nil 1772 1776 1777 + case emlDownloadedMsg: 1778 + if msg.err != nil { 1779 + m.status = "Download error: " + msg.err.Error() 1780 + m.isError = true 1781 + } else { 1782 + m.status = "Saved EML to " + msg.path 1783 + m.isError = false 1784 + } 1785 + return m, nil 1786 + 1773 1787 case saveDraftDoneMsg: 1774 1788 if msg.err != nil { 1775 1789 m.status = "Draft error: " + msg.err.Error() ··· 2948 2962 m.status = "link number (11-99): l__" 2949 2963 return m, nil 2950 2964 } 2965 + // space + d = download raw EML source 2966 + if key == "d" { 2967 + m.status = "Downloading EML…" 2968 + m.isError = false 2969 + return m, m.downloadEMLCmd() 2970 + } 2951 2971 // Not a digit or 'l' — fall through 2952 2972 case "l": // l + first digit (waiting for second digit) 2953 2973 if len(key) == 1 && key >= "0" && key <= "9" { ··· 3244 3264 } 3245 3265 } 3246 3266 3267 + // downloadEMLCmd fetches the raw MIME source and saves it as .eml to ~/Downloads. 3268 + func (m Model) downloadEMLCmd() tea.Cmd { 3269 + e := m.openEmail 3270 + if e == nil { 3271 + return nil 3272 + } 3273 + cli := m.imapCli() 3274 + folder := e.Folder 3275 + uid := e.UID 3276 + subject := e.Subject 3277 + emailDate := e.Date 3278 + return func() tea.Msg { 3279 + raw, err := cli.FetchRaw(nil, folder, uid) 3280 + if err != nil { 3281 + return emlDownloadedMsg{err: err} 3282 + } 3283 + home, err := os.UserHomeDir() 3284 + if err != nil { 3285 + return emlDownloadedMsg{err: err} 3286 + } 3287 + dir := filepath.Join(home, "Downloads") 3288 + if err := os.MkdirAll(dir, 0755); err != nil { 3289 + return emlDownloadedMsg{err: fmt.Errorf("create Downloads: %w", err)} 3290 + } 3291 + // Sanitize subject for filename 3292 + safe := strings.Map(func(r rune) rune { 3293 + if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' { 3294 + return '_' 3295 + } 3296 + return r 3297 + }, subject) 3298 + if len(safe) > 80 { 3299 + safe = safe[:80] 3300 + } 3301 + if safe == "" { 3302 + safe = "email" 3303 + } 3304 + datePart := emailDate.Format("20060102") 3305 + base := fmt.Sprintf("neomd-%s-%s.eml", datePart, safe) 3306 + dst := filepath.Join(dir, base) 3307 + if _, err := os.Stat(dst); err == nil { 3308 + for i := 1; ; i++ { 3309 + dst = filepath.Join(dir, fmt.Sprintf("neomd-%s-%s_%d.eml", datePart, safe, i)) 3310 + if _, err := os.Stat(dst); os.IsNotExist(err) { 3311 + break 3312 + } 3313 + } 3314 + } 3315 + if err := os.WriteFile(dst, raw, 0644); err != nil { 3316 + return emlDownloadedMsg{err: fmt.Errorf("save EML: %w", err)} 3317 + } 3318 + return emlDownloadedMsg{path: dst} 3319 + } 3320 + } 3321 + 3247 3322 // extractWebVersionURL looks for the "view in browser" / "read online" link 3248 3323 // that newsletter platforms insert near the top of every HTML email. 3249 3324 // Searches only the first 3000 bytes (the link is always in the preheader). ··· 4357 4432 b.WriteString(m.reader.View()) 4358 4433 } 4359 4434 isDraft := m.openEmail != nil && m.openEmail.Folder == m.cfg.Folders.Drafts 4360 - b.WriteString("\n" + readerHelp(isDraft, len(m.openLinks) > 0)) 4435 + if m.status != "" { 4436 + b.WriteString("\n" + statusBar(m.status, m.isError)) 4437 + } else { 4438 + b.WriteString("\n" + readerHelp(isDraft, len(m.openLinks) > 0)) 4439 + } 4361 4440 return b.String() 4362 4441 } 4363 4442
+1 -1
internal/ui/reader.go
··· 139 139 if isDraft { 140 140 keys = append(keys, "E draft") 141 141 } 142 - keys = append(keys, "o w3m", "O browser", "ctrl+o web", "1-9 attach") 142 + keys = append(keys, "o w3m", "O browser", "ctrl+o web", "1-9 attach", "space+d eml") 143 143 if hasLinks { 144 144 keys = append(keys, "space+1-0 links", "space+l11-99 links 11+") 145 145 }
+1 -1
scripts/sync-readme-to-docs.sh
··· 68 68 -e 's|docs/screener\.md|screener|g' \ 69 69 -e 's|docs/sending\.md|sending|g' \ 70 70 -e 's|docs/reading\.md|reading|g' \ 71 - -e 's|images/|/images/|g' \ 71 + -e 's|docs/static/images/|/images/|g' \ 72 72 >> "$DOCS_OVERVIEW" 73 73 74 74 echo "✅ Synced README.md → docs/content/docs/_index.md"