Something went wrong. Try again.
Keyboard-first TUI email: write in Neovim, render as Markdown, screen senders first, organize emails once. neomd.ssp.sh
email markdown neovim tui
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367package ui
import ( "fmt" "os" "strings"
tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/sspaeti/neomd/internal/config" "github.com/sspaeti/neomd/internal/editor")
// neomdCmd is a registered colon-command (like vim's :command).type neomdCmd struct { name string // full name, e.g. "screen-all" aliases []string // short forms accepted, e.g. ["sa", "screen-a"] desc string // run is called when the command is executed; m is the current model. run func(m *Model) (tea.Model, tea.Cmd)}
// cmdRegistry is the list of all available colon-commands.// Add new commands here — they become automatically available in the// command line with tab-completion.var cmdRegistry []neomdCmd
func init() { cmdRegistry = []neomdCmd{ { name: "screen", aliases: []string{"s"}, desc: "screen currently loaded emails only (up to inbox_count)", run: func(m *Model) (tea.Model, tea.Cmd) { if err := m.validateScreenerSafety(); err != nil { m.status = err.Error() m.isError = true return m, nil } moves := m.previewAutoScreen() if len(moves) == 0 { m.status = "Nothing to screen — all senders already classified." return m, nil } m.pendingMoves = moves m.status = screenSummary(moves) + " · y to apply, n to cancel" return m, nil }, }, { name: "screen-all", aliases: []string{"sa", "screen-a"}, desc: "fetch and screen EVERY inbox email, no limit (use after updating screener lists)", run: func(m *Model) (tea.Model, tea.Cmd) { if err := m.validateScreenerSafety(); err != nil { m.status = err.Error() m.isError = true return m, nil } m.loading = true return m, m.deepScreenCmd() }, }, { name: "scan-spy-pixels", aliases: []string{"ssp"}, desc: "scan current folder for tracking pixels (background, skips already scanned)", run: func(m *Model) (tea.Model, tea.Cmd) { m.status = "Scanning for spy pixels…" return m, m.spyScanCmd() }, }, { name: "reload", aliases: []string{"r", "re"}, desc: "reload / refresh the current folder", run: func(m *Model) (tea.Model, tea.Cmd) { m.loading = true return m, m.fetchFolderCmd(m.activeFolder()) }, }, { name: "mark-read", aliases: []string{"mr"}, desc: "mark all emails in current folder as read", run: func(m *Model) (tea.Model, tea.Cmd) { cmd := m.markAllSeenCmd() if cmd == nil { m.status = "All already read." return m, nil } m.loading = true return m, tea.Batch(m.spinner.Tick, cmd) }, }, { name: "check", aliases: []string{"ch"}, desc: "show screener classification for the selected email (diagnostic)", run: func(m *Model) (tea.Model, tea.Cmd) { e := selectedEmail(m.inbox) if e == nil { m.status = "No email selected." return m, nil } cat, addr := m.screener.ClassifyDebug(e.From) m.status = fmt.Sprintf("from=%q addr=%q → %s", e.From, addr, cat) return m, nil }, }, { name: "reset-toscreen", aliases: []string{"rts"}, desc: "move all ToScreen emails back to Inbox (then run screen-all to reclassify)", run: func(m *Model) (tea.Model, tea.Cmd) { m.loading = true return m, tea.Batch(m.spinner.Tick, m.resetToScreenSearchCmd()) }, }, { name: "delete-all", aliases: []string{"da"}, desc: "permanently delete ALL emails in the current folder (y/n confirmation)", run: func(m *Model) (tea.Model, tea.Cmd) { m.loading = true return m, tea.Batch(m.spinner.Tick, m.deleteAllSearchCmd()) }, }, { name: "empty-trash", aliases: []string{"et"}, desc: "permanently delete ALL emails in Trash (y/n confirmation)", run: func(m *Model) (tea.Model, tea.Cmd) { m.loading = true return m, tea.Batch(m.spinner.Tick, m.emptyTrashSearchCmd()) }, }, { name: "create-folders", aliases: []string{"cf"}, desc: "create any missing IMAP folders defined in config (safe to run multiple times)", run: func(m *Model) (tea.Model, tea.Cmd) { m.loading = true return m, tea.Batch(m.spinner.Tick, m.ensureFoldersCmd()) }, }, { name: "everything", aliases: []string{"ev"}, desc: "show latest 50 emails across all folders (newest first)", run: func(m *Model) (tea.Model, tea.Cmd) { m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchEverythingCmd()) }, }, { name: "search", aliases: []string{"se"}, desc: "IMAP search all emails across all configured folders (From + Subject + To)", run: func(m *Model) (tea.Model, tea.Cmd) { m.imapSearchActive = true m.imapSearchText = "" m.imapSearchResults = false return m, nil }, }, { name: "go-spam", aliases: []string{"spam"}, desc: "open Spam folder (not in tab rotation — use :go-spam to visit)", run: func(m *Model) (tea.Model, tea.Cmd) { m.loading = true m.status = "Spam folder — press R to reload, tab to leave" return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.Folders.Spam)) }, }, { name: "debug", aliases: []string{"dbg"}, desc: "write diagnostic report to /tmp/neomd/debug.log and open it", run: func(m *Model) (tea.Model, tea.Cmd) { return m, m.writeDebugReport() }, }, { name: "recover", aliases: []string{"rec"}, desc: "reopen the most recent compose backup from ~/.cache/neomd/drafts/", run: func(m *Model) (tea.Model, tea.Cmd) { dir := config.DraftsBackupDir() files := listBackupsByAge(dir) if len(files) == 0 { m.status = "No draft backups found in " + dir return m, nil }
// Read the most recent backup (list is oldest-first). raw, err := os.ReadFile(files[len(files)-1].path) if err != nil { m.status = "read backup: " + err.Error() m.isError = true return m, nil } to, cc, bcc, from, subject, body := editor.ParseHeaders(string(raw))
// Pre-fill compose fields. m.compose.reset() m.presendFromI = 0 if idx := m.matchFromAddress(from); idx >= 0 { m.presendFromI = idx } m.compose.to.SetValue(to) m.compose.cc.SetValue(cc) m.compose.bcc.SetValue(bcc) m.compose.subject.SetValue(subject) if cc != "" || bcc != "" { m.compose.extraVisible = true } m.compose.step = 3
return m.launchEditorWithBodyCmd(to, cc, bcc, subject, body) }, }, { name: "thread", aliases: []string{"t"}, desc: "show full conversation for the selected email (across folders)", run: func(m *Model) (tea.Model, tea.Cmd) { e := selectedEmail(m.inbox) if e == nil { m.status = "No email selected." return m, nil } m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchConversationCmd(e)) }, }, { name: "notify-test", aliases: []string{"nt"}, desc: "fire a single test desktop notification using the current [notifications] config (diagnostic)", run: func(m *Model) (tea.Model, tea.Cmd) { if !m.notifier.Enabled() { m.status = "Notifications disabled. Set [notifications].enabled = true in config.toml." m.isError = true return m, nil } if err := m.notifier.Send("neomd: test", "If you can see this, notify-send works."); err != nil { m.status = "notify-send failed: " + err.Error() m.isError = true return m, nil } m.status = "Test notification sent — check your desktop notifications. Listening folders: " + strings.Join(m.cfg.Notifications.Resolved().Folders, ", ") return m, nil }, }, { name: "quit", aliases: []string{"q"}, desc: "quit neomd", run: func(m *Model) (tea.Model, tea.Cmd) { return m, tea.Quit }, }, }}
// screenSummary builds the "N→Folder …" part of the S/screen-all preview.func screenSummary(moves []autoScreenMove) string { counts := map[string]int{} for _, mv := range moves { counts[mv.dst]++ } s := "" for dst, n := range counts { s += " " + formatInt(n) + "→" + dst } return "Would move " + formatInt(len(moves)) + " email(s):" + s}
func formatInt(n int) string { return fmt.Sprintf("%d", n) }
// matchCmds returns all commands whose name or any alias has text as a prefix.// When text is empty, all commands are returned (for tab-cycling).func matchCmds(text string) []*neomdCmd { lower := strings.ToLower(text) var out []*neomdCmd for i := range cmdRegistry { c := &cmdRegistry[i] if lower == "" || strings.HasPrefix(c.name, lower) { out = append(out, c) continue } for _, a := range c.aliases { if strings.HasPrefix(a, lower) { out = append(out, c) break } } } return out}
// matchCmd returns the first matching command (for enter / ghost completion).func matchCmd(text string) *neomdCmd { if text == "" { return nil } if m := matchCmds(text); len(m) > 0 { return m[0] } return nil}
// viewCmdLine renders the command-line bar shown at the bottom of the inbox.// When input is empty or has multiple matches it shows a tab-cycle menu above.func viewCmdLine(text string, width int) string { matches := matchCmds(text) first := matchCmd(text) // nil when empty
prefix := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true).Render(":") inputS := lipgloss.NewStyle().Foreground(colorText).Render(text)
// Ghost completion: rest of first matched name ghost := "" if first != nil && text != "" { lower := strings.ToLower(text) if strings.HasPrefix(first.name, lower) && len(first.name) > len(lower) { ghost = lipgloss.NewStyle().Foreground(colorMuted).Render(first.name[len(lower):]) } }
cursor := lipgloss.NewStyle().Foreground(colorPrimary).Render("█")
// Inline description / error desc := "" if first != nil { desc = lipgloss.NewStyle().Foreground(colorMuted).Render(" — " + first.desc) } else if text != "" { desc = lipgloss.NewStyle().Foreground(colorError).Render(" unknown command") }
cmdLine := " " + prefix + inputS + ghost + cursor + desc
// When empty or multiple matches: show a compact menu above the command line // so the user can see what's available and tab-cycle through them. if len(matches) > 1 || text == "" { nameStyle := lipgloss.NewStyle().Foreground(colorPrimary) dimStyle := lipgloss.NewStyle().Foreground(colorMuted) var parts []string for _, c := range matches { entry := nameStyle.Render(c.name) if len(c.aliases) > 0 { entry += dimStyle.Render(" (" + strings.Join(c.aliases, ",") + ")") } parts = append(parts, entry) } hint := dimStyle.Render("tab to cycle · enter to run · esc cancel") menu := " " + strings.Join(parts, dimStyle.Render(" · ")) + " " + hint return menu + "\n" + cmdLine }
_ = width return cmdLine}