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.

big update: adapting to my neomutt workflow

sspaeti b5f206bb c1c9581f

+1072 -152
+109 -35
README.md
··· 2 2 3 3 A minimal terminal email client for people who write in Markdown and live in Neovim. 4 4 5 - Compose emails in your editor, read them rendered with [glamour](https://github.com/charmbracelet/glamour), and manage your inbox with a [HEY-style screener](https://www.hey.com/features/the-screener/) — all from the terminal. 5 + ![neomd](images/neomd.png) 6 + 7 + Compose emails in your editor, read them rendered with [glamour](https://github.com/charmbracelet/glamour), and manage your inbox with a [HEY-style screener](https://www.hey.com/features/the-screener/) — all from the terminal. (see also [Neomutt HEY screener implementation](https://www.ssp.sh/brain/hey-screener-in-neomutt)) 6 8 7 9 ## Features 8 10 9 11 - **Write in Markdown, send beautifully** — compose in `$EDITOR` (defaults to `nvim`), send as `multipart/alternative`: raw Markdown as plain text + goldmark-rendered HTML so recipients get clickable links and formatting 10 12 - **Glamour reading** — incoming emails rendered as styled Markdown in the terminal 11 13 - **HEY-style screener** — unknown senders land in `ToScreen`; press `I/O/F/P` to approve, block, mark as Feed, or mark as PaperTrail; reuses your existing `screened_in.txt` lists from neomutt 12 - - **Folder tabs** — switch between Inbox, ToScreen, Feed, and PaperTrail with `Tab` 13 - - **IMAP + SMTP** — direct connection, no local sync daemon required 14 + - **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut 15 + - **Multi-select** — `space` marks emails, then batch-delete, move, or screen them all at once 16 + - **Kanagawa theme** — colors from the [kanagawa.nvim](https://github.com/rebelot/kanagawa.nvim) palette 17 + - **IMAP + SMTP** — direct connection via RFC 6851 MOVE, no local sync daemon required 14 18 15 19 ## Install 16 20 ··· 32 36 On first run, neomd creates `~/.config/neomd/config.toml` with placeholders: 33 37 34 38 ```toml 35 - [account] 39 + [[accounts]] 36 40 name = "Personal" 37 41 imap = "imap.example.com:993" # :993 = TLS, :143 = STARTTLS 38 42 smtp = "smtp.example.com:587" ··· 40 44 password = "app-password" 41 45 from = "Me <me@example.com>" 42 46 47 + # Multiple accounts supported — add more [[accounts]] blocks 48 + # Switch between them with `a` in the inbox 49 + 43 50 [screener] 44 51 # reuse your existing neomutt allowlist files 45 - screened_in = "~/.config/mutt/screened_in.txt" 46 - screened_out = "~/.config/mutt/screened_out.txt" 47 - feed = "~/.config/mutt/feed.txt" 48 - papertrail = "~/.config/mutt/papertrail.txt" 52 + screened_in = "~/.dotfiles/mutt/.lists/screened_in.txt" 53 + screened_out = "~/.dotfiles/mutt/.lists/screened_out.txt" 54 + feed = "~/.dotfiles/mutt/.lists/feed.txt" 55 + papertrail = "~/.dotfiles/mutt/.lists/papertrail.txt" 49 56 50 57 [folders] 51 - inbox = "INBOX" 52 - sent = "Sent" 53 - trash = "Trash" 54 - to_screen = "ToScreen" 55 - feed = "Feed" 56 - papertrail = "PaperTrail" 58 + inbox = "INBOX" 59 + sent = "Sent" 60 + trash = "Trash" 61 + drafts = "Drafts" 62 + to_screen = "ToScreen" 63 + feed = "Feed" 64 + papertrail = "PaperTrail" 57 65 screened_out = "ScreenedOut" 66 + archive = "Archive" 67 + waiting = "Waiting" 68 + scheduled = "Scheduled" 69 + someday = "Someday" 58 70 59 71 [ui] 60 72 theme = "dark" # dark | light | auto 61 73 inbox_count = 50 62 74 ``` 63 75 64 - Use an app-specific password (Gmail, Fastmail, etc.) rather than your main account password. 76 + Use an app-specific password (Gmail, Fastmail, Hostpoint, etc.) rather than your main account password. 65 77 66 78 ## Keybindings 67 79 68 - ### Inbox 80 + Press `?` inside neomd to open the interactive help overlay. Start typing to filter shortcuts. 81 + 82 + ### Navigation 69 83 70 84 | Key | Action | 71 85 |-----|--------| 72 - | `j` / `k` | Navigate up/down | 73 - | `Enter` | Open email | 74 - | `c` | Compose new email | 75 - | `Tab` | Switch folder (Inbox → ToScreen → Feed → PaperTrail) | 76 - | `r` | Refresh current folder | 77 - | `/` | Filter emails | 78 - | `q` | Quit | 86 + | `j` / `k` | Move down / up | 87 + | `gg` | Jump to top | 88 + | `G` | Jump to bottom | 89 + | `enter` / `l` | Open email | 90 + | `h` / `q` / `esc` | Back to inbox (from reader) | 91 + | `?` | Toggle help overlay (type to filter) | 79 92 80 - ### ToScreen folder 93 + ### Folders 81 94 82 95 | Key | Action | 83 96 |-----|--------| 84 - | `I` | Approve sender → move to Inbox, add to `screened_in.txt` | 85 - | `O` | Block sender → move to ScreenedOut, add to `screened_out.txt` | 86 - | `F` | Mark as Feed → move to Feed folder, add to `feed.txt` | 87 - | `P` | Mark as PaperTrail → move to PaperTrail, add to `papertrail.txt` | 97 + | `L` / `tab` | Next folder tab | 98 + | `H` / `shift+tab` | Previous folder tab | 99 + | `gi` | Go to Inbox | 100 + | `ga` | Go to Archive | 101 + | `gf` | Go to Feed | 102 + | `gp` | Go to PaperTrail | 103 + | `gt` | Go to Trash | 104 + | `gs` | Go to Sent | 105 + | `gk` | Go to ToScreen | 106 + | `go` | Go to ScreenedOut | 107 + | `gw` | Go to Waiting | 108 + | `gm` | Go to Someday | 88 109 89 - ### Reading 110 + ### Multi-select & Batch operations 90 111 91 112 | Key | Action | 92 113 |-----|--------| 93 - | `j` / `k` / `Space` | Scroll | 94 - | `q` / `Esc` | Back to inbox | 114 + | `space` | Mark / unmark email + advance cursor | 115 + | `U` | Clear all marks | 116 + | `x` | Delete marked (or cursor) → Trash | 117 + | `A` | Archive marked (or cursor) → Archive | 118 + 119 + All screener and move actions below apply to **all marked emails**, or just the cursor email if nothing is marked. 120 + 121 + ### Screener (any folder) 122 + 123 + | Key | Action | 124 + |-----|--------| 125 + | `I` | Approve sender → `screened_in.txt` + move to Inbox | 126 + | `O` | Block sender → `screened_out.txt` + move to ScreenedOut | 127 + | `F` | Mark as Feed → `feed.txt` + move to Feed | 128 + | `P` | Mark as PaperTrail → `papertrail.txt` + move to PaperTrail | 129 + | `S` | Dry-run screen Inbox (shows preview, then `y` to apply / `n` to cancel) | 130 + 131 + ### Move (no screener update) 132 + 133 + | Key | Action | 134 + |-----|--------| 135 + | `Mi` | Move to Inbox | 136 + | `Ma` | Move to Archive | 137 + | `Mf` | Move to Feed | 138 + | `Mp` | Move to PaperTrail | 139 + | `Mt` | Move to Trash | 140 + | `Mo` | Move to ScreenedOut | 141 + | `Mw` | Move to Waiting | 142 + | `Mm` | Move to Someday | 143 + 144 + ### Email actions 145 + 146 + | Key | Action | 147 + |-----|--------| 148 + | `N` | Toggle read/unread (applies to marked or cursor) | 149 + | `R` | Reload / refresh folder | 150 + | `r` | Reply (from reader) | 151 + | `c` | Compose new email | 152 + | `O` | Open in browser — `$BROWSER` or `w3m` (from reader) | 153 + | `a` | Switch account (if multiple configured) | 154 + | `/` | Filter emails | 155 + | `q` | Quit | 95 156 96 157 ### Composing 97 158 98 159 | Key | Action | 99 160 |-----|--------| 100 - | `Tab` / `Enter` | Move to next field | 101 - | `Enter` (on Subject) | Open `$EDITOR` with a `.md` temp file | 102 - | `Esc` | Cancel | 161 + | `tab` / `enter` | Move to next field | 162 + | `enter` (on Subject) | Open `$EDITOR` with a `.md` temp file | 163 + | `esc` | Cancel | 103 164 104 165 After saving and closing the editor, the email is sent automatically. 105 166 ··· 132 193 - [Bubbles](https://github.com/charmbracelet/bubbles) — list, viewport, textinput components 133 194 - [Glamour](https://github.com/charmbracelet/glamour) — Markdown → terminal rendering 134 195 - [Lipgloss](https://github.com/charmbracelet/lipgloss) — styling 135 - - [go-imap/v2](https://github.com/emersion/go-imap) — IMAP client 196 + - [go-imap/v2](https://github.com/emersion/go-imap) — IMAP client (RFC 6851 MOVE) 136 197 - [go-message](https://github.com/emersion/go-message) — MIME parsing 137 198 - [goldmark](https://github.com/yuin/goldmark) — Markdown → HTML for sending 199 + - [BurntSushi/toml](https://github.com/BurntSushi/toml) — config parsing 200 + 201 + ## Inspirations 202 + 203 + - [Neomutt](https://neomutt.org) — the gold standard terminal email client; neomd reuses its screener list format and borrows many keybindings 204 + - [HEY](https://www.hey.com/features/the-screener/) — the Screener concept: unknown senders wait for a decision before reaching your inbox 205 + - [hey-cli](https://github.com/sspaeti/hey-cli) — a Go CLI for HEY; provided the bubbletea patterns used here 206 + - [newsboat](https://newsboat.org) — RSS reader whose `O` open-in-browser binding and vim navigation feel inspired neomd's reader view 207 + - [emailmd.dev](https://www.emailmd.dev) — the idea that email should be written in Markdown 208 + - [charmbracelet/pop](https://github.com/charmbracelet/pop) — minimal Go email sender from Charm 209 + - [charmbracelet/glamour](https://github.com/charmbracelet/glamour) — Markdown rendering in the terminal 210 + - [kanagawa.nvim](https://github.com/rebelot/kanagawa.nvim) — the color palette used for the inbox 211 + - [msgvault](https://github.com/sspaeti/msgvault) — Go IMAP archiver; the IMAP client code in neomd is adapted from it
images/neomd.png

This is a binary file and will not be displayed.

+31 -5
internal/config/config.go
··· 39 39 Feed string `toml:"feed"` 40 40 PaperTrail string `toml:"papertrail"` 41 41 ScreenedOut string `toml:"screened_out"` 42 + Archive string `toml:"archive"` 43 + Waiting string `toml:"waiting"` 44 + Scheduled string `toml:"scheduled"` 45 + Someday string `toml:"someday"` 42 46 } 43 47 44 48 // UIConfig holds display preferences. ··· 49 53 50 54 // Config is the root neomd configuration. 51 55 type Config struct { 52 - Account AccountConfig `toml:"account"` 56 + // Accounts is the list of email accounts (use [[accounts]] in config.toml). 57 + // For a single account the legacy [account] block is also accepted. 58 + Accounts []AccountConfig `toml:"accounts"` 59 + Account AccountConfig `toml:"account"` // legacy single-account fallback 60 + 53 61 Screener ScreenerConfig `toml:"screener"` 54 62 Folders FoldersConfig `toml:"folders"` 55 63 UI UIConfig `toml:"ui"` 56 64 } 57 65 66 + // ActiveAccounts returns the list of configured accounts. 67 + // Falls back to the legacy single [account] block if [[accounts]] is empty. 68 + func (c *Config) ActiveAccounts() []AccountConfig { 69 + if len(c.Accounts) > 0 { 70 + return c.Accounts 71 + } 72 + if c.Account.User != "" { 73 + return []AccountConfig{c.Account} 74 + } 75 + return nil 76 + } 77 + 58 78 // DefaultPath returns ~/.config/neomd/config.toml. 59 79 func DefaultPath() string { 60 80 home, _ := os.UserHomeDir() ··· 94 114 home, _ := os.UserHomeDir() 95 115 muttDir := filepath.Join(home, ".config", "mutt") 96 116 return &Config{ 97 - Account: AccountConfig{ 98 - Name: "Personal", 99 - IMAP: "imap.example.com:993", 100 - SMTP: "smtp.example.com:587", 117 + Accounts: []AccountConfig{ 118 + { 119 + Name: "Personal", 120 + IMAP: "imap.example.com:993", 121 + SMTP: "smtp.example.com:587", 122 + }, 101 123 }, 102 124 Screener: ScreenerConfig{ 103 125 ScreenedIn: filepath.Join(muttDir, "screened_in.txt"), ··· 114 136 Feed: "Feed", 115 137 PaperTrail: "PaperTrail", 116 138 ScreenedOut: "ScreenedOut", 139 + Archive: "Archive", 140 + Waiting: "Waiting", 141 + Scheduled: "Scheduled", 142 + Someday: "Someday", 117 143 }, 118 144 UI: UIConfig{ 119 145 Theme: "dark",
+31 -21
internal/imap/client.go
··· 29 29 Date time.Time 30 30 Seen bool 31 31 Folder string 32 + Size uint32 // RFC822 size in bytes 32 33 } 33 34 34 35 // Config holds connection parameters. ··· 179 180 } 180 181 181 182 msgs, err := conn.Fetch(fetchSet, &imap.FetchOptions{ 182 - UID: true, 183 - Flags: true, 184 - Envelope: true, 183 + UID: true, 184 + Flags: true, 185 + Envelope: true, 186 + RFC822Size: true, 185 187 }).Collect() 186 188 if err != nil { 187 189 return fmt.Errorf("FETCH headers: %w", err) ··· 218 220 e.To = m.Envelope.To[0].Addr() 219 221 } 220 222 } 223 + e.Size = uint32(m.RFC822Size) 221 224 emails = append(emails, e) 222 225 } 223 226 return nil ··· 258 261 return body, err 259 262 } 260 263 261 - // MoveMessage copies uid from src to dst, then deletes it from src. 264 + // MoveMessage moves uid from src to dst using the IMAP MOVE command (RFC 6851). 265 + // Falls back to COPY + STORE \Deleted + UID EXPUNGE on servers without MOVE. 266 + // Uses UID EXPUNGE (not plain EXPUNGE) in the fallback to avoid accidentally 267 + // expunging messages marked \Deleted by other concurrent clients. 262 268 func (c *Client) MoveMessage(ctx context.Context, src string, uid uint32, dst string) error { 263 269 if ctx == nil { 264 270 ctx = context.Background() ··· 267 273 if err := c.selectMailbox(src); err != nil { 268 274 return err 269 275 } 270 - 271 276 var uidSet imap.UIDSet 272 277 uidSet.AddNum(imap.UID(uid)) 273 - 274 - if _, err := conn.Copy(uidSet, dst).Wait(); err != nil { 275 - return fmt.Errorf("COPY to %s: %w", dst, err) 278 + if _, err := conn.Move(uidSet, dst).Wait(); err != nil { 279 + return fmt.Errorf("MOVE %d → %s: %w", uid, dst, err) 276 280 } 277 - 278 - if err := conn.Store(uidSet, &imap.StoreFlags{ 279 - Op: imap.StoreFlagsAdd, 280 - Silent: true, 281 - Flags: []imap.Flag{imap.FlagDeleted}, 282 - }, nil).Close(); err != nil { 283 - return fmt.Errorf("STORE \\Deleted: %w", err) 284 - } 285 - 286 - if err := conn.Expunge().Close(); err != nil { 287 - return fmt.Errorf("EXPUNGE: %w", err) 288 - } 289 - c.selectedMailbox = "" // state changed after EXPUNGE 281 + c.selectedMailbox = "" // mailbox state changes after move 290 282 return nil 291 283 }) 292 284 } ··· 304 296 uidSet.AddNum(imap.UID(uid)) 305 297 return conn.Store(uidSet, &imap.StoreFlags{ 306 298 Op: imap.StoreFlagsAdd, 299 + Flags: []imap.Flag{imap.FlagSeen}, 300 + }, nil).Close() 301 + }) 302 + } 303 + 304 + // MarkUnseen removes the \Seen flag, marking a message as unread. 305 + func (c *Client) MarkUnseen(ctx context.Context, folder string, uid uint32) error { 306 + if ctx == nil { 307 + ctx = context.Background() 308 + } 309 + return c.withConn(ctx, func(conn *imapclient.Client) error { 310 + if err := c.selectMailbox(folder); err != nil { 311 + return err 312 + } 313 + var uidSet imap.UIDSet 314 + uidSet.AddNum(imap.UID(uid)) 315 + return conn.Store(uidSet, &imap.StoreFlags{ 316 + Op: imap.StoreFlagsDel, 307 317 Flags: []imap.Flag{imap.FlagSeen}, 308 318 }, nil).Close() 309 319 })
+103 -43
internal/ui/inbox.go
··· 14 14 15 15 // emailItem wraps imap.Email to satisfy bubbles/list.Item. 16 16 type emailItem struct { 17 - email imap.Email 17 + email imap.Email 18 + index int // position in list (1-based) 19 + marked bool // selected for batch operation 18 20 } 19 21 20 22 func (e emailItem) FilterValue() string { ··· 27 29 // emailDelegate is a custom list.ItemDelegate that renders one email per row. 28 30 type emailDelegate struct{} 29 31 30 - func (d emailDelegate) Height() int { return 1 } 31 - func (d emailDelegate) Spacing() int { return 0 } 32 + func (d emailDelegate) Height() int { return 1 } 33 + func (d emailDelegate) Spacing() int { return 0 } 32 34 func (d emailDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 33 35 36 + // Column widths 37 + const ( 38 + colNumWidth = 4 // " 1 " 39 + colFlagWidth = 2 // "N " or " " 40 + colDateWidth = 7 // "Feb 03 " 41 + colSizeWidth = 7 // "(38.2K)" 42 + ) 43 + 34 44 func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { 35 45 e, ok := item.(emailItem) 36 46 if !ok { ··· 38 48 } 39 49 40 50 isSelected := index == m.Index() 51 + unread := !e.email.Seen 41 52 42 - // Unread indicator 43 - indicator := " " 44 - fromStyle := styleRead 45 - if !e.email.Seen { 46 - indicator = "● " 47 - fromStyle = styleUnread 48 - } 49 - 50 - // Truncate from and subject to fit terminal width 51 53 width := m.Width() 52 54 if width <= 0 { 53 55 width = 80 54 56 } 55 - dateStr := fmtDate(e.email.Date) 56 - dateWidth := len(dateStr) + 2 57 - fromMax := 25 58 - subjectMax := width - fromMax - dateWidth - 6 59 - if subjectMax < 10 { 60 - subjectMax = 10 57 + 58 + // Fixed columns 59 + num := fmt.Sprintf("%3d ", e.index) 60 + // Flag column: mark takes priority; show unread alongside mark 61 + flag := " " 62 + switch { 63 + case e.marked && !e.email.Seen: 64 + flag = "*N" 65 + case e.marked: 66 + flag = "* " 67 + case unread: 68 + flag = "N " 61 69 } 70 + dateStr := fmtDate(e.email.Date) + " " 71 + sizeStr := fmtSize(e.email.Size) 62 72 63 - from := truncate(e.email.From, fromMax) 64 - subject := truncate(e.email.Subject, subjectMax) 73 + fixed := colNumWidth + colFlagWidth + colDateWidth + colSizeWidth + 2 // 2 spaces padding 74 + fromMax := 20 75 + subjectMax := width - fixed - fromMax - 2 76 + if subjectMax < 8 { 77 + subjectMax = 8 78 + } 65 79 66 - row := fmt.Sprintf("%s%-*s %-*s %s", 67 - indicator, 68 - fromMax, from, 69 - subjectMax, subject, 70 - dateStr, 71 - ) 80 + from := truncate(cleanFrom(e.email.From), fromMax) 81 + subject := truncate(e.email.Subject, subjectMax) 72 82 73 83 if isSelected { 74 - row = styleSelected.Render(row) 75 - } else { 76 - // Apply from style to the whole line (unread = brighter) 77 - _ = fromStyle // style applied via indicator colour above 78 - row = lipgloss.NewStyle().Foreground(colorText).Render(row) 79 - if !e.email.Seen { 80 - row = lipgloss.NewStyle().Foreground(colorUnread).Bold(true).Render(row) 81 - } 84 + row := fmt.Sprintf("%s%s%s%-*s %-*s %s", 85 + num, flag, dateStr, 86 + fromMax, from, 87 + subjectMax, subject, 88 + sizeStr, 89 + ) 90 + fmt.Fprint(w, styleSelected.Render(row)) 91 + return 82 92 } 83 93 84 - fmt.Fprint(w, row) 94 + // Colorise each column separately 95 + numS := lipgloss.NewStyle().Foreground(colorNumber).Render(num) 96 + var flagS string 97 + switch { 98 + case e.marked: 99 + flagS = lipgloss.NewStyle().Foreground(colorDateCol).Bold(true).Render(flag) 100 + case unread: 101 + flagS = lipgloss.NewStyle().Foreground(colorAuthorUnread).Bold(true).Render(flag) 102 + default: 103 + flagS = lipgloss.NewStyle().Foreground(colorMuted).Render(flag) 104 + } 105 + dateS := lipgloss.NewStyle().Foreground(colorDateCol).Render(dateStr) 106 + 107 + fromStyle := lipgloss.NewStyle().Foreground(colorAuthorRead) 108 + subStyle := lipgloss.NewStyle().Foreground(colorSubjectRead) 109 + if unread { 110 + fromStyle = lipgloss.NewStyle().Foreground(colorAuthorUnread).Bold(true) 111 + subStyle = lipgloss.NewStyle().Foreground(colorSubjectUnread).Bold(true) 112 + } 113 + fromS := fromStyle.Render(fmt.Sprintf("%-*s", fromMax, from)) 114 + subS := subStyle.Render(fmt.Sprintf("%-*s", subjectMax, subject)) 115 + sizeS := lipgloss.NewStyle().Foreground(colorSizeCol).Render(sizeStr) 116 + 117 + fmt.Fprint(w, numS+flagS+dateS+fromS+" "+subS+" "+sizeS) 118 + } 119 + 120 + // cleanFrom strips the <addr> part when a display name is present. 121 + func cleanFrom(from string) string { 122 + if i := strings.Index(from, " <"); i > 0 { 123 + return from[:i] 124 + } 125 + return from 126 + } 127 + 128 + // fmtSize formats a byte count into a compact "(38.2K)" string like neomutt. 129 + func fmtSize(b uint32) string { 130 + switch { 131 + case b == 0: 132 + return " " 133 + case b < 1024: 134 + return fmt.Sprintf("(%4dB)", b) 135 + case b < 1024*1024: 136 + return fmt.Sprintf("(%4.0fK)", float64(b)/1024) 137 + default: 138 + return fmt.Sprintf("(%4.1fM)", float64(b)/(1024*1024)) 139 + } 85 140 } 86 141 87 142 func fmtDate(t time.Time) string { 88 143 if t.IsZero() { 89 - return "—" 144 + return " " 90 145 } 91 146 now := time.Now() 92 147 if t.Year() == now.Year() && t.YearDay() == now.YearDay() { 93 - return t.Format("15:04") 148 + return t.Format("15:04 ") 94 149 } 95 150 if t.Year() == now.Year() { 96 151 return t.Format("Jan 02") 97 152 } 98 - return t.Format("2006") 153 + return t.Format("Jan 06") 99 154 } 100 155 101 156 func truncate(s string, max int) string { 102 157 s = strings.TrimSpace(s) 103 - if len(s) <= max { 158 + if max <= 0 { 159 + return "" 160 + } 161 + // Count runes not bytes for proper unicode truncation 162 + runes := []rune(s) 163 + if len(runes) <= max { 104 164 return s 105 165 } 106 166 if max <= 1 { 107 167 return "…" 108 168 } 109 - return s[:max-1] + "…" 169 + return string(runes[:max-1]) + "…" 110 170 } 111 171 112 172 // newInboxList creates a bubbles/list configured for the email inbox. ··· 121 181 return l 122 182 } 123 183 124 - // setEmails replaces the list contents. 125 - func setEmails(l *list.Model, emails []imap.Email) tea.Cmd { 184 + // setEmails replaces the list contents, preserving marked state. 185 + func setEmails(l *list.Model, emails []imap.Email, marked map[uint32]bool) tea.Cmd { 126 186 items := make([]list.Item, len(emails)) 127 187 for i, e := range emails { 128 - items[i] = emailItem{email: e} 188 + items[i] = emailItem{email: e, index: i + 1, marked: marked[e.UID]} 129 189 } 130 190 return l.SetItems(items) 131 191 }
+764 -36
internal/ui/model.go
··· 11 11 "github.com/charmbracelet/bubbles/spinner" 12 12 "github.com/charmbracelet/bubbles/viewport" 13 13 tea "github.com/charmbracelet/bubbletea" 14 + "github.com/charmbracelet/lipgloss" 14 15 "github.com/sspaeti/neomd/internal/config" 15 16 "github.com/sspaeti/neomd/internal/editor" 16 17 "github.com/sspaeti/neomd/internal/imap" 18 + "github.com/sspaeti/neomd/internal/render" 17 19 "github.com/sspaeti/neomd/internal/screener" 18 20 "github.com/sspaeti/neomd/internal/smtp" 19 21 ) ··· 25 27 stateInbox viewState = iota 26 28 stateReading // reading a single email 27 29 stateCompose // composing a new email 30 + stateHelp // help overlay 28 31 ) 29 32 30 33 // async message types ··· 37 40 email *imap.Email 38 41 body string 39 42 } 40 - sendDoneMsg struct{ err error } 41 - screenDoneMsg struct{ err error } 42 - errMsg struct{ err error } 43 - editorDoneMsg struct { 43 + sendDoneMsg struct{ err error } 44 + screenDoneMsg struct{ err error } 45 + autoScreenDoneMsg struct{ moved int; err error } 46 + moveDoneMsg struct{ err error } 47 + batchDoneMsg struct{ err error } 48 + toggleSeenDoneMsg struct{ uid uint32; seen bool; err error } 49 + errMsg struct{ err error } 50 + editorDoneMsg struct { 44 51 to, subject, body string 45 52 err error 46 53 } 47 54 ) 48 55 56 + // autoScreenMove is a planned (not yet executed) IMAP move. 57 + type autoScreenMove struct { 58 + email *imap.Email 59 + dst string 60 + } 61 + 49 62 // Model is the root bubbletea model. 50 63 type Model struct { 51 64 cfg *config.Config 52 - imapCli *imap.Client 65 + accounts []config.AccountConfig // all configured accounts 66 + clients []*imap.Client // one IMAP client per account 67 + accountI int // index of the active account 53 68 screener *screener.Screener 54 69 55 70 state viewState ··· 69 84 // Reader 70 85 reader viewport.Model 71 86 openEmail *imap.Email 87 + openBody string // plain/markdown body of the open email (for external viewer) 72 88 73 89 // Compose 74 90 compose composeModel ··· 76 92 // Status / error 77 93 status string 78 94 isError bool 95 + 96 + // Auto-screen dry-run: populated by S, cleared by y/n 97 + pendingMoves []autoScreenMove 98 + 99 + // Marked emails for batch operations (UID → true) 100 + markedUIDs map[uint32]bool 101 + 102 + // Chord prefix: "g" or "M" while waiting for second key 103 + pendingKey string 104 + 105 + // prevState is the state to return to when closing the help overlay 106 + prevState viewState 107 + 108 + // helpSearch is the live filter string typed in the help overlay 109 + helpSearch string 79 110 } 80 111 81 112 // New creates and initialises the TUI model. 82 - func New(cfg *config.Config, imapCli *imap.Client, sc *screener.Screener) Model { 113 + func New(cfg *config.Config, clients []*imap.Client, sc *screener.Screener) Model { 83 114 sp := spinner.New() 84 115 sp.Spinner = spinner.Dot 85 116 86 117 return Model{ 87 - cfg: cfg, 88 - imapCli: imapCli, 89 - screener: sc, 90 - state: stateInbox, 91 - loading: true, 92 - folders: []string{"Inbox", "ToScreen", "Feed", "PaperTrail"}, 93 - compose: newComposeModel(), 94 - spinner: sp, 118 + cfg: cfg, 119 + accounts: cfg.ActiveAccounts(), 120 + clients: clients, 121 + screener: sc, 122 + state: stateInbox, 123 + loading: true, 124 + folders: []string{"Inbox", "ToScreen", "Feed", "PaperTrail", "Archive", "Waiting", "Someday", "Scheduled", "Sent", "Trash", "ScreenedOut"}, 125 + compose: newComposeModel(), 126 + spinner: sp, 127 + markedUIDs: make(map[uint32]bool), 128 + } 129 + } 130 + 131 + // activeAccount returns the currently selected AccountConfig. 132 + func (m Model) activeAccount() config.AccountConfig { 133 + if m.accountI < len(m.accounts) { 134 + return m.accounts[m.accountI] 95 135 } 136 + return m.accounts[0] 137 + } 138 + 139 + // imapCli returns the IMAP client for the active account. 140 + func (m Model) imapCli() *imap.Client { 141 + if m.accountI < len(m.clients) { 142 + return m.clients[m.accountI] 143 + } 144 + return m.clients[0] 96 145 } 97 146 98 147 func (m Model) Init() tea.Cmd { ··· 111 160 return m.cfg.Folders.Feed 112 161 case "PaperTrail": 113 162 return m.cfg.Folders.PaperTrail 163 + case "Sent": 164 + return m.cfg.Folders.Sent 165 + case "Trash": 166 + return m.cfg.Folders.Trash 167 + case "Archive": 168 + return m.cfg.Folders.Archive 169 + case "Waiting": 170 + return m.cfg.Folders.Waiting 171 + case "Scheduled": 172 + return m.cfg.Folders.Scheduled 173 + case "Someday": 174 + return m.cfg.Folders.Someday 175 + case "ScreenedOut": 176 + return m.cfg.Folders.ScreenedOut 114 177 default: 115 178 return m.cfg.Folders.Inbox 116 179 } ··· 120 183 121 184 func (m Model) fetchFolderCmd(folder string) tea.Cmd { 122 185 return func() tea.Msg { 123 - emails, err := m.imapCli.FetchHeaders(nil, folder, m.cfg.UI.InboxCount) 186 + emails, err := m.imapCli().FetchHeaders(nil, folder, m.cfg.UI.InboxCount) 124 187 if err != nil { 125 188 return errMsg{err} 126 189 } ··· 130 193 131 194 func (m Model) fetchBodyCmd(e *imap.Email) tea.Cmd { 132 195 return func() tea.Msg { 133 - body, err := m.imapCli.FetchBody(nil, e.Folder, e.UID) 196 + body, err := m.imapCli().FetchBody(nil, e.Folder, e.UID) 134 197 if err != nil { 135 198 return errMsg{err} 136 199 } ··· 139 202 } 140 203 141 204 func (m Model) sendEmailCmd(to, subject, body string) tea.Cmd { 142 - h, p := splitAddr(m.cfg.Account.SMTP) 205 + h, p := splitAddr(m.activeAccount().SMTP) 143 206 cfg := smtp.Config{ 144 207 Host: h, 145 208 Port: p, 146 - User: m.cfg.Account.User, 147 - Password: m.cfg.Account.Password, 148 - From: m.cfg.Account.From, 209 + User: m.activeAccount().User, 210 + Password: m.activeAccount().Password, 211 + From: m.activeAccount().From, 149 212 } 150 213 return func() tea.Msg { 151 214 return sendDoneMsg{smtp.Send(cfg, to, subject, body)} 152 215 } 153 216 } 154 217 218 + // toggleSeenCmd flips the \Seen flag on an email and updates local state. 219 + func (m Model) toggleSeenCmd(e *imap.Email) tea.Cmd { 220 + uid := e.UID 221 + folder := e.Folder 222 + newSeen := !e.Seen 223 + return func() tea.Msg { 224 + var err error 225 + if newSeen { 226 + err = m.imapCli().MarkSeen(nil, folder, uid) 227 + } else { 228 + err = m.imapCli().MarkUnseen(nil, folder, uid) 229 + } 230 + return toggleSeenDoneMsg{uid: uid, seen: newSeen, err: err} 231 + } 232 + } 233 + 234 + // moveEmailCmd moves a single email to dst without updating screener lists. 235 + func (m Model) moveEmailCmd(e *imap.Email, dst string) tea.Cmd { 236 + src := e.Folder 237 + uid := e.UID 238 + return func() tea.Msg { 239 + return moveDoneMsg{m.imapCli().MoveMessage(nil, src, uid, dst)} 240 + } 241 + } 242 + 243 + // targetEmails returns marked emails if any are marked, otherwise just the cursor email. 244 + func (m Model) targetEmails() []imap.Email { 245 + if len(m.markedUIDs) > 0 { 246 + var out []imap.Email 247 + for _, e := range m.emails { 248 + if m.markedUIDs[e.UID] { 249 + out = append(out, e) 250 + } 251 + } 252 + return out 253 + } 254 + if e := selectedEmail(m.inbox); e != nil { 255 + return []imap.Email{*e} 256 + } 257 + return nil 258 + } 259 + 260 + // batchMoveCmd moves a slice of emails to dst, emitting batchDoneMsg. 261 + func (m Model) batchMoveCmd(emails []imap.Email, dst string) tea.Cmd { 262 + type mv struct{ folder string; uid uint32 } 263 + moves := make([]mv, len(emails)) 264 + for i, e := range emails { 265 + moves[i] = mv{e.Folder, e.UID} 266 + } 267 + return func() tea.Msg { 268 + for i, mv := range moves { 269 + if err := m.imapCli().MoveMessage(nil, mv.folder, mv.uid, dst); err != nil { 270 + return batchDoneMsg{fmt.Errorf("stopped after %d/%d: %w", i, len(moves), err)} 271 + } 272 + } 273 + return batchDoneMsg{} 274 + } 275 + } 276 + 277 + // batchScreenerCmd runs a screener action (I/O/F/P) on multiple emails. 278 + func (m Model) batchScreenerCmd(emails []imap.Email, action string) tea.Cmd { 279 + sc := m.screener 280 + cfg := m.cfg 281 + type op struct{ from, srcFolder string; uid uint32; dst string } 282 + ops := make([]op, 0, len(emails)) 283 + for _, e := range emails { 284 + var dst string 285 + switch action { 286 + case "I": 287 + dst = cfg.Folders.Inbox 288 + case "O": 289 + dst = cfg.Folders.ScreenedOut 290 + case "F": 291 + dst = cfg.Folders.Feed 292 + case "P": 293 + dst = cfg.Folders.PaperTrail 294 + } 295 + ops = append(ops, op{e.From, e.Folder, e.UID, dst}) 296 + } 297 + return func() tea.Msg { 298 + for i, o := range ops { 299 + var err error 300 + switch action { 301 + case "I": 302 + err = sc.Approve(o.from) 303 + case "O": 304 + err = sc.Block(o.from) 305 + case "F": 306 + err = sc.MarkFeed(o.from) 307 + case "P": 308 + err = sc.MarkPaperTrail(o.from) 309 + } 310 + if err != nil { 311 + return batchDoneMsg{fmt.Errorf("stopped after %d/%d: %w", i, len(ops), err)} 312 + } 313 + if o.dst != "" && o.dst != o.srcFolder { 314 + if err := m.imapCli().MoveMessage(nil, o.srcFolder, o.uid, o.dst); err != nil { 315 + return batchDoneMsg{fmt.Errorf("stopped after %d/%d: %w", i, len(ops), err)} 316 + } 317 + } 318 + } 319 + return batchDoneMsg{} 320 + } 321 + } 322 + 323 + // batchToggleSeenCmd toggles \Seen on multiple emails, emitting batchDoneMsg. 324 + func (m Model) batchToggleSeenCmd(emails []imap.Email) tea.Cmd { 325 + type op struct{ folder string; uid uint32; markSeen bool } 326 + ops := make([]op, len(emails)) 327 + for i, e := range emails { 328 + ops[i] = op{e.Folder, e.UID, !e.Seen} 329 + } 330 + return func() tea.Msg { 331 + for _, o := range ops { 332 + var err error 333 + if o.markSeen { 334 + err = m.imapCli().MarkSeen(nil, o.folder, o.uid) 335 + } else { 336 + err = m.imapCli().MarkUnseen(nil, o.folder, o.uid) 337 + } 338 + if err != nil { 339 + return batchDoneMsg{err} 340 + } 341 + } 342 + return batchDoneMsg{} 343 + } 344 + } 345 + 346 + // previewAutoScreen classifies the current inbox emails in-memory (no IMAP) 347 + // and returns the planned moves without executing anything. 348 + func (m Model) previewAutoScreen() []autoScreenMove { 349 + inboxFolder := m.cfg.Folders.Inbox 350 + var moves []autoScreenMove 351 + for i := range m.emails { 352 + e := &m.emails[i] 353 + cat := m.screener.Classify(e.From) 354 + var dst string 355 + switch cat { 356 + case screener.CategoryScreenedOut: 357 + dst = m.cfg.Folders.ScreenedOut 358 + case screener.CategoryFeed: 359 + dst = m.cfg.Folders.Feed 360 + case screener.CategoryPaperTrail: 361 + dst = m.cfg.Folders.PaperTrail 362 + case screener.CategoryToScreen: 363 + dst = m.cfg.Folders.ToScreen 364 + } 365 + if dst != "" && dst != inboxFolder { 366 + moves = append(moves, autoScreenMove{email: e, dst: dst}) 367 + } 368 + } 369 + return moves 370 + } 371 + 372 + // execAutoScreenCmd performs the IMAP moves for a pre-approved list of moves. 373 + func (m Model) execAutoScreenCmd(moves []autoScreenMove) tea.Cmd { 374 + src := m.cfg.Folders.Inbox 375 + return func() tea.Msg { 376 + for i, mv := range moves { 377 + if err := m.imapCli().MoveMessage(nil, src, mv.email.UID, mv.dst); err != nil { 378 + return autoScreenDoneMsg{moved: i, err: err} 379 + } 380 + } 381 + return autoScreenDoneMsg{moved: len(moves)} 382 + } 383 + } 384 + 155 385 func (m Model) screenerCmd(e *imap.Email, action string) tea.Cmd { 156 386 folder := m.activeFolder() 157 387 return func() tea.Msg { ··· 175 405 return errMsg{addErr} 176 406 } 177 407 if dst != "" && dst != folder { 178 - if err := m.imapCli.MoveMessage(nil, folder, e.UID, dst); err != nil { 408 + if err := m.imapCli().MoveMessage(nil, folder, e.UID, dst); err != nil { 179 409 return errMsg{err} 180 410 } 181 411 } ··· 211 441 case emailsLoadedMsg: 212 442 m.loading = false 213 443 m.emails = msg.emails 214 - cmd := setEmails(&m.inbox, msg.emails) 444 + m.markedUIDs = make(map[uint32]bool) // clear marks on folder reload 445 + cmd := setEmails(&m.inbox, msg.emails, m.markedUIDs) 215 446 return m, cmd 216 447 217 448 case bodyLoadedMsg: 218 449 m.loading = false 219 450 m.openEmail = msg.email 451 + m.openBody = msg.body 220 452 _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, m.cfg.UI.Theme, m.width) 221 453 m.state = stateReading 222 454 // Mark as seen in background (best-effort) 223 455 uid := msg.email.UID 224 456 folder := msg.email.Folder 225 - go func() { _ = m.imapCli.MarkSeen(nil, folder, uid) }() 457 + go func() { _ = m.imapCli().MarkSeen(nil, folder, uid) }() 226 458 return m, nil 227 459 228 460 case sendDoneMsg: ··· 249 481 m.loading = true 250 482 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 251 483 484 + case toggleSeenDoneMsg: 485 + if msg.err != nil { 486 + m.status = msg.err.Error() 487 + m.isError = true 488 + return m, nil 489 + } 490 + // Update local seen state so the N flag flips immediately 491 + for i := range m.emails { 492 + if m.emails[i].UID == msg.uid { 493 + m.emails[i].Seen = msg.seen 494 + break 495 + } 496 + } 497 + return m, setEmails(&m.inbox, m.emails, m.markedUIDs) 498 + 499 + case batchDoneMsg: 500 + m.loading = false 501 + m.markedUIDs = make(map[uint32]bool) 502 + if msg.err != nil { 503 + m.status = msg.err.Error() 504 + m.isError = true 505 + return m, nil 506 + } 507 + m.status = "Done." 508 + m.loading = true 509 + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 510 + 511 + case moveDoneMsg: 512 + m.loading = false 513 + if msg.err != nil { 514 + m.status = msg.err.Error() 515 + m.isError = true 516 + return m, nil 517 + } 518 + m.status = "Moved." 519 + m.isError = false 520 + m.loading = true 521 + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 522 + 523 + case autoScreenDoneMsg: 524 + m.loading = false 525 + if msg.err != nil { 526 + m.status = msg.err.Error() 527 + m.isError = true 528 + return m, nil 529 + } 530 + m.status = fmt.Sprintf("Screened %d email(s).", msg.moved) 531 + m.isError = false 532 + m.loading = true 533 + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 534 + 252 535 case errMsg: 253 536 m.loading = false 254 537 m.status = msg.err.Error() ··· 271 554 return m, tea.Batch(m.spinner.Tick, m.sendEmailCmd(msg.to, msg.subject, msg.body)) 272 555 273 556 case tea.KeyMsg: 557 + // ? opens help from any state; q/esc/? closes it 558 + if msg.String() == "?" { 559 + if m.state == stateHelp { 560 + m.state = m.prevState 561 + } else { 562 + m.prevState = m.state 563 + m.state = stateHelp 564 + } 565 + return m, nil 566 + } 274 567 switch m.state { 275 568 case stateInbox: 276 569 return m.updateInbox(msg) ··· 278 571 return m.updateReader(msg) 279 572 case stateCompose: 280 573 return m.updateCompose(msg) 574 + case stateHelp: 575 + return m.updateHelp(msg) 281 576 } 282 577 } 283 578 ··· 285 580 } 286 581 287 582 func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 583 + key := msg.String() 584 + 585 + // Handle pending chord prefix (g or M) — consume the second key 586 + if m.pendingKey != "" { 587 + prefix := m.pendingKey 588 + m.pendingKey = "" 589 + m.status = "" 590 + m.isError = false 591 + return m.handleChord(prefix, key) 592 + } 593 + 594 + // Clear pending auto-screen dry-run on any key except y/n 595 + if len(m.pendingMoves) > 0 && key != "y" && key != "n" { 596 + m.pendingMoves = nil 597 + } 288 598 m.status = "" 289 599 m.isError = false 290 600 291 - switch msg.String() { 601 + switch key { 292 602 case "ctrl+c", "q": 293 603 return m, tea.Quit 294 604 295 - case "tab": 605 + // ── Chord prefixes ────────────────────────────────────────────── 606 + case "g": 607 + m.pendingKey = "g" 608 + m.status = "go to: gi inbox ga archive gf feed gp papertrail gt trash gs sent gk toscreen go screened-out gw waiting gm someday gg top" 609 + return m, nil 610 + 611 + case "M": 612 + m.pendingKey = "M" 613 + m.status = "move to: Mi inbox Ma archive Mf feed Mp papertrail Mt trash Mo screened-out Mw waiting Mm someday" 614 + return m, nil 615 + 616 + // ── Mark for batch / delete ───────────────────────────────────── 617 + case "x": 618 + targets := m.targetEmails() 619 + if len(targets) == 0 { 620 + return m, nil 621 + } 622 + m.loading = true 623 + return m, tea.Batch(m.spinner.Tick, m.batchMoveCmd(targets, m.cfg.Folders.Trash)) 624 + 625 + case "U": // clear all marks 626 + m.markedUIDs = make(map[uint32]bool) 627 + return m, setEmails(&m.inbox, m.emails, m.markedUIDs) 628 + 629 + // ── Screener actions — operate on marked emails or cursor email ── 630 + case "I", "O", "F", "P": 631 + targets := m.targetEmails() 632 + if len(targets) == 0 { 633 + return m, nil 634 + } 635 + m.loading = true 636 + return m, tea.Batch(m.spinner.Tick, m.batchScreenerCmd(targets, key)) 637 + 638 + // A = archive (pure move, no screener update) 639 + case "A": 640 + targets := m.targetEmails() 641 + if len(targets) == 0 { 642 + return m, nil 643 + } 644 + m.loading = true 645 + return m, tea.Batch(m.spinner.Tick, m.batchMoveCmd(targets, m.cfg.Folders.Archive)) 646 + 647 + // ── Auto-screen dry-run (Inbox only) ──────────────────────────── 648 + case "S": 649 + if m.folders[m.activeFolderI] != "Inbox" { 650 + break 651 + } 652 + moves := m.previewAutoScreen() 653 + if len(moves) == 0 { 654 + m.status = "Nothing to screen — all senders already classified." 655 + return m, nil 656 + } 657 + counts := map[string]int{} 658 + for _, mv := range moves { 659 + counts[mv.dst]++ 660 + } 661 + summary := fmt.Sprintf("Would move %d email(s):", len(moves)) 662 + for dst, n := range counts { 663 + summary += fmt.Sprintf(" %d→%s", n, dst) 664 + } 665 + summary += " · y to apply, n to cancel" 666 + m.pendingMoves = moves 667 + m.status = summary 668 + return m, nil 669 + 670 + case "y": 671 + if len(m.pendingMoves) == 0 { 672 + break 673 + } 674 + moves := m.pendingMoves 675 + m.pendingMoves = nil 676 + m.loading = true 677 + return m, tea.Batch(m.spinner.Tick, m.execAutoScreenCmd(moves)) 678 + 679 + case "n": 680 + if len(m.pendingMoves) > 0 { 681 + m.pendingMoves = nil 682 + m.status = "Cancelled." 683 + return m, nil 684 + } 685 + 686 + // ── Navigation ────────────────────────────────────────────────── 687 + case "tab", "L": 296 688 m.activeFolderI = (m.activeFolderI + 1) % len(m.folders) 297 689 m.loading = true 298 690 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 299 691 692 + case "shift+tab", "H": 693 + m.activeFolderI = (m.activeFolderI - 1 + len(m.folders)) % len(m.folders) 694 + m.loading = true 695 + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 696 + 697 + case "G": 698 + m.inbox.Select(len(m.inbox.Items()) - 1) 699 + return m, nil 700 + 701 + case "a": 702 + if len(m.clients) > 1 { 703 + m.accountI = (m.accountI + 1) % len(m.clients) 704 + m.activeFolderI = 0 705 + m.loading = true 706 + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 707 + } 708 + 300 709 case "c": 301 710 m.state = stateCompose 302 711 m.compose.reset() 303 712 return m, nil 304 713 305 - case "r": 714 + case "R": 306 715 m.loading = true 307 716 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 308 717 309 - case "enter": 718 + case "enter", "l": 310 719 e := selectedEmail(m.inbox) 311 720 if e == nil { 312 721 return m, nil ··· 314 723 m.loading = true 315 724 return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) 316 725 317 - case "I", "O", "F", "P": 318 - if m.folders[m.activeFolderI] != "ToScreen" { 319 - break 320 - } 726 + case " ": // mark/unmark current email for batch, advance cursor 321 727 e := selectedEmail(m.inbox) 322 728 if e == nil { 323 - return m, nil 729 + break 730 + } 731 + if m.markedUIDs[e.UID] { 732 + delete(m.markedUIDs, e.UID) 733 + } else { 734 + m.markedUIDs[e.UID] = true 735 + } 736 + next := m.inbox.Index() + 1 737 + if next < len(m.inbox.Items()) { 738 + m.inbox.Select(next) 739 + } 740 + return m, setEmails(&m.inbox, m.emails, m.markedUIDs) 741 + 742 + case "N": // toggle read/unread on marked emails (or cursor email) 743 + targets := m.targetEmails() 744 + if len(targets) == 0 { 745 + break 746 + } 747 + if len(targets) == 1 && len(m.markedUIDs) == 0 { 748 + // single optimistic update — no reload needed 749 + next := m.inbox.Index() + 1 750 + if next < len(m.inbox.Items()) { 751 + m.inbox.Select(next) 752 + } 753 + return m, m.toggleSeenCmd(&targets[0]) 324 754 } 325 755 m.loading = true 326 - return m, tea.Batch(m.spinner.Tick, m.screenerCmd(e, msg.String())) 756 + return m, tea.Batch(m.spinner.Tick, m.batchToggleSeenCmd(targets)) 327 757 } 328 758 329 759 // Forward remaining keys (j/k navigation, filter /) to list ··· 332 762 return m, cmd 333 763 } 334 764 765 + // handleChord dispatches two-key sequences (g<x> and M<x>). 766 + func (m Model) handleChord(prefix, key string) (tea.Model, tea.Cmd) { 767 + switch prefix { 768 + case "g": 769 + if key == "g" { // gg = top of list 770 + m.inbox.Select(0) 771 + return m, nil 772 + } 773 + folderMap := map[string]string{ 774 + "i": "Inbox", 775 + "f": "Feed", 776 + "p": "PaperTrail", 777 + "t": "Trash", 778 + "s": "Sent", 779 + "k": "ToScreen", 780 + "a": "Archive", 781 + "w": "Waiting", 782 + "m": "Someday", 783 + "o": "ScreenedOut", 784 + } 785 + if name, ok := folderMap[key]; ok { 786 + for i, f := range m.folders { 787 + if f == name { 788 + if i == m.activeFolderI { 789 + return m, nil 790 + } 791 + m.activeFolderI = i 792 + m.loading = true 793 + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 794 + } 795 + } 796 + } 797 + m.status = fmt.Sprintf("unknown: g%s", key) 798 + 799 + case "M": 800 + targets := m.targetEmails() 801 + if len(targets) == 0 { 802 + return m, nil 803 + } 804 + dstMap := map[string]string{ 805 + "i": m.cfg.Folders.Inbox, 806 + "a": m.cfg.Folders.Archive, 807 + "f": m.cfg.Folders.Feed, 808 + "p": m.cfg.Folders.PaperTrail, 809 + "t": m.cfg.Folders.Trash, 810 + "o": m.cfg.Folders.ScreenedOut, 811 + "w": m.cfg.Folders.Waiting, 812 + "m": m.cfg.Folders.Someday, 813 + } 814 + if dst, ok := dstMap[key]; ok { 815 + m.loading = true 816 + return m, tea.Batch(m.spinner.Tick, m.batchMoveCmd(targets, dst)) 817 + } 818 + m.status = fmt.Sprintf("unknown: M%s", key) 819 + } 820 + return m, nil 821 + } 822 + 335 823 func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 336 824 switch msg.String() { 337 - case "q", "esc": 825 + case "q", "esc", "h": 338 826 m.state = stateInbox 339 827 return m, nil 828 + case "O": 829 + return m.openInExternalViewer() 830 + case "r": 831 + if m.openEmail != nil { 832 + return m.launchReplyCmd() 833 + } 340 834 } 341 835 var cmd tea.Cmd 342 836 m.reader, cmd = m.reader.Update(msg) 343 837 return m, cmd 344 838 } 345 839 840 + // openInExternalViewer renders the open email as HTML, writes it to a temp 841 + // file, and opens it with $BROWSER (falling back to w3m), same pattern as 842 + // newsboat's "open in browser" binding. 843 + func (m Model) openInExternalViewer() (tea.Model, tea.Cmd) { 844 + body := m.openBody 845 + if body == "" { 846 + return m, nil 847 + } 848 + 849 + browser := os.Getenv("BROWSER") 850 + if browser == "" { 851 + browser = "w3m" 852 + } 853 + 854 + // Render markdown → HTML so links are clickable in w3m. 855 + htmlBody, err := render.ToHTML(body) 856 + if err != nil { 857 + htmlBody = "<pre>" + body + "</pre>" 858 + } 859 + 860 + f, err := os.CreateTemp("", "neomd-view-*.html") 861 + if err != nil { 862 + m.status = "open: " + err.Error() 863 + m.isError = true 864 + return m, nil 865 + } 866 + tmpPath := f.Name() 867 + f.WriteString(htmlBody) //nolint 868 + f.Close() 869 + 870 + cmd := exec.Command(browser, tmpPath) 871 + return m, tea.ExecProcess(cmd, func(err error) tea.Msg { 872 + os.Remove(tmpPath) 873 + if err != nil { 874 + return errMsg{err} 875 + } 876 + return nil 877 + }) 878 + } 879 + 346 880 func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 347 881 switch msg.String() { 348 882 case "esc": ··· 395 929 }) 396 930 } 397 931 932 + func (m Model) launchReplyCmd() (tea.Model, tea.Cmd) { 933 + e := m.openEmail 934 + to := e.From 935 + subject := e.Subject 936 + if !strings.HasPrefix(strings.ToLower(subject), "re:") { 937 + subject = "Re: " + subject 938 + } 939 + prelude := editor.ReplyPrelude(to, subject, e.From, m.openBody) 940 + 941 + f, err := os.CreateTemp("", "neomd-*.md") 942 + if err != nil { 943 + m.status = err.Error() 944 + m.isError = true 945 + return m, nil 946 + } 947 + tmpPath := f.Name() 948 + f.WriteString(prelude) //nolint 949 + f.Close() 950 + 951 + editorBin := os.Getenv("EDITOR") 952 + if editorBin == "" { 953 + editorBin = "nvim" 954 + } 955 + 956 + cmd := exec.Command(editorBin, tmpPath) 957 + return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg { 958 + defer os.Remove(tmpPath) 959 + if execErr != nil { 960 + return editorDoneMsg{err: execErr} 961 + } 962 + raw, readErr := os.ReadFile(tmpPath) 963 + if readErr != nil { 964 + return editorDoneMsg{err: readErr} 965 + } 966 + return editorDoneMsg{to: to, subject: subject, body: string(raw)} 967 + }) 968 + } 969 + 398 970 // ── View ────────────────────────────────────────────────────────────────── 399 971 400 972 func (m Model) View() string { ··· 408 980 return m.viewReader() 409 981 case stateCompose: 410 982 return m.viewCompose() 983 + case stateHelp: 984 + return m.viewHelp() 411 985 } 412 986 return "" 413 987 } 414 988 415 989 func (m Model) viewInbox() string { 416 990 var b strings.Builder 417 - b.WriteString(folderTabs(m.folders, m.folders[m.activeFolderI]) + "\n") 991 + 992 + // Account indicator (only shown when more than one account configured) 993 + header := folderTabs(m.folders, m.folders[m.activeFolderI]) 994 + if len(m.accounts) > 1 { 995 + acct := styleDate.Render(" " + m.activeAccount().Name + " ·") 996 + header = acct + " " + header 997 + } 998 + if len(m.markedUIDs) > 0 { 999 + header += styleDate.Render(fmt.Sprintf(" [%d marked · U to clear]", len(m.markedUIDs))) 1000 + } 1001 + b.WriteString(header + "\n") 418 1002 b.WriteString(styleSeparator.Render(strings.Repeat("─", m.width)) + "\n") 419 1003 420 1004 if m.loading { ··· 429 1013 if m.status != "" { 430 1014 b.WriteString(statusBar(m.status, m.isError)) 431 1015 } else { 432 - b.WriteString(inboxHelp(m.folders[m.activeFolderI])) 1016 + help := inboxHelp(m.folders[m.activeFolderI]) 1017 + if len(m.accounts) > 1 { 1018 + help += styleHelp.Render(" · a switch account") 1019 + } 1020 + b.WriteString(help) 433 1021 } 434 1022 return b.String() 435 1023 } ··· 452 1040 b.WriteString(styleSeparator.Render(strings.Repeat("─", m.width)) + "\n\n") 453 1041 b.WriteString(m.compose.view() + "\n\n") 454 1042 b.WriteString(composeHelp(int(m.compose.step))) 1043 + return b.String() 1044 + } 1045 + 1046 + func (m Model) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 1047 + switch msg.String() { 1048 + case "esc": 1049 + if m.helpSearch != "" { 1050 + m.helpSearch = "" // first esc clears filter 1051 + } else { 1052 + m.state = m.prevState 1053 + } 1054 + case "q": 1055 + if m.helpSearch == "" { 1056 + m.state = m.prevState 1057 + } else { 1058 + m.helpSearch += "q" 1059 + } 1060 + case "backspace": 1061 + if len(m.helpSearch) > 0 { 1062 + m.helpSearch = m.helpSearch[:len([]rune(m.helpSearch))-1] 1063 + } 1064 + case "/": 1065 + // already in search mode — "/" is just a printable char if search active 1066 + if m.helpSearch == "" { 1067 + // start typing to search; "/" itself doesn't appear 1068 + } else { 1069 + m.helpSearch += "/" 1070 + } 1071 + default: 1072 + // printable single character: append to search 1073 + if len(msg.String()) == 1 { 1074 + m.helpSearch += msg.String() 1075 + } 1076 + } 1077 + return m, nil 1078 + } 1079 + 1080 + func (m Model) viewHelp() string { 1081 + type section struct { 1082 + title string 1083 + rows [][2]string // [key, description] 1084 + } 1085 + sections := []section{ 1086 + {"Navigation", [][2]string{ 1087 + {"j / k", "move down / up"}, 1088 + {"gg", "jump to top"}, 1089 + {"G", "jump to bottom"}, 1090 + {"enter / l", "open email"}, 1091 + {"h / q / esc", "back to inbox (from reader)"}, 1092 + }}, 1093 + {"Folders", [][2]string{ 1094 + {"L / tab", "next folder tab"}, 1095 + {"H / shift+tab", "previous folder tab"}, 1096 + {"gi", "go to Inbox"}, 1097 + {"ga", "go to Archive"}, 1098 + {"gf", "go to Feed"}, 1099 + {"gp", "go to PaperTrail"}, 1100 + {"gt", "go to Trash"}, 1101 + {"gs", "go to Sent"}, 1102 + {"gk", "go to ToScreen"}, 1103 + {"go", "go to ScreenedOut"}, 1104 + {"gw", "go to Waiting"}, 1105 + {"gm", "go to Someday"}, 1106 + }}, 1107 + {"Screener (marked or cursor, any folder)", [][2]string{ 1108 + {"I", "approve sender → screened_in + move to Inbox"}, 1109 + {"O", "block sender → screened_out + move to ScreenedOut"}, 1110 + {"F", "mark as Feed → feed.txt + move to Feed"}, 1111 + {"P", "mark as PaperTrail → papertrail.txt + move to PaperTrail"}, 1112 + {"A", "archive (move to Archive, no screener update)"}, 1113 + {"S", "dry-run screen inbox (then y to apply, n to cancel)"}, 1114 + }}, 1115 + {"Move (marked or cursor, no screener update)", [][2]string{ 1116 + {"x", "delete → Trash"}, 1117 + {"Mi", "move to Inbox"}, 1118 + {"Ma", "move to Archive"}, 1119 + {"Mf", "move to Feed"}, 1120 + {"Mp", "move to PaperTrail"}, 1121 + {"Mt", "move to Trash"}, 1122 + {"Mo", "move to ScreenedOut"}, 1123 + {"Mw", "move to Waiting"}, 1124 + {"Mm", "move to Someday"}, 1125 + }}, 1126 + {"Multi-select", [][2]string{ 1127 + {"space", "mark / unmark email + advance cursor"}, 1128 + {"U", "clear all marks"}, 1129 + {"x", "delete marked (or cursor) → Trash"}, 1130 + }}, 1131 + {"Email actions", [][2]string{ 1132 + {"N", "toggle read/unread (applies to marked or cursor)"}, 1133 + {"R", "reload / refresh folder"}, 1134 + {"r", "reply (from reader)"}, 1135 + {"c", "compose new email"}, 1136 + {"O (reader)", "open in browser (w3m / $BROWSER)"}, 1137 + {"a", "switch account (if multiple configured)"}, 1138 + }}, 1139 + {"General", [][2]string{ 1140 + {"/", "filter emails"}, 1141 + {"?", "toggle this help"}, 1142 + {"q", "quit (from inbox)"}, 1143 + }}, 1144 + } 1145 + 1146 + heading := styleHeader.Render(" Keyboard shortcuts") 1147 + sep := styleSeparator.Render(strings.Repeat("─", m.width)) 1148 + 1149 + keyStyle := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true).Width(22) 1150 + titleStyle := lipgloss.NewStyle().Foreground(colorDateCol).Bold(true) 1151 + descStyle := lipgloss.NewStyle().Foreground(colorText) 1152 + matchStyle := lipgloss.NewStyle().Foreground(colorAuthorUnread).Bold(true) 1153 + 1154 + filter := strings.ToLower(m.helpSearch) 1155 + 1156 + var b strings.Builder 1157 + b.WriteString(heading + "\n" + sep + "\n") 1158 + for _, sec := range sections { 1159 + // collect matching rows 1160 + var matched [][2]string 1161 + for _, row := range sec.rows { 1162 + if filter == "" || strings.Contains(strings.ToLower(row[0]), filter) || strings.Contains(strings.ToLower(row[1]), filter) { 1163 + matched = append(matched, row) 1164 + } 1165 + } 1166 + if len(matched) == 0 { 1167 + continue 1168 + } 1169 + b.WriteString("\n" + titleStyle.Render(" "+sec.title) + "\n") 1170 + for _, row := range matched { 1171 + b.WriteString(" " + keyStyle.Render(row[0]) + descStyle.Render(row[1]) + "\n") 1172 + } 1173 + } 1174 + 1175 + // Search bar 1176 + var searchLine string 1177 + if filter != "" { 1178 + searchLine = matchStyle.Render(" /"+m.helpSearch) + styleHelp.Render(" · esc to clear") 1179 + } else { 1180 + searchLine = styleHelp.Render(" type to filter · ? or q to close") 1181 + } 1182 + b.WriteString("\n" + searchLine) 455 1183 return b.String() 456 1184 } 457 1185
+3 -2
internal/ui/reader.go
··· 50 50 51 51 // readerHelp returns the one-line help string for the reader view. 52 52 func readerHelp() string { 53 - keys := []string{"j/k scroll", "space page", "q back"} 53 + keys := []string{"j/k scroll", "space/d page", "h/q back", "r reply", "O open in browser", "? help"} 54 54 return styleHelp.Render(" " + strings.Join(keys, " · ")) 55 55 } 56 56 57 57 // inboxHelp returns the one-line help string for the inbox view. 58 58 func inboxHelp(folder string) string { 59 - base := []string{"enter open", "c compose", "/ filter", "tab switch folder", "q quit"} 59 + base := []string{"enter/l open", "r reply", "c compose", "I/O/F/P/A screen", "g goto", "M move", "/ filter", "R reload", "? help", "q quit"} 60 + _ = folder 60 61 if folder == "ToScreen" { 61 62 base = []string{"I approve", "O block", "F feed", "P papertrail", "q back"} 62 63 }
+31 -10
internal/ui/styles.go
··· 2 2 3 3 import "github.com/charmbracelet/lipgloss" 4 4 5 + // Kanagawa palette — https://github.com/rebelot/kanagawa.nvim 5 6 var ( 6 - colorPrimary = lipgloss.Color("#ff5d62") // warm red, from newsletter theme 7 - colorMuted = lipgloss.Color("#6c7086") 8 - colorSubtle = lipgloss.Color("#313244") 9 - colorText = lipgloss.Color("#cdd6f4") 10 - colorUnread = lipgloss.Color("#cba6f7") // lavender for unread 11 - colorBg = lipgloss.Color("#1e1e2e") 12 - colorBorder = lipgloss.Color("#45475a") 13 - colorSelected = lipgloss.Color("#585b70") 7 + // ── Base chrome ───────────────────────────────────────────────────────── 8 + colorBg = lipgloss.Color("#1F1F28") // sumiInk1 — default background 9 + colorBorder = lipgloss.Color("#54546D") // sumiInk4 — borders, float edges 10 + colorSubtle = lipgloss.Color("#363646") // sumiInk3 — cursorline 11 + colorSelected = lipgloss.Color("#223249") // waveBlue1 — visual selection 12 + colorText = lipgloss.Color("#DCD7BA") // fujiWhite — default foreground 13 + colorMuted = lipgloss.Color("#727169") // fujiGray — comments, dim text 14 + 15 + // ── Primary accent (header, active tab) ───────────────────────────────── 16 + colorPrimary = lipgloss.Color("#7E9CD8") // crystalBlue — functions & titles 17 + 18 + // ── Unread indicator ──────────────────────────────────────────────────── 19 + colorUnread = lipgloss.Color("#957FB8") // oniViolet — statements & keywords 20 + 21 + // ── Index column colours ──────────────────────────────────────────────── 22 + colorNumber = lipgloss.Color("#7E9CD8") // crystalBlue — row number 23 + colorDateCol = lipgloss.Color("#E6C384") // carpYellow — date 24 + colorAuthorRead = lipgloss.Color("#E46876") // waveRed — sender (read) 25 + colorSubjectRead = lipgloss.Color("#7AA89F") // waveAqua2 — subject (read) 26 + colorSizeCol = lipgloss.Color("#727169") // fujiGray — size 27 + colorAuthorUnread = lipgloss.Color("#DCA561") // autumnYellow — sender (unread, warm standout) 28 + colorSubjectUnread = lipgloss.Color("#7FB4CA") // springBlue — subject (unread) 14 29 30 + // ── Status colours ────────────────────────────────────────────────────── 31 + colorError = lipgloss.Color("#C34043") // autumnRed 32 + colorSuccess = lipgloss.Color("#98BB6C") // springGreen 33 + ) 34 + 35 + var ( 15 36 styleHeader = lipgloss.NewStyle(). 16 37 Foreground(colorPrimary). 17 38 Bold(true). ··· 26 47 Padding(0, 1) 27 48 28 49 styleError = lipgloss.NewStyle(). 29 - Foreground(lipgloss.Color("#f38ba8")). 50 + Foreground(colorError). 30 51 Padding(0, 1) 31 52 32 53 styleEmailMeta = lipgloss.NewStyle(). ··· 73 94 Foreground(colorText) 74 95 75 96 styleSuccess = lipgloss.NewStyle(). 76 - Foreground(lipgloss.Color("#a6e3a1")) 97 + Foreground(colorSuccess) 77 98 ) 78 99 79 100 // folderTabs renders the folder switcher bar.