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.

added IMAP search overall folders, better filtering on local search, everything view, address autocomplete based on screened_in, feed, etc. list

sspaeti 723961f5 da446135

+610 -35
+10 -1
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## 2026-03-31 4 + - **IMAP search across all folders (`space /` or `:search`)** — server-side IMAP SEARCH across all configured folders (Inbox, Sent, Archive, Feed, etc.); results displayed in a temporary "Search" tab with `[Folder]` prefix on each subject; supports query prefixes: `from:simon`, `subject:invoice`, `to:team@`, or plain text to search all three fields; press `esc` to close results 5 + - **Filter preserves across actions** — the local `/` filter no longer clears when pressing `n` (toggle read), `m` (mark), `U` (clear marks), or sorting; filter stays active until `esc` 6 + - **Address autocomplete in compose** — To, Cc, and Bcc fields show autocomplete suggestions from screener lists (`screened_in.txt`, `feed.txt`, `papertrail.txt`); navigate with `ctrl+n`/`ctrl+p`/arrows, accept with `tab`; supports multi-address fields (autocomplete applies after the last comma) 7 + - **Everything view (`ge` or `:everything`)** — shows the 50 most recent emails across all folders in a temporary "Everything" tab, sorted by date descending; each subject prefixed with `[Folder]`; useful for finding emails that were screened out or moved to spam 8 + - **Draft signature fix** — re-opening a draft (`E`) no longer appends a duplicate signature; the draft body already contains it from the first compose 9 + - **Draft reader footer** — `E draft` now appears in the reader footer when viewing an email from the Drafts folder 10 + - **Android support (`make android`)** — cross-compile for Android ARM64; runs in Termux; documented in `docs/android.md` with install instructions and useful shortcuts 11 + - **Docs restructure** — detailed documentation moved from README to `docs/` folder: `docs/keybindings.md` (auto-generated), `docs/screener.md`, `docs/sending.md`, `docs/configuration.md`, `docs/android.md`; README kept concise with links 12 + 3 13 ## 2026-03-30 4 14 5 15 - added preview email in $BROWSER (images rendered, same as recipient sees) with `p` ··· 21 31 - **Default screener paths** — changed from `~/.config/mutt/` to `~/.config/neomd/lists/` for new installs; existing configs with custom paths are unaffected 22 32 - **Go prerequisite check in Makefile** — `make build`/`make install` now prints clear Go installation instructions instead of a cryptic error when `go` is not found 23 33 - **Pre-send preview (`p`)** — press `p` in the pre-send screen to open a browser preview of the composed email; renders through the same goldmark pipeline as sending, with local image paths converted to `file://` URLs so inline images from `[attach]` lines display correctly 24 - - **Docs restructure** — detailed documentation moved from README to `docs/` folder: `docs/keybindings.md` (auto-generated), `docs/screener.md`, `docs/sending.md`, `docs/configuration.md`, `docs/android.md`; README kept concise with links 25 34 26 35 ## 2026-03-29 27 36
+3
README.md
··· 70 70 - **Drafts** — `d` in pre-send saves to Drafts (IMAP APPEND); `E` in the reader re-opens a draft as an editable compose 71 71 - **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 72 72 - **Undo** — `u` reverses the last move or delete (`x`, `A`, `M*`) using the UIDPLUS destination UID 73 + - **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 74 + - **Address autocomplete** — To/Cc/Bcc fields autocomplete from screener lists; navigate with `ctrl+n`/`ctrl+p`, accept with `tab` 75 + - **Everything view** — `ge` or `:everything` shows the 50 most recent emails across all folders; find emails that were screened out, moved to spam, or otherwise hard to locate 73 76 - **Glamour reading** — incoming emails rendered as styled Markdown in the terminal 74 77 - **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 75 78 - **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut
+8 -2
docs/keybindings.md
··· 36 36 | `gw` | go to Waiting | 37 37 | `gm` | go to Someday | 38 38 | `gd` | go to Drafts | 39 + | `ge` | go to Everything — latest 50 emails across all folders | 39 40 | `gS` | go to Spam (not in tab rotation) | 40 41 41 42 ··· 82 83 | Key | Action | 83 84 |-----|--------| 84 85 | `<space>1 … <space>9` | jump to folder tab by number (Inbox=1, ToScreen=2, …) | 86 + | `<space>/` | IMAP search ALL emails on server (From + Subject) | 85 87 86 88 87 89 ### Sort (, prefix) ··· 136 138 | `:mark-read / :mr` | mark all emails in current folder as read | 137 139 | `:reload / :r` | reload current folder | 138 140 | `:check / :ch` | show screener classification for selected email | 141 + | `:everything / :ev` | show latest 50 emails across all folders | 142 + | `:search / :se` | IMAP search all emails on server (From + Subject + To) | 139 143 | `:delete-all / :da` | permanently delete ALL emails in current folder (y/n) | 140 144 | `:empty-trash / :et` | permanently delete ALL emails in Trash (y/n) | 141 145 | `:create-folders / :cf` | create missing IMAP folders from config (safe, idempotent) | ··· 147 151 148 152 | Key | Action | 149 153 |-----|--------| 150 - | `tab / enter` | move to next field | 154 + | `tab (To/Cc/Bcc)` | accept autocomplete suggestion or next field | 155 + | `ctrl+n / ctrl+p / arrows (To/Cc/Bcc)` | cycle through address suggestions | 151 156 | `enter (on Subject)` | open $EDITOR with a .md temp file | 152 157 | `esc` | cancel | 153 158 ··· 156 161 157 162 | Key | Action | 158 163 |-----|--------| 159 - | `/` | filter emails in current folder | 164 + | `/` | filter loaded emails (From + Subject, in-memory) | 165 + | `<space>/ or :search` | IMAP search ALL emails on server (From + Subject) | 160 166 | `?` | toggle this help | 161 167 | `q` | quit (from inbox) | 162 168
+150
internal/imap/client.go
··· 331 331 return false 332 332 } 333 333 334 + // SearchMessages searches a folder using IMAP SEARCH and returns matching emails 335 + // with headers fetched. The query is matched against From, Subject, and To headers. 336 + // Supports prefixes: "from:x", "subject:x", "to:x". Plain text searches all three. 337 + // Searches ALL messages on the server, not just loaded ones. 338 + func (c *Client) SearchMessages(ctx context.Context, folder, query string) ([]Email, error) { 339 + if ctx == nil { 340 + ctx = context.Background() 341 + } 342 + if query == "" { 343 + return nil, nil 344 + } 345 + 346 + criteria := buildSearchCriteria(query) 347 + 348 + var uids []uint32 349 + err := c.withConn(ctx, func(conn *imapclient.Client) error { 350 + if err := c.selectMailbox(folder); err != nil { 351 + return err 352 + } 353 + 354 + searchData, err := conn.UIDSearch(criteria, nil).Wait() 355 + if err != nil { 356 + return fmt.Errorf("UID SEARCH: %w", err) 357 + } 358 + uidSet, ok := searchData.All.(imap.UIDSet) 359 + if !ok { 360 + return nil 361 + } 362 + nums, _ := uidSet.Nums() 363 + for _, u := range nums { 364 + uids = append(uids, uint32(u)) 365 + } 366 + return nil 367 + }) 368 + if err != nil { 369 + return nil, err 370 + } 371 + if len(uids) == 0 { 372 + return nil, nil 373 + } 374 + 375 + // Cap results per folder to avoid huge fetches 376 + if len(uids) > 100 { 377 + uids = uids[len(uids)-100:] // keep newest (highest UIDs) 378 + } 379 + 380 + return c.FetchHeadersByUID(ctx, folder, uids) 381 + } 382 + 383 + // SearchAllFolders searches across multiple folders and returns combined results. 384 + // Each folder is SELECTed and searched individually (IMAP limitation). 385 + func (c *Client) SearchAllFolders(ctx context.Context, folders []string, query string) ([]Email, error) { 386 + if query == "" { 387 + return nil, nil 388 + } 389 + var all []Email 390 + for _, folder := range folders { 391 + emails, err := c.SearchMessages(ctx, folder, query) 392 + if err != nil { 393 + // Skip folders that fail (e.g. don't exist on this server) 394 + continue 395 + } 396 + all = append(all, emails...) 397 + } 398 + return all, nil 399 + } 400 + 401 + // buildSearchCriteria parses a query string into IMAP SearchCriteria. 402 + // Supports prefixes: "from:value", "subject:value", "to:value". 403 + // Plain text without a prefix searches OR(FROM, SUBJECT, TO). 404 + func buildSearchCriteria(query string) *imap.SearchCriteria { 405 + q := strings.TrimSpace(query) 406 + lower := strings.ToLower(q) 407 + 408 + switch { 409 + case strings.HasPrefix(lower, "from:"): 410 + val := strings.TrimSpace(q[5:]) 411 + return &imap.SearchCriteria{ 412 + Header: []imap.SearchCriteriaHeaderField{{Key: "From", Value: val}}, 413 + } 414 + case strings.HasPrefix(lower, "subject:"): 415 + val := strings.TrimSpace(q[8:]) 416 + return &imap.SearchCriteria{ 417 + Header: []imap.SearchCriteriaHeaderField{{Key: "Subject", Value: val}}, 418 + } 419 + case strings.HasPrefix(lower, "to:"): 420 + val := strings.TrimSpace(q[3:]) 421 + return &imap.SearchCriteria{ 422 + Header: []imap.SearchCriteriaHeaderField{{Key: "To", Value: val}}, 423 + } 424 + default: 425 + // Plain text: OR(FROM q, OR(SUBJECT q, TO q)) 426 + return &imap.SearchCriteria{ 427 + Or: [][2]imap.SearchCriteria{ 428 + { 429 + {Header: []imap.SearchCriteriaHeaderField{{Key: "From", Value: q}}}, 430 + {Or: [][2]imap.SearchCriteria{ 431 + { 432 + {Header: []imap.SearchCriteriaHeaderField{{Key: "Subject", Value: q}}}, 433 + {Header: []imap.SearchCriteriaHeaderField{{Key: "To", Value: q}}}, 434 + }, 435 + }}, 436 + }, 437 + }, 438 + } 439 + } 440 + } 441 + 442 + // FetchLatest fetches the N most recent emails (by UID, descending) from a folder. 443 + // Uses UID SEARCH ALL to get all UIDs, takes the last N, and fetches headers. 444 + func (c *Client) FetchLatest(ctx context.Context, folder string, n int) ([]Email, error) { 445 + uids, err := c.SearchUIDs(ctx, folder) 446 + if err != nil { 447 + return nil, err 448 + } 449 + if len(uids) == 0 { 450 + return nil, nil 451 + } 452 + // UIDs are ascending; take the last N (newest) 453 + if len(uids) > n { 454 + uids = uids[len(uids)-n:] 455 + } 456 + return c.FetchHeadersByUID(ctx, folder, uids) 457 + } 458 + 459 + // FetchLatestAllFolders fetches the N most recent emails across all given folders, 460 + // sorted by date descending. Takes a few per folder proportionally. 461 + func (c *Client) FetchLatestAllFolders(ctx context.Context, folders []string, total int) ([]Email, error) { 462 + perFolder := total / len(folders) 463 + if perFolder < 5 { 464 + perFolder = 5 465 + } 466 + var all []Email 467 + for _, folder := range folders { 468 + emails, err := c.FetchLatest(ctx, folder, perFolder) 469 + if err != nil { 470 + continue // skip folders that fail 471 + } 472 + all = append(all, emails...) 473 + } 474 + // Sort by date descending and cap 475 + sort.Slice(all, func(i, j int) bool { 476 + return all[i].Date.After(all[j].Date) 477 + }) 478 + if len(all) > total { 479 + all = all[:total] 480 + } 481 + return all, nil 482 + } 483 + 334 484 // FetchHeadersByUID fetches envelope headers for a specific slice of UIDs. 335 485 // Callers should pass small batches (≤200) to avoid oversized IMAP requests. 336 486 func (c *Client) FetchHeadersByUID(ctx context.Context, folder string, uids []uint32) ([]Email, error) {
+17
internal/screener/screener.go
··· 84 84 return s, nil 85 85 } 86 86 87 + // AllAddresses returns a deduplicated slice of all known email addresses 88 + // from screened_in, feed, and papertrail lists. Useful for autocomplete. 89 + // Excludes screened_out and spam since you wouldn't want to email those. 90 + func (s *Screener) AllAddresses() []string { 91 + seen := make(map[string]bool) 92 + var addrs []string 93 + for _, m := range []map[string]bool{s.screenedIn, s.feed, s.paperTrail} { 94 + for addr := range m { 95 + if !seen[addr] { 96 + seen[addr] = true 97 + addrs = append(addrs, addr) 98 + } 99 + } 100 + } 101 + return addrs 102 + } 103 + 87 104 // Classify returns the category for a given "from" email address. 88 105 // The address is normalised to lowercase before matching. 89 106 func (s *Screener) Classify(from string) Category {
+20
internal/ui/cmdline.go
··· 123 123 }, 124 124 }, 125 125 { 126 + name: "everything", 127 + aliases: []string{"ev"}, 128 + desc: "show latest 50 emails across all folders (newest first)", 129 + run: func(m *Model) (tea.Model, tea.Cmd) { 130 + m.loading = true 131 + return m, tea.Batch(m.spinner.Tick, m.fetchEverythingCmd()) 132 + }, 133 + }, 134 + { 135 + name: "search", 136 + aliases: []string{"se"}, 137 + desc: "IMAP search all emails in current folder (From + Subject)", 138 + run: func(m *Model) (tea.Model, tea.Cmd) { 139 + m.imapSearchActive = true 140 + m.imapSearchText = "" 141 + m.imapSearchResults = false 142 + return m, nil 143 + }, 144 + }, 145 + { 126 146 name: "go-spam", 127 147 aliases: []string{"spam"}, 128 148 desc: "open Spam folder (not in tab rotation — use :go-spam to visit)",
+165 -23
internal/ui/compose.go
··· 1 1 package ui 2 2 3 3 import ( 4 + "sort" 5 + "strings" 6 + 4 7 "github.com/charmbracelet/bubbles/textinput" 5 8 tea "github.com/charmbracelet/bubbletea" 6 9 ) ··· 23 26 subject textinput.Model 24 27 step composeStep 25 28 extraVisible bool // ctrl+b toggles Cc+Bcc together; off by default 29 + 30 + // Address autocomplete 31 + knownAddrs []string // all addresses from screener lists (set once) 32 + suggestions []string // current matching suggestions 33 + suggestI int // selected suggestion index (-1 = none) 26 34 } 27 35 28 36 func newComposeModel() composeModel { ··· 51 59 sub.Width = 60 52 60 sub.Prompt = "" 53 61 54 - return composeModel{to: to, cc: cc, bcc: bcc, subject: sub, step: stepTo} 62 + return composeModel{to: to, cc: cc, bcc: bcc, subject: sub, step: stepTo, suggestI: -1} 55 63 } 56 64 57 - // reset clears all fields and refocuses on To. 65 + // reset clears all fields and refocuses on To. Preserves knownAddrs. 58 66 func (c *composeModel) reset() { 59 67 c.to.Reset() 60 68 c.cc.Reset() ··· 66 74 c.cc.Blur() 67 75 c.bcc.Blur() 68 76 c.subject.Blur() 77 + c.suggestions = nil 78 + c.suggestI = -1 79 + // knownAddrs is intentionally preserved 80 + } 81 + 82 + // activeField returns the textinput currently being edited. 83 + func (c *composeModel) activeField() *textinput.Model { 84 + switch c.step { 85 + case stepTo: 86 + return &c.to 87 + case stepCC: 88 + return &c.cc 89 + case stepBCC: 90 + return &c.bcc 91 + default: 92 + return &c.subject 93 + } 94 + } 95 + 96 + // isAddrField returns true if the current step is an address field (To/Cc/Bcc). 97 + func (c *composeModel) isAddrField() bool { 98 + return c.step == stepTo || c.step == stepCC || c.step == stepBCC 99 + } 100 + 101 + // updateSuggestions refreshes the suggestion list based on the current input. 102 + func (c *composeModel) updateSuggestions() { 103 + c.suggestI = -1 104 + c.suggestions = nil 105 + if !c.isAddrField() || len(c.knownAddrs) == 0 { 106 + return 107 + } 108 + // Get the last address being typed (after the last comma) 109 + input := c.activeField().Value() 110 + lastPart := input 111 + if i := strings.LastIndex(input, ","); i >= 0 { 112 + lastPart = strings.TrimSpace(input[i+1:]) 113 + } 114 + if lastPart == "" { 115 + return 116 + } 117 + query := strings.ToLower(lastPart) 118 + for _, addr := range c.knownAddrs { 119 + if strings.Contains(strings.ToLower(addr), query) { 120 + c.suggestions = append(c.suggestions, addr) 121 + } 122 + } 123 + // Sort: prefix matches first, then alphabetical 124 + sort.Slice(c.suggestions, func(i, j int) bool { 125 + ip := strings.HasPrefix(strings.ToLower(c.suggestions[i]), query) 126 + jp := strings.HasPrefix(strings.ToLower(c.suggestions[j]), query) 127 + if ip != jp { 128 + return ip 129 + } 130 + return c.suggestions[i] < c.suggestions[j] 131 + }) 132 + if len(c.suggestions) > 8 { 133 + c.suggestions = c.suggestions[:8] 134 + } 135 + } 136 + 137 + // applySuggestion inserts the selected suggestion into the active field. 138 + func (c *composeModel) applySuggestion() { 139 + if c.suggestI < 0 || c.suggestI >= len(c.suggestions) { 140 + return 141 + } 142 + field := c.activeField() 143 + input := field.Value() 144 + selected := c.suggestions[c.suggestI] 145 + 146 + // Replace the last partial address with the full one 147 + if i := strings.LastIndex(input, ","); i >= 0 { 148 + prefix := input[:i+1] + " " 149 + field.SetValue(prefix + selected) 150 + } else { 151 + field.SetValue(selected) 152 + } 153 + field.SetCursor(len(field.Value())) 154 + c.suggestions = nil 155 + c.suggestI = -1 69 156 } 70 157 71 158 // update handles key input for the compose form. ··· 89 176 } 90 177 return c, nil, false 91 178 92 - case "tab", "enter": 93 - switch c.step { 94 - case stepTo: 95 - if c.extraVisible { 96 - c.step = stepCC 97 - c.to.Blur() 98 - c.cc.Focus() 99 - } else { 100 - c.step = stepSubject 101 - c.to.Blur() 102 - c.subject.Focus() 179 + case "tab": 180 + // Tab with suggestions → accept suggestion 181 + if len(c.suggestions) > 0 && c.isAddrField() { 182 + if c.suggestI < 0 { 183 + c.suggestI = 0 103 184 } 185 + c.applySuggestion() 104 186 return c, nil, false 105 - case stepCC: 106 - c.step = stepBCC 107 - c.cc.Blur() 108 - c.bcc.Focus() 187 + } 188 + // Tab without suggestions → next field (same as enter) 189 + return c.advanceField() 190 + 191 + case "enter": 192 + // Enter always advances to next field 193 + if len(c.suggestions) > 0 && c.suggestI >= 0 { 194 + c.applySuggestion() 195 + } 196 + return c.advanceField() 197 + 198 + case "down", "ctrl+n": 199 + if len(c.suggestions) > 0 { 200 + c.suggestI = (c.suggestI + 1) % len(c.suggestions) 109 201 return c, nil, false 110 - case stepBCC: 111 - c.step = stepSubject 112 - c.bcc.Blur() 113 - c.subject.Focus() 202 + } 203 + 204 + case "up", "ctrl+p": 205 + if len(c.suggestions) > 0 { 206 + if c.suggestI <= 0 { 207 + c.suggestI = len(c.suggestions) - 1 208 + } else { 209 + c.suggestI-- 210 + } 114 211 return c, nil, false 115 - case stepSubject: 116 - return c, nil, true 117 212 } 118 213 } 119 214 } ··· 129 224 default: 130 225 c.subject, cmd = c.subject.Update(msg) 131 226 } 227 + c.updateSuggestions() 132 228 return c, cmd, false 133 229 } 134 230 231 + // advanceField moves to the next compose field. Returns (model, cmd, launchEditor). 232 + func (c composeModel) advanceField() (composeModel, tea.Cmd, bool) { 233 + c.suggestions = nil 234 + c.suggestI = -1 235 + switch c.step { 236 + case stepTo: 237 + if c.extraVisible { 238 + c.step = stepCC 239 + c.to.Blur() 240 + c.cc.Focus() 241 + } else { 242 + c.step = stepSubject 243 + c.to.Blur() 244 + c.subject.Focus() 245 + } 246 + return c, nil, false 247 + case stepCC: 248 + c.step = stepBCC 249 + c.cc.Blur() 250 + c.bcc.Focus() 251 + return c, nil, false 252 + case stepBCC: 253 + c.step = stepSubject 254 + c.bcc.Blur() 255 + c.subject.Focus() 256 + return c, nil, false 257 + case stepSubject: 258 + return c, nil, true 259 + } 260 + return c, nil, false 261 + } 262 + 135 263 // view renders the compose header form. 136 264 func (c composeModel) view() string { 137 265 toLabel := styleInputLabel.Render("To:") ··· 169 297 } 170 298 171 299 out += subLabel + subField 300 + 301 + // Show autocomplete suggestions 302 + if len(c.suggestions) > 0 && c.isAddrField() { 303 + out += "\n" 304 + for i, s := range c.suggestions { 305 + if i == c.suggestI { 306 + out += styleSuggestionSelected.Render(" > "+s) + "\n" 307 + } else { 308 + out += styleSuggestion.Render(" "+s) + "\n" 309 + } 310 + } 311 + out += styleHelp.Render(" tab accept · ↑↓ select") 312 + } 313 + 172 314 return out 173 315 }
+8 -2
internal/ui/keys.go
··· 32 32 {"gw", "go to Waiting"}, 33 33 {"gm", "go to Someday"}, 34 34 {"gd", "go to Drafts"}, 35 + {"ge", "go to Everything — latest 50 emails across all folders"}, 35 36 {"gS", "go to Spam (not in tab rotation)"}, 36 37 }}, 37 38 {"Screener (marked or cursor, any folder)", [][2]string{ ··· 62 63 }}, 63 64 {"Leader Key Mappings (space prefix)", [][2]string{ 64 65 {"<space>1 … <space>9", "jump to folder tab by number (Inbox=1, ToScreen=2, …)"}, 66 + {"<space>/", "IMAP search ALL emails on server (From + Subject)"}, 65 67 }}, 66 68 {"Sort (, prefix)", [][2]string{ 67 69 {",m", "date newest first (default)"}, ··· 104 106 {":mark-read / :mr", "mark all emails in current folder as read"}, 105 107 {":reload / :r", "reload current folder"}, 106 108 {":check / :ch", "show screener classification for selected email"}, 109 + {":everything / :ev", "show latest 50 emails across all folders"}, 110 + {":search / :se", "IMAP search all emails on server (From + Subject + To)"}, 107 111 {":delete-all / :da", "permanently delete ALL emails in current folder (y/n)"}, 108 112 {":empty-trash / :et", "permanently delete ALL emails in Trash (y/n)"}, 109 113 {":create-folders / :cf", "create missing IMAP folders from config (safe, idempotent)"}, ··· 111 115 {":quit / :q", "quit neomd"}, 112 116 }}, 113 117 {"Composing", [][2]string{ 114 - {"tab / enter", "move to next field"}, 118 + {"tab (To/Cc/Bcc)", "accept autocomplete suggestion or next field"}, 119 + {"ctrl+n / ctrl+p / arrows (To/Cc/Bcc)", "cycle through address suggestions"}, 115 120 {"enter (on Subject)", "open $EDITOR with a .md temp file"}, 116 121 {"esc", "cancel"}, 117 122 }}, 118 123 {"General", [][2]string{ 119 - {"/", "filter emails in current folder"}, 124 + {"/", "filter loaded emails (From + Subject, in-memory)"}, 125 + {"<space>/ or :search", "IMAP search ALL emails on server (From + Subject)"}, 120 126 {"?", "toggle this help"}, 121 127 {"q", "quit (from inbox)"}, 122 128 }},
+52 -7
internal/ui/model.go
··· 183 183 cmdHistory []string // up to 5 most-recent distinct commands (newest first) 184 184 cmdHistI int // -1 = not browsing history; 0..n = history index 185 185 186 + // IMAP server-side search (ctrl+/) 187 + imapSearchActive bool // true while typing in the IMAP search prompt 188 + imapSearchText string // current IMAP search query 189 + imapSearchResults bool // true when displaying IMAP search results (esc to clear) 190 + 186 191 // filterActive / filterText implement our own inbox search. 187 192 // We bypass bubbles/list's built-in filter because SetShowTitle(false) 188 193 // hides the filter input. filterActive is true while the user is typing. ··· 212 217 sp := spinner.New() 213 218 sp.Spinner = spinner.Dot 214 219 220 + compose := newComposeModel() 221 + compose.knownAddrs = sc.AllAddresses() 222 + 215 223 return Model{ 216 224 cfg: cfg, 217 225 accounts: cfg.ActiveAccounts(), ··· 223 231 cmdHistory: loadCmdHistory(config.HistoryPath()), 224 232 cmdHistI: -1, 225 233 // Note: Spam is intentionally excluded from tabs — use :go-spam to visit. 226 - compose: newComposeModel(), 234 + compose: compose, 227 235 spinner: sp, 228 236 markedUIDs: make(map[uint32]bool), 229 237 sortField: "date", ··· 976 984 break 977 985 } 978 986 } 979 - return m, setEmails(&m.inbox, m.emails, m.markedUIDs) 987 + return m, m.applyFilter() 988 + 989 + case imapSearchResultMsg: 990 + return m.handleIMAPSearchResult(msg) 991 + 992 + case everythingResultMsg: 993 + return m.handleEverythingResult(msg) 980 994 981 995 case batchDoneMsg: 982 996 m.loading = false ··· 1283 1297 return m, nil 1284 1298 } 1285 1299 1300 + // ── IMAP server-side search mode ──────────────────────────────── 1301 + if m.imapSearchActive { 1302 + mm, cmd, consumed := m.updateIMAPSearch(key) 1303 + if consumed { 1304 + return mm, cmd 1305 + } 1306 + } 1307 + 1286 1308 // ── Our own filter mode ───────────────────────────────────────── 1287 1309 // When active, consume all keys for text input; no inbox commands fire. 1288 1310 if m.filterActive { ··· 1331 1353 case "ctrl+c", "q": 1332 1354 return m, tea.Quit 1333 1355 1356 + case "esc": 1357 + if m.imapSearchResults { 1358 + m.imapSearchResults = false 1359 + m.imapSearchText = "" 1360 + m.offTabFolder = "" 1361 + m.loading = true 1362 + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 1363 + } 1364 + 1334 1365 // ── Chord prefixes ────────────────────────────────────────────── 1335 1366 case "g": 1336 1367 m.pendingKey = "g" 1337 - 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 gd drafts gS spam gg top" 1368 + 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 gd drafts gS spam ge everything gg top" 1338 1369 return m, nil 1339 1370 1340 1371 case " ": // leader key — wait for digit or shortcut 1341 1372 m.pendingKey = " " 1342 - m.status = "leader: 1-9 folder tab (press digit, esc to cancel)" 1373 + m.status = "leader: 1-9 folder tab / IMAP search (esc to cancel)" 1343 1374 return m, nil 1344 1375 1345 1376 case "M": ··· 1380 1411 1381 1412 case "U": // clear all marks 1382 1413 m.markedUIDs = make(map[uint32]bool) 1383 - return m, setEmails(&m.inbox, m.emails, m.markedUIDs) 1414 + return m, m.applyFilter() 1384 1415 1385 1416 case "u": // undo last move/delete 1386 1417 if len(m.undoStack) == 0 { ··· 1489 1520 case "tab", "L": 1490 1521 m.activeFolderI = (m.activeFolderI + 1) % len(m.folders) 1491 1522 m.offTabFolder = "" 1523 + m.imapSearchResults = false 1492 1524 m.loading = true 1493 1525 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 1494 1526 1495 1527 case "shift+tab", "H": 1496 1528 m.activeFolderI = (m.activeFolderI - 1 + len(m.folders)) % len(m.folders) 1497 1529 m.offTabFolder = "" 1530 + m.imapSearchResults = false 1498 1531 m.loading = true 1499 1532 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 1500 1533 ··· 1565 1598 if next < len(m.inbox.Items()) { 1566 1599 m.inbox.Select(next) 1567 1600 } 1568 - return m, setEmails(&m.inbox, m.emails, m.markedUIDs) 1601 + return m, m.applyFilter() 1569 1602 1570 1603 } 1571 1604 ··· 1597 1630 } 1598 1631 return less 1599 1632 }) 1600 - return setEmails(&m.inbox, m.emails, m.markedUIDs) 1633 + return m.applyFilter() 1601 1634 } 1602 1635 1603 1636 // loadCmdHistory reads persisted command history from path (newest first). ··· 1664 1697 func (m Model) handleChord(prefix, key string) (tea.Model, tea.Cmd) { 1665 1698 switch prefix { 1666 1699 case " ": // leader key — digit jumps to folder tab (1-based) 1700 + if key == "/" { 1701 + m.imapSearchActive = true 1702 + m.imapSearchText = "" 1703 + m.imapSearchResults = false 1704 + return m, nil 1705 + } 1667 1706 if len(key) == 1 && key >= "1" && key <= "9" { 1668 1707 idx := int(key[0]-'1') // 0-based 1669 1708 if idx < len(m.folders) { ··· 1697 1736 m.offTabFolder = "Drafts" 1698 1737 m.status = "Drafts folder — press R to reload, tab to leave" 1699 1738 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.Folders.Drafts)) 1739 + } 1740 + if key == "e" { // ge — Everything: latest emails across all folders 1741 + m.loading = true 1742 + return m, tea.Batch(m.spinner.Tick, m.fetchEverythingCmd()) 1700 1743 } 1701 1744 folderMap := map[string]string{ 1702 1745 "i": "Inbox", ··· 2658 2701 b.WriteString("\n") 2659 2702 if m.cmdMode { 2660 2703 b.WriteString(viewCmdLine(m.cmdText, m.width)) 2704 + } else if m.imapSearchActive || m.imapSearchResults { 2705 + b.WriteString(m.viewIMAPSearchBar()) 2661 2706 } else if m.filterActive || m.filterText != "" { 2662 2707 cursor := "" 2663 2708 if m.filterActive {
+170
internal/ui/search.go
··· 1 + package ui 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + tea "github.com/charmbracelet/bubbletea" 8 + "github.com/sspaeti/neomd/internal/imap" 9 + ) 10 + 11 + // ── IMAP server-side search ────────────────────────────────────────────── 12 + // 13 + // Local filter (/) searches loaded emails in the current folder in-memory. 14 + // 15 + // IMAP search (space + /) queries ALL emails across ALL folders on the server 16 + // using IMAP SEARCH. Results are displayed in a temporary "Search" tab 17 + // (like Spam or Drafts). 18 + // 19 + // Query syntax: 20 + // plain text — matches FROM, SUBJECT, or TO (any field) 21 + // from:value — matches FROM header only 22 + // subject:val — matches SUBJECT header only 23 + // to:value — matches TO header only 24 + 25 + // imapSearchResultMsg carries results from a server-side IMAP search. 26 + type imapSearchResultMsg struct { 27 + emails []imap.Email 28 + query string 29 + err error 30 + } 31 + 32 + // imapSearchAllCmd runs IMAP SEARCH across all configured folders. 33 + func (m Model) imapSearchAllCmd(query string) tea.Cmd { 34 + cli := m.imapCli() 35 + f := m.cfg.Folders 36 + folders := []string{ 37 + f.Inbox, f.Sent, f.Trash, f.Drafts, 38 + f.ToScreen, f.Feed, f.PaperTrail, f.ScreenedOut, 39 + f.Archive, f.Waiting, f.Scheduled, f.Someday, f.Spam, 40 + } 41 + return func() tea.Msg { 42 + emails, err := cli.SearchAllFolders(nil, folders, query) 43 + return imapSearchResultMsg{emails: emails, query: query, err: err} 44 + } 45 + } 46 + 47 + // updateIMAPSearch handles key input while the IMAP search prompt is active. 48 + // Returns true if the key was consumed. 49 + func (m *Model) updateIMAPSearch(key string) (tea.Model, tea.Cmd, bool) { 50 + if !m.imapSearchActive { 51 + return m, nil, false 52 + } 53 + 54 + switch key { 55 + case "esc": 56 + m.imapSearchActive = false 57 + m.imapSearchText = "" 58 + return m, nil, true 59 + 60 + case "enter": 61 + query := strings.TrimSpace(m.imapSearchText) 62 + if query == "" { 63 + m.imapSearchActive = false 64 + return m, nil, true 65 + } 66 + m.imapSearchActive = false 67 + m.loading = true 68 + return m, tea.Batch(m.spinner.Tick, m.imapSearchAllCmd(query)), true 69 + 70 + case "backspace", "ctrl+h": 71 + runes := []rune(m.imapSearchText) 72 + if len(runes) > 0 { 73 + m.imapSearchText = string(runes[:len(runes)-1]) 74 + } 75 + return m, nil, true 76 + 77 + default: 78 + if len(key) == 1 { 79 + m.imapSearchText += key 80 + return m, nil, true 81 + } 82 + } 83 + return m, nil, true 84 + } 85 + 86 + // handleIMAPSearchResult processes the result of an IMAP SEARCH command. 87 + // Displays results in a temporary "Search" off-tab. 88 + func (m *Model) handleIMAPSearchResult(msg imapSearchResultMsg) (tea.Model, tea.Cmd) { 89 + m.loading = false 90 + if msg.err != nil { 91 + m.status = "Search error: " + msg.err.Error() 92 + m.isError = true 93 + return m, nil 94 + } 95 + if len(msg.emails) == 0 { 96 + m.status = fmt.Sprintf("No results for %q.", msg.query) 97 + return m, nil 98 + } 99 + m.imapSearchResults = true 100 + m.offTabFolder = "Search" 101 + // Prepend folder name to subject so user can see where each result is from 102 + for i := range msg.emails { 103 + folder := msg.emails[i].Folder 104 + msg.emails[i].Subject = "[" + folder + "] " + msg.emails[i].Subject 105 + } 106 + m.emails = msg.emails 107 + m.markedUIDs = make(map[uint32]bool) 108 + m.filterActive = false 109 + m.filterText = "" 110 + m.status = fmt.Sprintf("Found %d email(s) for %q — esc to return, enter to open", len(msg.emails), msg.query) 111 + return m, m.sortEmails() 112 + } 113 + 114 + // everythingResultMsg carries results from fetching latest across all folders. 115 + type everythingResultMsg struct { 116 + emails []imap.Email 117 + err error 118 + } 119 + 120 + // fetchEverythingCmd fetches the latest N emails across all folders. 121 + func (m Model) fetchEverythingCmd() tea.Cmd { 122 + cli := m.imapCli() 123 + f := m.cfg.Folders 124 + folders := []string{ 125 + f.Inbox, f.Sent, f.Trash, f.Drafts, 126 + f.ToScreen, f.Feed, f.PaperTrail, f.ScreenedOut, 127 + f.Archive, f.Waiting, f.Scheduled, f.Someday, f.Spam, 128 + } 129 + return func() tea.Msg { 130 + emails, err := cli.FetchLatestAllFolders(nil, folders, 50) 131 + return everythingResultMsg{emails: emails, err: err} 132 + } 133 + } 134 + 135 + // handleEverythingResult displays the "Everything" view. 136 + func (m *Model) handleEverythingResult(msg everythingResultMsg) (tea.Model, tea.Cmd) { 137 + m.loading = false 138 + if msg.err != nil { 139 + m.status = "Everything: " + msg.err.Error() 140 + m.isError = true 141 + return m, nil 142 + } 143 + if len(msg.emails) == 0 { 144 + m.status = "No emails found." 145 + return m, nil 146 + } 147 + m.offTabFolder = "Everything" 148 + // Prepend folder name so user knows where each email is 149 + for i := range msg.emails { 150 + msg.emails[i].Subject = "[" + msg.emails[i].Folder + "] " + msg.emails[i].Subject 151 + } 152 + m.emails = msg.emails 153 + m.markedUIDs = make(map[uint32]bool) 154 + m.filterActive = false 155 + m.filterText = "" 156 + m.status = fmt.Sprintf("Everything — %d most recent emails across all folders. esc to close.", len(msg.emails)) 157 + return m, m.sortEmails() 158 + } 159 + 160 + // viewIMAPSearchBar renders the search prompt at the bottom of the inbox. 161 + func (m Model) viewIMAPSearchBar() string { 162 + cursor := "" 163 + if m.imapSearchActive { 164 + cursor = "█" 165 + } 166 + if m.imapSearchResults && !m.imapSearchActive { 167 + return styleHelp.Render(fmt.Sprintf(" search: %q — esc to close · from: subject: to: prefixes supported", m.imapSearchText)) 168 + } 169 + return styleHelp.Render(fmt.Sprintf(" search (all folders): %s%s · enter search · esc cancel · e.g. newsletter from:simon subject:invoice to:team@", m.imapSearchText, cursor)) 170 + }
+7
internal/ui/styles.go
··· 104 104 Foreground(colorMuted). 105 105 Italic(true). 106 106 Padding(0, 1) 107 + 108 + styleSuggestion = lipgloss.NewStyle(). 109 + Foreground(colorMuted) 110 + 111 + styleSuggestionSelected = lipgloss.NewStyle(). 112 + Foreground(colorPrimary). 113 + Bold(true) 107 114 ) 108 115 109 116 // folderTabs renders the folder switcher bar.