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.

fix pre-send editing emails, store rolling backup of drafts locally and add :recover cmd

sspaeti 30d716b7 671a9dd6

+201 -6
+6
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + # 2026-04-06 4 + - **Fix: pre-send `e` losing email body** — pressing `e` in the pre-send review to re-edit now correctly reopens the editor with the existing body; previously it opened a blank compose with only the signature, silently discarding the email content (including reply history) 5 + - **Draft backups** — every compose session is automatically backed up to `~/.cache/neomd/drafts/` before the temp file is deleted; keeps a rolling 20 backups (configurable via `draft_backup_count` in `[ui]`, set to `-1` to disable); no more lost emails after crashes or accidental closes 6 + - **`:recover` / `:rec` command** — reopens the most recent draft backup as a compose session; To/Cc/Bcc/Subject are parsed from the backup and pre-filled automatically 7 + - **Screener docs: "screening happens once"** — documented that auto-screening only runs on the Inbox folder; emails moved to ToScreen by another device are not re-classified; use `:reset-toscreen` to move them back for re-screening 8 + 3 9 # 2026-04-05 4 10 - **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 5 11 - **`auto_bcc` config** — root-level `auto_bcc = "addr@example.com"` appends an address to every outgoing email's Bcc so you keep a copy in an external mailbox (e.g. a hey.com archive); visible in the composer and pre-send review (no silent BCC), deduped against any manual Bcc entry
+1 -1
README.md
··· 69 69 - **Attachments** — attach files from the pre-send screen via yazi (`a`); images appear inline in the email body, other files as attachments; also attach from within neovim via `<leader>a`; the reader lists all attachments (including inline images) and `1`–`9` downloads and opens them 70 70 - **Link opener** — links in emails are numbered `[1]`-`[0]` in the reader header; press `space+digit` to open in `$BROWSER` 71 71 - **CC, BCC, Reply-all** — optional Cc/Bcc fields (toggle with `ctrl+b`); `R` in the reader replies to sender + all CC recipients 72 - - **Drafts** — `d` in pre-send saves to Drafts (IMAP APPEND); `E` in the reader re-opens a draft as an editable compose 72 + - **Drafts** — `d` in pre-send saves to Drafts (IMAP APPEND); `E` in the reader re-opens a draft as an editable compose; compose sessions are auto-backed up to `~/.cache/neomd/drafts/` so you never lose an unsent email (`:recover` to reopen) 73 73 - **Multiple From addresses** — define SMTP-only `[[senders]]` aliases (e.g. `s@ssp.sh` through an existing account); cycle with `ctrl+f` in compose and pre-send; sent copies always land in the Sent folder 74 74 - **Undo** — `u` reverses the last move or delete (`x`, `A`, `M*`) using the UIDPLUS destination UID 75 75 - **Search** — `/` filters loaded emails in-memory; `space /` or `:search` runs IMAP SEARCH across all folders (only fetching header capped at 100 per folder) with results in a temporary "Search" tab; supports `from:`, `subject:`, `to:` prefixes
+1
docs/configuration.md
··· 66 66 auto_screen_on_load = true # screen inbox automatically on every load (default true) 67 67 bg_sync_interval = 5 # background sync interval in minutes; 0 = disabled (default 5) 68 68 bulk_progress_threshold = 10 # show progress counter for batch operations larger than this (default 10) 69 + draft_backup_count = 20 # rolling compose backups in ~/.cache/neomd/drafts/ (default 20, -1 = disabled) 69 70 signature = """**Your Name** 70 71 Your Title, Your Company 71 72
+21
internal/config/config.go
··· 123 123 AutoScreenOnLoad *bool `toml:"auto_screen_on_load"` // screen inbox on every load (default true) 124 124 BgSyncInterval int `toml:"bg_sync_interval"` // background sync interval in minutes (0 = disabled, default 5) 125 125 BulkProgressThreshold int `toml:"bulk_progress_threshold"` // show progress counter for batches larger than this (default 10) 126 + DraftBackupCount int `toml:"draft_backup_count"` // rolling compose backups in ~/.cache/neomd/drafts/ (default 20, 0 = disabled) 127 + } 128 + 129 + // DraftBackups returns the max number of rolling draft backups (default 20, 0 = disabled). 130 + func (u UIConfig) DraftBackups() int { 131 + if u.DraftBackupCount == 0 { 132 + return 20 133 + } 134 + return u.DraftBackupCount 126 135 } 127 136 128 137 // BulkThreshold returns the configured bulk progress threshold (default 10). ··· 195 204 return filepath.Join(p, "cmd_history") 196 205 } 197 206 return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_cmd_history", os.Getuid())) 207 + } 208 + 209 + // DraftsBackupDir returns ~/.cache/neomd/drafts/, creating it if needed. 210 + func DraftsBackupDir() string { 211 + if dir, err := os.UserCacheDir(); err == nil { 212 + p := filepath.Join(dir, cacheDirName, "drafts") 213 + _ = os.MkdirAll(p, 0700) 214 + return p 215 + } 216 + p := filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_drafts", os.Getuid())) 217 + _ = os.MkdirAll(p, 0700) 218 + return p 198 219 } 199 220 200 221 // welcomePath returns the path of the first-run marker file.
+65
internal/ui/cmdline.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "os" 6 + "path/filepath" 7 + "sort" 5 8 "strings" 6 9 7 10 tea "github.com/charmbracelet/bubbletea" 8 11 "github.com/charmbracelet/lipgloss" 12 + "github.com/sspaeti/neomd/internal/config" 13 + "github.com/sspaeti/neomd/internal/editor" 9 14 ) 10 15 11 16 // neomdCmd is a registered colon-command (like vim's :command). ··· 158 163 desc: "write diagnostic report to /tmp/neomd/debug.log and open it", 159 164 run: func(m *Model) (tea.Model, tea.Cmd) { 160 165 return m, m.writeDebugReport() 166 + }, 167 + }, 168 + { 169 + name: "recover", 170 + aliases: []string{"rec"}, 171 + desc: "reopen the most recent compose backup from ~/.cache/neomd/drafts/", 172 + run: func(m *Model) (tea.Model, tea.Cmd) { 173 + dir := config.DraftsBackupDir() 174 + entries, err := os.ReadDir(dir) 175 + if err != nil || len(entries) == 0 { 176 + m.status = "No draft backups found in " + dir 177 + return m, nil 178 + } 179 + // Sort by mod time descending (newest first). 180 + type fileEntry struct { 181 + path string 182 + modTime int64 183 + } 184 + var files []fileEntry 185 + for _, e := range entries { 186 + if e.IsDir() { 187 + continue 188 + } 189 + info, err := e.Info() 190 + if err != nil { 191 + continue 192 + } 193 + files = append(files, fileEntry{ 194 + path: filepath.Join(dir, e.Name()), 195 + modTime: info.ModTime().Unix(), 196 + }) 197 + } 198 + if len(files) == 0 { 199 + m.status = "No draft backups found in " + dir 200 + return m, nil 201 + } 202 + sort.Slice(files, func(i, j int) bool { return files[i].modTime > files[j].modTime }) 203 + 204 + // Read the most recent backup. 205 + raw, err := os.ReadFile(files[0].path) 206 + if err != nil { 207 + m.status = "read backup: " + err.Error() 208 + m.isError = true 209 + return m, nil 210 + } 211 + to, cc, bcc, subject, body := editor.ParseHeaders(string(raw)) 212 + 213 + // Pre-fill compose fields. 214 + m.compose.reset() 215 + m.presendFromI = 0 216 + m.compose.to.SetValue(to) 217 + m.compose.cc.SetValue(cc) 218 + m.compose.bcc.SetValue(bcc) 219 + m.compose.subject.SetValue(subject) 220 + if cc != "" || bcc != "" { 221 + m.compose.extraVisible = true 222 + } 223 + m.compose.step = 3 224 + 225 + return m.launchEditorWithBodyCmd(to, cc, subject, body) 161 226 }, 162 227 }, 163 228 {
+107 -5
internal/ui/model.go
··· 167 167 return dir 168 168 } 169 169 170 + // backupDraft copies a compose temp file to ~/.cache/neomd/drafts/ before it is 171 + // deleted. Keeps at most maxBackups files, pruning the oldest. 172 + func backupDraft(tmpPath string, maxBackups int) { 173 + if maxBackups < 0 { 174 + return // disabled 175 + } 176 + dir := config.DraftsBackupDir() 177 + dst := filepath.Join(dir, filepath.Base(tmpPath)) 178 + src, err := os.ReadFile(tmpPath) 179 + if err != nil || len(src) == 0 { 180 + return 181 + } 182 + _ = os.WriteFile(dst, src, 0600) 183 + 184 + // Prune oldest if over limit. 185 + entries, _ := os.ReadDir(dir) 186 + if len(entries) <= maxBackups { 187 + return 188 + } 189 + type fileInfo struct { 190 + name string 191 + modTime time.Time 192 + } 193 + files := make([]fileInfo, 0, len(entries)) 194 + for _, e := range entries { 195 + if e.IsDir() { 196 + continue 197 + } 198 + info, err := e.Info() 199 + if err != nil { 200 + continue 201 + } 202 + files = append(files, fileInfo{name: e.Name(), modTime: info.ModTime()}) 203 + } 204 + sort.Slice(files, func(i, j int) bool { return files[i].modTime.Before(files[j].modTime) }) 205 + for i := 0; i < len(files)-maxBackups; i++ { 206 + _ = os.Remove(filepath.Join(dir, files[i].name)) 207 + } 208 + } 209 + 170 210 // maskEmail masks the local part of an email address: "user@example.com" → "u***@example.com". 171 211 // For "Name <email>" format, masks the email part only. 172 212 func maskEmail(s string) string { ··· 2510 2550 editorBin = "nvim" 2511 2551 } 2512 2552 cmd := exec.Command(editorBin, tmpPath) 2553 + draftBackups := m.cfg.UI.DraftBackups() 2513 2554 m.state = stateCompose 2514 2555 return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg { 2556 + backupDraft(tmpPath, draftBackups) 2515 2557 defer os.Remove(tmpPath) 2516 2558 if execErr != nil { 2517 2559 return editorDoneMsg{err: execErr} ··· 2601 2643 return m, nil 2602 2644 case "e": 2603 2645 // Re-open the editor with the current body for further edits. 2604 - // Build a temp file with existing body and re-launch. 2605 2646 m.state = stateCompose 2606 2647 m.pendingSend = nil 2607 - prelude := editor.Prelude(ps.to, ps.cc, ps.subject, m.cfg.UI.Signature) 2608 - // Pre-fill compose fields so launchEditorCmd picks them up 2609 2648 m.compose.to.SetValue(ps.to) 2610 2649 m.compose.cc.SetValue(ps.cc) 2611 2650 m.compose.bcc.SetValue(ps.bcc) ··· 2613 2652 if ps.cc != "" || ps.bcc != "" { 2614 2653 m.compose.extraVisible = true 2615 2654 } 2616 - _ = prelude 2617 - return m.launchEditorCmd() 2655 + return m.launchEditorWithBodyCmd(ps.to, ps.cc, ps.subject, ps.body) 2618 2656 case "s": 2619 2657 // Open in nvim with spell checking, cursor on first error. 2620 2658 return m.launchSpellCheckCmd(ps) ··· 2677 2715 tmpPath, 2678 2716 ) 2679 2717 m.state = stateCompose 2718 + draftBackups := m.cfg.UI.DraftBackups() 2680 2719 return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg { 2720 + backupDraft(tmpPath, draftBackups) 2681 2721 defer os.Remove(tmpPath) 2682 2722 if execErr != nil { 2683 2723 return editorDoneMsg{err: execErr} ··· 2788 2828 } 2789 2829 2790 2830 cmd := exec.Command(editorBin, tmpPath) 2831 + draftBackups := m.cfg.UI.DraftBackups() 2791 2832 return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg { 2833 + backupDraft(tmpPath, draftBackups) 2792 2834 defer os.Remove(tmpPath) 2793 2835 if execErr != nil { 2794 2836 return editorDoneMsg{err: execErr} ··· 2817 2859 }) 2818 2860 } 2819 2861 2862 + // launchEditorWithBodyCmd re-opens the editor with an existing body (e.g. from 2863 + // the pre-send screen). The prelude is built from the provided headers (no 2864 + // signature — it is already in the body from the first compose). 2865 + func (m Model) launchEditorWithBodyCmd(to, cc, subject, body string) (tea.Model, tea.Cmd) { 2866 + prelude := editor.Prelude(to, cc, subject, "") 2867 + content := prelude + body 2868 + 2869 + f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") 2870 + if err != nil { 2871 + m.status = err.Error() 2872 + m.isError = true 2873 + m.state = stateInbox 2874 + return m, nil 2875 + } 2876 + tmpPath := f.Name() 2877 + f.WriteString(content) //nolint 2878 + f.Close() 2879 + 2880 + editorBin := os.Getenv("EDITOR") 2881 + if editorBin == "" { 2882 + editorBin = "nvim" 2883 + } 2884 + 2885 + bcc := m.compose.bcc.Value() 2886 + cmd := exec.Command(editorBin, tmpPath) 2887 + draftBackups := m.cfg.UI.DraftBackups() 2888 + return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg { 2889 + backupDraft(tmpPath, draftBackups) 2890 + defer os.Remove(tmpPath) 2891 + if execErr != nil { 2892 + return editorDoneMsg{err: execErr} 2893 + } 2894 + raw, readErr := os.ReadFile(tmpPath) 2895 + if readErr != nil { 2896 + return editorDoneMsg{err: readErr} 2897 + } 2898 + if string(raw) == content { 2899 + return editorDoneMsg{aborted: true} 2900 + } 2901 + pto, pcc, pbcc, psubject, _ := editor.ParseHeaders(string(raw)) 2902 + if pto == "" { 2903 + pto = to 2904 + } 2905 + if pcc == "" { 2906 + pcc = cc 2907 + } 2908 + if pbcc == "" { 2909 + pbcc = bcc 2910 + } 2911 + if psubject == "" { 2912 + psubject = subject 2913 + } 2914 + return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, subject: psubject, body: string(raw)} 2915 + }) 2916 + } 2917 + 2820 2918 // launchAttachPickerCmd suspends the TUI, launches yazi (or $NEOMD_FILE_PICKER) 2821 2919 // with --chooser-file, and returns selected paths as attachPickDoneMsg. 2822 2920 // Falls back to a no-op status message if no picker is available. ··· 2889 2987 } 2890 2988 2891 2989 cmd := exec.Command(editorBin, tmpPath) 2990 + draftBackups := m.cfg.UI.DraftBackups() 2892 2991 return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg { 2992 + backupDraft(tmpPath, draftBackups) 2893 2993 defer os.Remove(tmpPath) 2894 2994 if execErr != nil { 2895 2995 return editorDoneMsg{err: execErr} ··· 2977 3077 } 2978 3078 2979 3079 cmd := exec.Command(editorBin, tmpPath) 3080 + draftBackups := m.cfg.UI.DraftBackups() 2980 3081 return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg { 3082 + backupDraft(tmpPath, draftBackups) 2981 3083 defer os.Remove(tmpPath) 2982 3084 if execErr != nil { 2983 3085 return editorDoneMsg{err: execErr}