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.

small changes: `r` now works from inbox list view; `# [neomd: from: ...]` shown in editor; `x` in pre-send discards the email

sspaeti 1662d502 57852425

+49 -9
+4 -1
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 - ## 2026-04-01 3 + # 2026-04-02 4 + - **Auto From on reply** — replying auto-selects the From address that matches the email's To/CC field (e.g. email sent to `simon@domain.com` replies from `simon@domain.com`); `r` now works from inbox list view; `# [neomd: from: ...]` shown in editor; `x` in pre-send discards the email 5 + 6 + # 2026-04-01 4 7 - **Threaded inbox** — related emails are automatically grouped in the inbox list with a Twitter-style vertical connector line (`│`/`╰`); threads detected via `In-Reply-To`/`Message-ID` IMAP envelope headers with a reply-prefix subject fallback (only emails with `Re:`, `AW:`, `Fwd:` etc. are grouped by subject — recurring notifications/invoices stay separate); newest reply on top, root at bottom; threads sorted by most recent email so active conversations float to the top 5 8 - **Clickable tabs** — folder tabs in the top bar are clickable with the mouse; click any tab to switch folders 6 9 - **Spell check in pre-send (`s`)** — opens nvim with spell checking enabled (`en_us` + `de`), cursor jumps to the first misspelled word; use `]s`/`[s` to navigate errors, `z=` for suggestions, `zg` to add to dictionary; corrected body flows back to pre-send
+9 -3
internal/editor/editor.go
··· 99 99 return s 100 100 } 101 101 102 - // ReplyPrelude builds a quote block for replies. cc may be empty. 103 - func ReplyPrelude(to, cc, subject, originalFrom, originalBody string) string { 102 + // ReplyPrelude builds a quote block for replies. cc and from may be empty. 103 + func ReplyPrelude(to, cc, subject, from, originalFrom, originalBody string) string { 104 104 s := fmt.Sprintf("# [neomd: to: %s]\n", to) 105 105 if cc != "" { 106 106 s += fmt.Sprintf("# [neomd: cc: %s]\n", cc) 107 107 } 108 + if from != "" { 109 + s += fmt.Sprintf("# [neomd: from: %s]\n", from) 110 + } 108 111 s += fmt.Sprintf("# [neomd: subject: Re: %s]\n\n---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n", 109 112 subject, originalFrom, quoteLines(originalBody)) 110 113 return s ··· 112 115 113 116 // ForwardPrelude builds a quoted forward block. The To field is left empty for 114 117 // the user to fill in. 115 - func ForwardPrelude(subject, originalFrom, originalDate, originalTo, originalBody string) string { 118 + func ForwardPrelude(subject, from, originalFrom, originalDate, originalTo, originalBody string) string { 116 119 s := "# [neomd: to: ]\n" 120 + if from != "" { 121 + s += fmt.Sprintf("# [neomd: from: %s]\n", from) 122 + } 117 123 if !strings.HasPrefix(strings.ToLower(subject), "fwd:") { 118 124 subject = "Fwd: " + subject 119 125 }
+36 -5
internal/ui/model.go
··· 328 328 // Screener operations (I/O/F/P/$) are not undoable — they also modify .txt files. 329 329 undoStack [][]undoMove 330 330 331 - // Forward: when true, bodyLoadedMsg launches forward editor instead of reader 332 - pendingForward bool 331 + // Forward/Reply: when true, bodyLoadedMsg launches the action instead of reader 332 + pendingForward bool 333 + pendingReply bool 334 + pendingReplyAll bool 333 335 334 336 // Chord prefix: "g" or "M" while waiting for second key 335 337 pendingKey string ··· 1110 1112 m.pendingForward = false 1111 1113 return m.launchForwardCmd() 1112 1114 } 1115 + if m.pendingReply { 1116 + m.pendingReply = false 1117 + return m.launchReplyCmd() 1118 + } 1119 + if m.pendingReplyAll { 1120 + m.pendingReplyAll = false 1121 + return m.launchReplyAllCmd() 1122 + } 1113 1123 m.openLinks = extractLinks(msg.body) 1114 1124 _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.openLinks, m.cfg.UI.Theme, m.width) 1115 1125 m.state = stateReading ··· 1766 1776 m.loading = true 1767 1777 return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) 1768 1778 1779 + case "r": 1780 + e := selectedEmail(m.inbox) 1781 + if e == nil { 1782 + return m, nil 1783 + } 1784 + // Pre-select the correct From address before fetching body. 1785 + if idx := m.matchFromIndex(e.To, e.CC); idx >= 0 { 1786 + m.presendFromI = idx 1787 + } 1788 + m.pendingReply = true 1789 + m.loading = true 1790 + return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) 1791 + 1769 1792 case "f": 1770 1793 e := selectedEmail(m.inbox) 1771 1794 if e == nil { ··· 2476 2499 m.state = stateCompose 2477 2500 } 2478 2501 return m, nil 2502 + case "x": 2503 + // Discard the email entirely — clear everything and go back to inbox. 2504 + m.attachments = nil 2505 + m.pendingSend = nil 2506 + m.state = stateInbox 2507 + m.status = "Discarded." 2508 + m.isError = false 2509 + return m, nil 2479 2510 case "p": 2480 2511 return m.previewInBrowser() 2481 2512 case "esc": ··· 2706 2737 return m, nil 2707 2738 } 2708 2739 subject := e.Subject 2709 - prelude := editor.ForwardPrelude(subject, e.From, e.Date.Format("Mon, 02 Jan 2006 15:04:05 -0700"), e.To, m.openBody) 2740 + prelude := editor.ForwardPrelude(subject, m.presendFrom(), e.From, e.Date.Format("Mon, 02 Jan 2006 15:04:05 -0700"), e.To, m.openBody) 2710 2741 2711 2742 f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") 2712 2743 if err != nil { ··· 2789 2820 } 2790 2821 } 2791 2822 2792 - prelude := editor.ReplyPrelude(to, cc, subject, e.From, m.openBody) 2823 + prelude := editor.ReplyPrelude(to, cc, subject, m.presendFrom(), e.From, m.openBody) 2793 2824 2794 2825 f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") 2795 2826 if err != nil { ··· 3001 3032 b.WriteString("\n") 3002 3033 } 3003 3034 3004 - b.WriteString(styleHelp.Render(" enter send · s spell · p preview · ctrl+f from · ctrl+b cc/bcc · a attach · D remove last · d draft · e edit · esc cancel")) 3035 + b.WriteString(styleHelp.Render(" enter send · s spell · p preview · a attach · D remove attach · ctrl+f from · ctrl+b cc/bcc · e edit · d draft · esc cancel · x discard")) 3005 3036 return b.String() 3006 3037 } 3007 3038