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.

improved on-boarding

sspaeti 1d4b0d58 24dabf4b

+63 -18
+2
CHANGELOG.md
··· 9 9 - **`ctrl+b` in pre-send** — toggle CC/BCC fields from the pre-send review screen (previously only available during compose) 10 10 - **`u` / `U` rebind** — `u` is now free for page-up (vim-style half-page scroll); `U` is undo last move/delete; `ctrl+u` clears all marks 11 11 - **Temp files in `/tmp/neomd/`** — all temp files (compose, preview, spell check) now live in `/tmp/neomd/` subdirectory for easy recovery after crashes and less clutter 12 + - **Improved onboarding** — auto-screening is now paused when screener lists are empty (first run), preventing all emails from being moved to ToScreen; activates automatically once the user classifies their first sender; welcome screen rewritten with step-by-step getting-started guide explaining the screener workflow, batch operations (`m` + `I`), and config hints (`:debug`, `auto_screen_on_load`) 13 + - **`]` / `[` folder navigation** — bracket keys now switch to next/previous folder tab (alongside `L`/`H` and `tab`/`shift+tab`) 12 14 13 15 ## 2026-03-31 14 16 - fix showing recipient in SENT tab (instead of from)
+20
README.md
··· 138 138 139 139 For the full configuration reference including multiple accounts, `[[senders]]` aliases, folder customization, signatures, and UI options, see [docs/configuration.md](docs/configuration.md). 140 140 141 + ### Onboarding 142 + 143 + On first launch, **auto-screening is paused** because your screener lists are empty — neomd won't move anything until you've classified your first sender. Your Inbox loads normally so you can explore. 144 + 145 + **Getting started with the screener:** 146 + 147 + 1. From your Inbox, pick an email and press `I` (screen **in**) to approve the sender, or `O` (screen **out**) to block them. This creates your first screener list entry. 148 + 2. Once you've classified at least one sender, auto-screening activates on every Inbox load — new emails from known senders are sorted automatically. 149 + 3. Unknown senders land in the `ToScreen` tab. Jump there with `gk` (or `Tab`, or click the tab) and classify them: 150 + - `I` screen **in** — sender stays in Inbox forever 151 + - `O` screen **out** — sender never reaches Inbox again 152 + - `F` **feed** — newsletters go to the Feed tab 153 + - `P` **papertrail** — receipts go to the PaperTrail tab 154 + 4. Use `m` to mark multiple emails, then `I` to batch-approve them all at once. 155 + 156 + **The best part:** all classifications are saved permanently in your screener lists (`screened_in.txt`, `screened_out.txt`, etc.). An email address screened in will automatically go to your Inbox, and any email screened out will never be in your Inbox again. 157 + 158 + You choose who can land in your Inbox. Bye-bye spam. This is the beauty of [HEY-Screener](https://www.hey.com/features/the-screener/), and neomd implements the same concept. 159 + 160 + > **Tip:** To disable auto-screening entirely, set `auto_screen_on_load = false` in `[ui]` config. Run `:debug` inside neomd if something isn't working. 141 161 ## Keybindings 142 162 143 163 Press `?` inside neomd to open the interactive help overlay. Start typing to filter shortcuts.
+2 -2
docs/keybindings.md
··· 23 23 24 24 | Key | Action | 25 25 |-----|--------| 26 - | `L / tab` | next folder tab | 27 - | `H / shift+tab` | previous folder tab | 26 + | `L / ] / tab` | next folder tab | 27 + | `H / [ / shift+tab` | previous folder tab | 28 28 | `gi` | go to Inbox | 29 29 | `ga` | go to Archive | 30 30 | `gf` | go to Feed |
+7
internal/screener/screener.go
··· 84 84 return s, nil 85 85 } 86 86 87 + // IsEmpty returns true when all screener lists are empty (no senders classified yet). 88 + // This typically means neomd is running for the first time or lists were cleared. 89 + func (s *Screener) IsEmpty() bool { 90 + return len(s.screenedIn) == 0 && len(s.screenedOut) == 0 && 91 + len(s.feed) == 0 && len(s.paperTrail) == 0 && len(s.spam) == 0 92 + } 93 + 87 94 // AllAddresses returns a deduplicated slice of all known email addresses 88 95 // from screened_in, feed, and papertrail lists. Useful for autocomplete. 89 96 // Excludes screened_out and spam since you wouldn't want to email those.
+2 -2
internal/ui/keys.go
··· 19 19 {"?", "toggle help overlay (type to filter)"}, 20 20 }}, 21 21 {"Folders", [][2]string{ 22 - {"L / tab", "next folder tab"}, 23 - {"H / shift+tab", "previous folder tab"}, 22 + {"L / ] / tab", "next folder tab"}, 23 + {"H / [ / shift+tab", "previous folder tab"}, 24 24 {"gi", "go to Inbox"}, 25 25 {"ga", "go to Archive"}, 26 26 {"gf", "go to Feed"},
+30 -14
internal/ui/model.go
··· 1056 1056 // In-memory classification is instant; already-screened senders won't 1057 1057 // appear in inbox again so this is idempotent. 1058 1058 // Controlled by ui.auto_screen_on_load (default true). 1059 - if msg.folder == m.cfg.Folders.Inbox && m.cfg.UI.AutoScreen() { 1059 + // Skip when all screener lists are empty — otherwise every email would 1060 + // be moved to ToScreen on first run, confusing new users. 1061 + if msg.folder == m.cfg.Folders.Inbox && m.cfg.UI.AutoScreen() && !m.screener.IsEmpty() { 1060 1062 if moves := m.previewAutoScreen(); len(moves) > 0 { 1061 1063 m.loading = true 1064 + m.status = fmt.Sprintf("Screening %d email(s)…", len(moves)) 1062 1065 return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd(), m.spinner.Tick, m.execAutoScreenCmd(moves)) 1063 1066 } 1064 1067 } ··· 1705 1708 return m, tea.Batch(m.spinner.Tick, m.batchToggleSeenCmd(targets)) 1706 1709 1707 1710 // ── Navigation ────────────────────────────────────────────────── 1708 - case "tab", "L": 1711 + case "tab", "L", "]": 1709 1712 m.activeFolderI = (m.activeFolderI + 1) % len(m.folders) 1710 1713 m.offTabFolder = "" 1711 1714 m.imapSearchResults = false 1712 1715 m.loading = true 1713 1716 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 1714 1717 1715 - case "shift+tab", "H": 1718 + case "shift+tab", "H", "[": 1716 1719 m.activeFolderI = (m.activeFolderI - 1 + len(m.folders)) % len(m.folders) 1717 1720 m.offTabFolder = "" 1718 1721 m.imapSearchResults = false ··· 3102 3105 } 3103 3106 3104 3107 func (m Model) viewWelcome() string { 3108 + boxWidth := 64 3105 3109 box := lipgloss.NewStyle(). 3106 3110 Border(lipgloss.RoundedBorder()). 3107 3111 BorderForeground(colorPrimary). 3108 3112 Padding(1, 3). 3109 - Width(60) 3113 + Width(boxWidth) 3110 3114 3111 3115 title := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) 3112 3116 key := lipgloss.NewStyle().Foreground(colorAuthorUnread).Bold(true) 3113 3117 dim := lipgloss.NewStyle().Foreground(colorDateCol) 3118 + 3119 + warn := lipgloss.NewStyle().Foreground(colorError) 3114 3120 3115 3121 content := title.Render("Welcome to neomd!") + "\n\n" + 3116 - "Your IMAP folders and screener lists have been\n" + 3117 - "set up automatically.\n\n" + 3122 + "Your IMAP folders have been created automatically.\n\n" + 3118 3123 title.Render("Quick start") + "\n" + 3119 3124 key.Render(" j/k") + " navigate " + key.Render("enter") + " open email\n" + 3120 3125 key.Render(" c") + " compose " + key.Render("r") + " reply\n" + 3121 - key.Render(" f") + " forward " + key.Render("R") + " reply-all\n\n" + 3122 - title.Render("Screener") + " " + dim.Render("(from inbox or reader)") + "\n" + 3123 - key.Render(" I") + " approve sender (stays in Inbox)\n" + 3124 - key.Render(" O") + " block sender (moves to ScreenedOut)\n" + 3125 - key.Render(" F") + " mark as feed (moves to Feed)\n" + 3126 - key.Render(" P") + " mark as paper (moves to PaperTrail)\n\n" + 3127 - dim.Render("Press ? anytime for all keybindings.") + "\n\n" + 3126 + key.Render(" f") + " forward " + key.Render("R") + " reply-all\n" + 3127 + key.Render(" ]") + " / " + key.Render("[") + " next/prev tab " + key.Render("?") + " all keys\n\n" + 3128 + title.Render("How the Screener works") + "\n" + 3129 + "Your screener lists are empty, so " + warn.Render("auto-screening") + "\n" + 3130 + warn.Render("is paused") + " until you classify your first senders.\n\n" + 3131 + title.Render("Getting started") + "\n" + 3132 + "1. Go to " + key.Render("ToScreen") + " tab (" + key.Render("gk") + " or " + key.Render("Tab") + " or click)\n" + 3133 + "2. Screen each sender:\n" + 3134 + key.Render(" I") + " screen " + title.Render("in") + " " + dim.Render("sender stays in Inbox forever") + "\n" + 3135 + key.Render(" O") + " screen " + title.Render("out") + " " + dim.Render("sender never reaches Inbox again") + "\n" + 3136 + key.Render(" F") + " feed " + dim.Render("newsletters go to Feed tab") + "\n" + 3137 + key.Render(" P") + " papertrail " + dim.Render("receipts go to PaperTrail tab") + "\n" + 3138 + "3. Use " + key.Render("m") + " to mark multiple, then " + key.Render("I") + " to batch-approve\n\n" + 3139 + dim.Render("Once classified, senders are remembered forever.") + "\n" + 3140 + dim.Render("New emails auto-sort on every load. You choose") + "\n" + 3141 + dim.Render("who lands in your inbox. Bye-bye spam.") + "\n\n" + 3142 + dim.Render("Disable auto-screen: auto_screen_on_load = false") + "\n" + 3143 + dim.Render("Diagnostics: :debug All keys: ?") + "\n\n" + 3128 3144 dim.Render("Press any key to continue.") 3129 3145 3130 3146 rendered := box.Render(content) ··· 3135 3151 if padTop < 0 { 3136 3152 padTop = 0 3137 3153 } 3138 - padLeft := (m.width - 60) / 2 3154 + padLeft := (m.width - boxWidth) / 2 3139 3155 if padLeft < 0 { 3140 3156 padLeft = 0 3141 3157 }