···11# Changelog
2233+# 2026-04-06
44+- **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)
55+- **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
66+- **`: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
77+- **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
88+39# 2026-04-05
410- **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
511- **`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
···6969- **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
7070- **Link opener** — links in emails are numbered `[1]`-`[0]` in the reader header; press `space+digit` to open in `$BROWSER`
7171- **CC, BCC, Reply-all** — optional Cc/Bcc fields (toggle with `ctrl+b`); `R` in the reader replies to sender + all CC recipients
7272-- **Drafts** — `d` in pre-send saves to Drafts (IMAP APPEND); `E` in the reader re-opens a draft as an editable compose
7272+- **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)
7373- **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
7474- **Undo** — `u` reverses the last move or delete (`x`, `A`, `M*`) using the UIDPLUS destination UID
7575- **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
···6666auto_screen_on_load = true # screen inbox automatically on every load (default true)
6767bg_sync_interval = 5 # background sync interval in minutes; 0 = disabled (default 5)
6868bulk_progress_threshold = 10 # show progress counter for batch operations larger than this (default 10)
6969+draft_backup_count = 20 # rolling compose backups in ~/.cache/neomd/drafts/ (default 20, -1 = disabled)
6970signature = """**Your Name**
7071Your Title, Your Company
7172
+21
internal/config/config.go
···123123 AutoScreenOnLoad *bool `toml:"auto_screen_on_load"` // screen inbox on every load (default true)
124124 BgSyncInterval int `toml:"bg_sync_interval"` // background sync interval in minutes (0 = disabled, default 5)
125125 BulkProgressThreshold int `toml:"bulk_progress_threshold"` // show progress counter for batches larger than this (default 10)
126126+ DraftBackupCount int `toml:"draft_backup_count"` // rolling compose backups in ~/.cache/neomd/drafts/ (default 20, 0 = disabled)
127127+}
128128+129129+// DraftBackups returns the max number of rolling draft backups (default 20, 0 = disabled).
130130+func (u UIConfig) DraftBackups() int {
131131+ if u.DraftBackupCount == 0 {
132132+ return 20
133133+ }
134134+ return u.DraftBackupCount
126135}
127136128137// BulkThreshold returns the configured bulk progress threshold (default 10).
···195204 return filepath.Join(p, "cmd_history")
196205 }
197206 return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_cmd_history", os.Getuid()))
207207+}
208208+209209+// DraftsBackupDir returns ~/.cache/neomd/drafts/, creating it if needed.
210210+func DraftsBackupDir() string {
211211+ if dir, err := os.UserCacheDir(); err == nil {
212212+ p := filepath.Join(dir, cacheDirName, "drafts")
213213+ _ = os.MkdirAll(p, 0700)
214214+ return p
215215+ }
216216+ p := filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_drafts", os.Getuid()))
217217+ _ = os.MkdirAll(p, 0700)
218218+ return p
198219}
199220200221// welcomePath returns the path of the first-run marker file.
+65
internal/ui/cmdline.go
···2233import (
44 "fmt"
55+ "os"
66+ "path/filepath"
77+ "sort"
58 "strings"
69710 tea "github.com/charmbracelet/bubbletea"
811 "github.com/charmbracelet/lipgloss"
1212+ "github.com/sspaeti/neomd/internal/config"
1313+ "github.com/sspaeti/neomd/internal/editor"
914)
10151116// neomdCmd is a registered colon-command (like vim's :command).
···158163 desc: "write diagnostic report to /tmp/neomd/debug.log and open it",
159164 run: func(m *Model) (tea.Model, tea.Cmd) {
160165 return m, m.writeDebugReport()
166166+ },
167167+ },
168168+ {
169169+ name: "recover",
170170+ aliases: []string{"rec"},
171171+ desc: "reopen the most recent compose backup from ~/.cache/neomd/drafts/",
172172+ run: func(m *Model) (tea.Model, tea.Cmd) {
173173+ dir := config.DraftsBackupDir()
174174+ entries, err := os.ReadDir(dir)
175175+ if err != nil || len(entries) == 0 {
176176+ m.status = "No draft backups found in " + dir
177177+ return m, nil
178178+ }
179179+ // Sort by mod time descending (newest first).
180180+ type fileEntry struct {
181181+ path string
182182+ modTime int64
183183+ }
184184+ var files []fileEntry
185185+ for _, e := range entries {
186186+ if e.IsDir() {
187187+ continue
188188+ }
189189+ info, err := e.Info()
190190+ if err != nil {
191191+ continue
192192+ }
193193+ files = append(files, fileEntry{
194194+ path: filepath.Join(dir, e.Name()),
195195+ modTime: info.ModTime().Unix(),
196196+ })
197197+ }
198198+ if len(files) == 0 {
199199+ m.status = "No draft backups found in " + dir
200200+ return m, nil
201201+ }
202202+ sort.Slice(files, func(i, j int) bool { return files[i].modTime > files[j].modTime })
203203+204204+ // Read the most recent backup.
205205+ raw, err := os.ReadFile(files[0].path)
206206+ if err != nil {
207207+ m.status = "read backup: " + err.Error()
208208+ m.isError = true
209209+ return m, nil
210210+ }
211211+ to, cc, bcc, subject, body := editor.ParseHeaders(string(raw))
212212+213213+ // Pre-fill compose fields.
214214+ m.compose.reset()
215215+ m.presendFromI = 0
216216+ m.compose.to.SetValue(to)
217217+ m.compose.cc.SetValue(cc)
218218+ m.compose.bcc.SetValue(bcc)
219219+ m.compose.subject.SetValue(subject)
220220+ if cc != "" || bcc != "" {
221221+ m.compose.extraVisible = true
222222+ }
223223+ m.compose.step = 3
224224+225225+ return m.launchEditorWithBodyCmd(to, cc, subject, body)
161226 },
162227 },
163228 {
+107-5
internal/ui/model.go
···167167 return dir
168168}
169169170170+// backupDraft copies a compose temp file to ~/.cache/neomd/drafts/ before it is
171171+// deleted. Keeps at most maxBackups files, pruning the oldest.
172172+func backupDraft(tmpPath string, maxBackups int) {
173173+ if maxBackups < 0 {
174174+ return // disabled
175175+ }
176176+ dir := config.DraftsBackupDir()
177177+ dst := filepath.Join(dir, filepath.Base(tmpPath))
178178+ src, err := os.ReadFile(tmpPath)
179179+ if err != nil || len(src) == 0 {
180180+ return
181181+ }
182182+ _ = os.WriteFile(dst, src, 0600)
183183+184184+ // Prune oldest if over limit.
185185+ entries, _ := os.ReadDir(dir)
186186+ if len(entries) <= maxBackups {
187187+ return
188188+ }
189189+ type fileInfo struct {
190190+ name string
191191+ modTime time.Time
192192+ }
193193+ files := make([]fileInfo, 0, len(entries))
194194+ for _, e := range entries {
195195+ if e.IsDir() {
196196+ continue
197197+ }
198198+ info, err := e.Info()
199199+ if err != nil {
200200+ continue
201201+ }
202202+ files = append(files, fileInfo{name: e.Name(), modTime: info.ModTime()})
203203+ }
204204+ sort.Slice(files, func(i, j int) bool { return files[i].modTime.Before(files[j].modTime) })
205205+ for i := 0; i < len(files)-maxBackups; i++ {
206206+ _ = os.Remove(filepath.Join(dir, files[i].name))
207207+ }
208208+}
209209+170210// maskEmail masks the local part of an email address: "user@example.com" → "u***@example.com".
171211// For "Name <email>" format, masks the email part only.
172212func maskEmail(s string) string {
···25102550 editorBin = "nvim"
25112551 }
25122552 cmd := exec.Command(editorBin, tmpPath)
25532553+ draftBackups := m.cfg.UI.DraftBackups()
25132554 m.state = stateCompose
25142555 return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg {
25562556+ backupDraft(tmpPath, draftBackups)
25152557 defer os.Remove(tmpPath)
25162558 if execErr != nil {
25172559 return editorDoneMsg{err: execErr}
···26012643 return m, nil
26022644 case "e":
26032645 // Re-open the editor with the current body for further edits.
26042604- // Build a temp file with existing body and re-launch.
26052646 m.state = stateCompose
26062647 m.pendingSend = nil
26072607- prelude := editor.Prelude(ps.to, ps.cc, ps.subject, m.cfg.UI.Signature)
26082608- // Pre-fill compose fields so launchEditorCmd picks them up
26092648 m.compose.to.SetValue(ps.to)
26102649 m.compose.cc.SetValue(ps.cc)
26112650 m.compose.bcc.SetValue(ps.bcc)
···26132652 if ps.cc != "" || ps.bcc != "" {
26142653 m.compose.extraVisible = true
26152654 }
26162616- _ = prelude
26172617- return m.launchEditorCmd()
26552655+ return m.launchEditorWithBodyCmd(ps.to, ps.cc, ps.subject, ps.body)
26182656 case "s":
26192657 // Open in nvim with spell checking, cursor on first error.
26202658 return m.launchSpellCheckCmd(ps)
···26772715 tmpPath,
26782716 )
26792717 m.state = stateCompose
27182718+ draftBackups := m.cfg.UI.DraftBackups()
26802719 return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg {
27202720+ backupDraft(tmpPath, draftBackups)
26812721 defer os.Remove(tmpPath)
26822722 if execErr != nil {
26832723 return editorDoneMsg{err: execErr}
···27882828 }
2789282927902830 cmd := exec.Command(editorBin, tmpPath)
28312831+ draftBackups := m.cfg.UI.DraftBackups()
27912832 return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg {
28332833+ backupDraft(tmpPath, draftBackups)
27922834 defer os.Remove(tmpPath)
27932835 if execErr != nil {
27942836 return editorDoneMsg{err: execErr}
···28172859 })
28182860}
2819286128622862+// launchEditorWithBodyCmd re-opens the editor with an existing body (e.g. from
28632863+// the pre-send screen). The prelude is built from the provided headers (no
28642864+// signature — it is already in the body from the first compose).
28652865+func (m Model) launchEditorWithBodyCmd(to, cc, subject, body string) (tea.Model, tea.Cmd) {
28662866+ prelude := editor.Prelude(to, cc, subject, "")
28672867+ content := prelude + body
28682868+28692869+ f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md")
28702870+ if err != nil {
28712871+ m.status = err.Error()
28722872+ m.isError = true
28732873+ m.state = stateInbox
28742874+ return m, nil
28752875+ }
28762876+ tmpPath := f.Name()
28772877+ f.WriteString(content) //nolint
28782878+ f.Close()
28792879+28802880+ editorBin := os.Getenv("EDITOR")
28812881+ if editorBin == "" {
28822882+ editorBin = "nvim"
28832883+ }
28842884+28852885+ bcc := m.compose.bcc.Value()
28862886+ cmd := exec.Command(editorBin, tmpPath)
28872887+ draftBackups := m.cfg.UI.DraftBackups()
28882888+ return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg {
28892889+ backupDraft(tmpPath, draftBackups)
28902890+ defer os.Remove(tmpPath)
28912891+ if execErr != nil {
28922892+ return editorDoneMsg{err: execErr}
28932893+ }
28942894+ raw, readErr := os.ReadFile(tmpPath)
28952895+ if readErr != nil {
28962896+ return editorDoneMsg{err: readErr}
28972897+ }
28982898+ if string(raw) == content {
28992899+ return editorDoneMsg{aborted: true}
29002900+ }
29012901+ pto, pcc, pbcc, psubject, _ := editor.ParseHeaders(string(raw))
29022902+ if pto == "" {
29032903+ pto = to
29042904+ }
29052905+ if pcc == "" {
29062906+ pcc = cc
29072907+ }
29082908+ if pbcc == "" {
29092909+ pbcc = bcc
29102910+ }
29112911+ if psubject == "" {
29122912+ psubject = subject
29132913+ }
29142914+ return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, subject: psubject, body: string(raw)}
29152915+ })
29162916+}
29172917+28202918// launchAttachPickerCmd suspends the TUI, launches yazi (or $NEOMD_FILE_PICKER)
28212919// with --chooser-file, and returns selected paths as attachPickDoneMsg.
28222920// Falls back to a no-op status message if no picker is available.
···28892987 }
2890298828912989 cmd := exec.Command(editorBin, tmpPath)
29902990+ draftBackups := m.cfg.UI.DraftBackups()
28922991 return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg {
29922992+ backupDraft(tmpPath, draftBackups)
28932993 defer os.Remove(tmpPath)
28942994 if execErr != nil {
28952995 return editorDoneMsg{err: execErr}
···29773077 }
2978307829793079 cmd := exec.Command(editorBin, tmpPath)
30803080+ draftBackups := m.cfg.UI.DraftBackups()
29803081 return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg {
30823082+ backupDraft(tmpPath, draftBackups)
29813083 defer os.Remove(tmpPath)
29823084 if execErr != nil {
29833085 return editorDoneMsg{err: execErr}