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.

threaded mode for reading forth and back (sending and incoming) in one thread. Improve reply indication and adding unit tests

sspaeti ea3b45a1 ebb1e3ad

+465 -7
+4
CHANGELOG.md
··· 11 11 - **Fix: To/CC display** — reader and inbox now show all To and CC addresses, not just the first; `FetchHeadersByUID` (used by search/everything) now also populates To and CC fields 12 12 - **Reply-all rebind to `ctrl+r`** — `R` (Shift+R) is now consistently reload/refresh in all views; reply-all moved to `ctrl+r` which works from both inbox list and reader (previously `R` conflicted between reload in inbox and reply-all in reader) 13 13 - **Default signature for new users** — new installs get `*sent from [neomd](https://neomd.ssp.sh)*` as the default signature 14 + - **Reply indicator (`·`)** — emails you've replied to show a `·` dot in the inbox list between the flag and thread columns; uses the standard IMAP `\Answered` flag so it works across clients (reply from webmail → neomd shows it) 15 + - **`\Answered` flag on reply** — after sending a reply, the original email is automatically marked as `\Answered` on the IMAP server 16 + - **Conversation thread view (`T` / `:thread`)** — press `T` from inbox list or reader to see the full conversation across folders (Inbox, Sent, Archive, Waiting, Work, etc.); searches by normalized subject + participant overlap; displays in a temporary "Thread" tab with `[Folder]` prefix and `│`/`╰` threading connectors; esc returns to previous view 17 + - **Custom folder support (`work`)** — optional `work = "Work"` in `[folders]` config; add `"work"` to `tab_order` to show as a tab; `gb` to go, `Mb` to move; auto-created on first run if configured; included in Everything, Search, and conversation views 14 18 15 19 # 2026-04-05 16 20 - **OAuth2 authentication** ([#3](https://github.com/ssp-data/neomd/pull/3), thanks [@notthatjesus](https://github.com/notthatjesus)) — accounts can set `auth_type = "oauth2"` with `oauth2_client_id`, `oauth2_client_secret`, `oauth2_issuer_url`, and `oauth2_scopes` instead of a password; on first launch neomd opens the browser for the authorization code flow, persists the token to `~/.config/neomd/tokens/<account>.json`, and refreshes it automatically; works with Gmail, Office365, and any OIDC-discoverable provider via XOAUTH2 over IMAP and SMTP; password auth paths unchanged for existing accounts
+2 -1
README.md
··· 82 82 - **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 83 83 - **Address autocomplete** — To/Cc/Bcc fields autocomplete from screener lists; navigate with `ctrl+n`/`ctrl+p`, accept with `tab` 84 84 - **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 85 - - **Threaded inbox** — related emails are grouped together in the inbox list with a vertical connector line (`│`/`╰`), Twitter-style; threads are detected via `In-Reply-To`/`Message-ID` headers with a subject+participant fallback; newest reply on top, root at bottom 85 + - **Threaded inbox** — related emails are grouped together in the inbox list with a vertical connector line (`│`/`╰`), Twitter-style; threads are detected via `In-Reply-To`/`Message-ID` headers with a subject+participant fallback; newest reply on top, root at bottom; `·` reply indicator shows which emails you've answered 86 + - **Conversation view** — `T` or `:thread` shows the full conversation across folders (Inbox, Sent, Archive, etc.) in a temporary tab with `[Folder]` prefix; see your replies alongside received emails 86 87 - **Glamour reading** — incoming emails rendered as styled Markdown in the terminal 87 88 - **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 88 89 - **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut
+1
docs/configuration.md
··· 56 56 scheduled = "Scheduled" 57 57 someday = "Someday" 58 58 spam = "spam" #check capitalization of your pre-existing Spam folder, sometimes might be `Spam` with `S` 59 + # work = "Work" # optional custom folder; add "work" to tab_order to show as a tab (gb to go, Mb to move -b for business as w was taken) 59 60 # tab_order controls the left-to-right tab sequence; omit to use the built-in default order. e.g.: 60 61 # tab_order = ["inbox", "to_screen", "feed", "papertrail", "waiting", "someday", "scheduled", "sent", "archive", "screened_out", "drafts", "trash"] 61 62 # Gmail uses different folder names — see docs/gmail.md for the correct mapping.
+1
docs/keybindings.md
··· 111 111 | `r` | reply (from inbox or reader) | 112 112 | `ctrl+r` | reply-all — reply to sender + all CC recipients (from inbox or reader) | 113 113 | `f` | forward email (from reader or inbox) | 114 + | `T` | show full conversation thread across folders (from inbox or reader) | 114 115 | `c` | compose new email | 115 116 | `ctrl+b (compose/pre-send)` | toggle Cc+Bcc fields (both hidden by default) | 116 117 | `ctrl+f (compose/pre-send)` | cycle From address through all accounts + [[senders]] aliases |
+22 -3
docs/reading.md
··· 57 57 Threads display with a Twitter-style vertical connector line: 58 58 59 59 ``` 60 - 1 17:43 │ rafaelxxxxxxxxxxx@g… Re: Re: AUR Neomd (12K) 61 - 2 16:30 ╰ rafaelxxxxxxxxxxx@g… Re: AUR Neomd (10K) 60 + 1 ·17:43 │ rafaelxxxxxxxxxxx@g… Re: Re: AUR Neomd (12K) 61 + 2 ·16:30 ·╰ rafaelxxxxxxxxxxx@g… Re: AUR Neomd (10K) 62 62 3 N 19:50 │ Bla blabla via Li… Jenna just messaged you (38K) 63 63 4 N 18:53 │ Bla blabla via Li… Jenna just messaged you (38K) 64 64 5 N 17:59 ╰ Bla blabla via Li… Jenna just messaged you (38K) 65 65 6 18:46 LinkedIn tom Weller replied to ... (45K) 66 + 7 ·14:22 · Simon Späti Data pipeline question (5K) 66 67 ``` 67 68 69 + - `·` reply indicator — you've replied to this email (IMAP `\Answered` flag, works across clients) 70 + - `·╰ ` reply indicator within a thread 68 71 - `│` connects thread members (newest on top) 69 72 - `╰` marks the root/oldest email at the bottom of each thread 70 73 - Non-threaded emails show no connector (clean, no visual noise) ··· 79 82 | Key | Action | 80 83 |-----|--------| 81 84 | `r` | reply to sender | 82 - | `R` | reply-all (sender + all CC recipients) | 85 + | `ctrl+r` | reply-all (sender + all CC recipients) | 83 86 | `f` | forward email | 87 + | `T` | show full conversation thread across folders | 84 88 | `E` | continue draft (only in Drafts folder) — re-opens as editable compose | 89 + 90 + ## Conversation View 91 + 92 + Press `T` from the inbox list or while reading an email to see the **full conversation across folders**. neomd searches Inbox, Sent, Archive, Waiting, and other configured folders for related emails — matching by normalized subject and participant overlap. 93 + 94 + Results display in a temporary "Thread" tab: 95 + - Each email shows `[Folder]` prefix (e.g. `[Sent]`, `[Inbox]`, `[Archive]`) 96 + - Threading connectors (`│`/`╰`) show the conversation structure 97 + - Press Enter to read any email, Esc to return to previous view 98 + 99 + Also available as `:thread` (alias `:t`) from the command line. 100 + 101 + ## Reply Indicator 102 + 103 + Emails you've replied to show a `·` dot in the inbox list. This uses the standard IMAP `\Answered` flag, so it works across clients — if you reply from webmail, neomd shows it too.
+65
internal/imap/client.go
··· 458 458 return all, nil 459 459 } 460 460 461 + // FetchConversation searches across folders for emails related to the given 462 + // subject, filtered by participant overlap. Used for the conversation/thread view. 463 + // The subject should be the normalized base subject (Re:/Fwd: stripped). 464 + // Participants is a set of email addresses involved in the conversation. 465 + func (c *Client) FetchConversation(ctx context.Context, folders []string, subject string, participants map[string]bool) ([]Email, error) { 466 + if subject == "" { 467 + return nil, nil 468 + } 469 + if ctx == nil { 470 + ctx = context.Background() 471 + } 472 + var all []Email 473 + for _, folder := range folders { 474 + emails, err := c.SearchMessages(ctx, folder, "subject:"+subject) 475 + if err != nil { 476 + continue 477 + } 478 + all = append(all, emails...) 479 + } 480 + // Filter: keep only emails where at least one participant matches. 481 + if len(participants) > 0 { 482 + var filtered []Email 483 + for _, e := range all { 484 + if participantMatch(e, participants) { 485 + filtered = append(filtered, e) 486 + } 487 + } 488 + all = filtered 489 + } 490 + return all, nil 491 + } 492 + 493 + // participantMatch returns true if any address in the email's From/To/CC 494 + // is in the participants set. 495 + func participantMatch(e Email, participants map[string]bool) bool { 496 + for _, addr := range append(SplitAddrs(e.From), append(SplitAddrs(e.To), SplitAddrs(e.CC)...)...) { 497 + if participants[strings.ToLower(addr)] { 498 + return true 499 + } 500 + } 501 + return false 502 + } 503 + 504 + // SplitAddrs splits a comma-separated address field and extracts bare lowercase addresses. 505 + func SplitAddrs(field string) []string { 506 + var out []string 507 + for _, part := range strings.Split(field, ",") { 508 + part = strings.TrimSpace(part) 509 + if part == "" { 510 + continue 511 + } 512 + // Extract from "Name <addr>" or bare "addr" 513 + if i := strings.IndexByte(part, '<'); i >= 0 { 514 + if j := strings.IndexByte(part, '>'); j > i { 515 + part = part[i+1 : j] 516 + } 517 + } 518 + part = strings.TrimSpace(strings.ToLower(part)) 519 + if part != "" { 520 + out = append(out, part) 521 + } 522 + } 523 + return out 524 + } 525 + 461 526 // buildSearchCriteria parses a query string into IMAP SearchCriteria. 462 527 // Supports prefixes: "from:value", "subject:value", "to:value". 463 528 // Plain text without a prefix searches OR(FROM, SUBJECT, TO).
+72
internal/imap/client_test.go
··· 137 137 } 138 138 } 139 139 140 + func TestSplitAddrs(t *testing.T) { 141 + tests := []struct { 142 + input string 143 + want []string 144 + }{ 145 + {"alice@example.com", []string{"alice@example.com"}}, 146 + {"Alice <alice@example.com>, Bob <bob@example.com>", []string{"alice@example.com", "bob@example.com"}}, 147 + {"alice@example.com, bob@example.com", []string{"alice@example.com", "bob@example.com"}}, 148 + {"", nil}, 149 + {" , , ", nil}, 150 + {"ALICE@EXAMPLE.COM", []string{"alice@example.com"}}, // lowercased 151 + } 152 + for _, tt := range tests { 153 + got := SplitAddrs(tt.input) 154 + if len(got) != len(tt.want) { 155 + t.Errorf("SplitAddrs(%q) = %v (len %d), want %v (len %d)", tt.input, got, len(got), tt.want, len(tt.want)) 156 + continue 157 + } 158 + for i := range got { 159 + if got[i] != tt.want[i] { 160 + t.Errorf("SplitAddrs(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) 161 + } 162 + } 163 + } 164 + } 165 + 166 + func TestParticipantMatch(t *testing.T) { 167 + participants := map[string]bool{ 168 + "alice@example.com": true, 169 + "bob@example.com": true, 170 + } 171 + tests := []struct { 172 + name string 173 + email Email 174 + want bool 175 + }{ 176 + { 177 + "from matches", 178 + Email{From: "Alice <alice@example.com>", To: "other@example.com"}, 179 + true, 180 + }, 181 + { 182 + "to matches", 183 + Email{From: "other@example.com", To: "bob@example.com"}, 184 + true, 185 + }, 186 + { 187 + "cc matches", 188 + Email{From: "other@example.com", To: "other2@example.com", CC: "alice@example.com"}, 189 + true, 190 + }, 191 + { 192 + "no match", 193 + Email{From: "stranger@example.com", To: "other@example.com"}, 194 + false, 195 + }, 196 + { 197 + "empty email", 198 + Email{}, 199 + false, 200 + }, 201 + } 202 + for _, tt := range tests { 203 + t.Run(tt.name, func(t *testing.T) { 204 + got := participantMatch(tt.email, participants) 205 + if got != tt.want { 206 + t.Errorf("participantMatch() = %v, want %v", got, tt.want) 207 + } 208 + }) 209 + } 210 + } 211 + 140 212 func TestConnect_RefusesUnencrypted(t *testing.T) { 141 213 c := &Client{ 142 214 cfg: Config{
+14
internal/ui/cmdline.go
··· 200 200 }, 201 201 }, 202 202 { 203 + name: "thread", 204 + aliases: []string{"t"}, 205 + desc: "show full conversation for the selected email (across folders)", 206 + run: func(m *Model) (tea.Model, tea.Cmd) { 207 + e := selectedEmail(m.inbox) 208 + if e == nil { 209 + m.status = "No email selected." 210 + return m, nil 211 + } 212 + m.loading = true 213 + return m, tea.Batch(m.spinner.Tick, m.fetchConversationCmd(e)) 214 + }, 215 + }, 216 + { 203 217 name: "quit", 204 218 aliases: []string{"q"}, 205 219 desc: "quit neomd",
+45
internal/ui/cmdline_test.go
··· 128 128 t.Errorf("summary should contain \"1→Trash\", got: %s", got) 129 129 } 130 130 } 131 + 132 + // --- Thread / Conversation tests --- 133 + 134 + func TestNormalizeSubject(t *testing.T) { 135 + tests := []struct { 136 + input string 137 + want string 138 + }{ 139 + {"Re: Hello World", "hello world"}, 140 + {"Fwd: Re: Hello World", "hello world"}, 141 + {"AW: RE: FW: Meeting notes", "meeting notes"}, 142 + {"Hello World", "hello world"}, 143 + {"Re: Re: Re: Deep thread", "deep thread"}, 144 + {"", ""}, 145 + {"Re[2]: Numbered reply", "numbered reply"}, 146 + {" Re: Whitespace ", "whitespace"}, 147 + } 148 + for _, tt := range tests { 149 + got := normalizeSubject(tt.input) 150 + if got != tt.want { 151 + t.Errorf("normalizeSubject(%q) = %q, want %q", tt.input, got, tt.want) 152 + } 153 + } 154 + } 155 + 156 + func TestHasReplyPrefix(t *testing.T) { 157 + tests := []struct { 158 + input string 159 + want bool 160 + }{ 161 + {"Re: Hello", true}, 162 + {"Fwd: Hello", true}, 163 + {"AW: Hello", true}, 164 + {"Hello", false}, 165 + {"", false}, 166 + {"RE: caps", true}, 167 + {"Fw: short form", true}, 168 + } 169 + for _, tt := range tests { 170 + got := hasReplyPrefix(tt.input) 171 + if got != tt.want { 172 + t.Errorf("hasReplyPrefix(%q) = %v, want %v", tt.input, got, tt.want) 173 + } 174 + } 175 + }
+1 -1
internal/ui/inbox.go
··· 41 41 const ( 42 42 colNumWidth = 4 // " 1 " 43 43 colFlagWidth = 2 // "N " or " " 44 - colReplyWidth = 1 // "↩" or " " 44 + colReplyWidth = 1 // "·" or " " 45 45 colThreadWidth = 2 // "│ " or "╰ " or " " 46 46 colDateWidth = 7 // "Feb 03 " 47 47 colAttachWidth = 2 // "@ " or " "
+146
internal/ui/inbox_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "bytes" 5 + "strings" 6 + "testing" 7 + "time" 8 + 9 + "github.com/charmbracelet/bubbles/list" 10 + "github.com/sspaeti/neomd/internal/imap" 11 + ) 12 + 13 + // renderRow renders a single emailItem via emailDelegate and returns the raw string. 14 + func renderRow(item emailItem, width int) string { 15 + d := emailDelegate{} 16 + l := list.New([]list.Item{item}, d, width, 1) 17 + l.SetShowTitle(false) 18 + l.SetShowStatusBar(false) 19 + l.SetShowHelp(false) 20 + 21 + var buf bytes.Buffer 22 + d.Render(&buf, l, 0, item) 23 + return buf.String() 24 + } 25 + 26 + func TestReplyIndicator(t *testing.T) { 27 + base := imap.Email{ 28 + UID: 1, 29 + From: "Alice <alice@example.com>", 30 + Subject: "Hello", 31 + Date: time.Now(), 32 + Seen: true, 33 + Size: 1024, 34 + } 35 + 36 + t.Run("no reply indicator when not answered", func(t *testing.T) { 37 + e := base 38 + e.Answered = false 39 + row := renderRow(emailItem{email: e, index: 1}, 100) 40 + // The reply column should be a space, not · 41 + if strings.Contains(row, "·") { 42 + t.Errorf("expected no reply indicator, got: %s", row) 43 + } 44 + }) 45 + 46 + t.Run("reply indicator shown when answered", func(t *testing.T) { 47 + e := base 48 + e.Answered = true 49 + row := renderRow(emailItem{email: e, index: 1}, 100) 50 + if !strings.Contains(row, "·") { 51 + t.Errorf("expected · reply indicator, got: %s", row) 52 + } 53 + }) 54 + } 55 + 56 + func TestReplyIndicatorWithThread(t *testing.T) { 57 + base := imap.Email{ 58 + UID: 1, 59 + From: "Bob <bob@example.com>", 60 + Subject: "test reply mode", 61 + Date: time.Now(), 62 + Seen: true, 63 + Size: 2048, 64 + } 65 + 66 + t.Run("reply dot with thread root", func(t *testing.T) { 67 + e := base 68 + e.Answered = true 69 + row := renderRow(emailItem{email: e, index: 2, threadPrefix: "╰"}, 100) 70 + if !strings.Contains(row, "·") { 71 + t.Errorf("expected · reply indicator with thread, got: %s", row) 72 + } 73 + if !strings.Contains(row, "╰") { 74 + t.Errorf("expected thread root prefix ╰, got: %s", row) 75 + } 76 + // · should appear before ╰ 77 + dotIdx := strings.Index(row, "·") 78 + threadIdx := strings.Index(row, "╰") 79 + if dotIdx >= threadIdx { 80 + t.Errorf("expected · before ╰, dot at %d, thread at %d", dotIdx, threadIdx) 81 + } 82 + }) 83 + 84 + t.Run("reply dot with thread continuation", func(t *testing.T) { 85 + e := base 86 + e.Answered = true 87 + row := renderRow(emailItem{email: e, index: 1, threadPrefix: "│"}, 100) 88 + if !strings.Contains(row, "·") { 89 + t.Errorf("expected · reply indicator, got: %s", row) 90 + } 91 + if !strings.Contains(row, "│") { 92 + t.Errorf("expected thread continuation │, got: %s", row) 93 + } 94 + }) 95 + 96 + t.Run("no reply dot without answered in thread", func(t *testing.T) { 97 + e := base 98 + e.Answered = false 99 + row := renderRow(emailItem{email: e, index: 2, threadPrefix: "╰"}, 100) 100 + if strings.Contains(row, "·") { 101 + t.Errorf("expected no reply indicator, got: %s", row) 102 + } 103 + if !strings.Contains(row, "╰") { 104 + t.Errorf("expected thread root prefix ╰, got: %s", row) 105 + } 106 + }) 107 + } 108 + 109 + func TestSendDoneMsgUpdatesAnsweredFlag(t *testing.T) { 110 + // Simulate the local list update logic from the sendDoneMsg handler. 111 + emails := []imap.Email{ 112 + {UID: 10, Subject: "unrelated", Answered: false}, 113 + {UID: 20, Subject: "original", Answered: false}, 114 + {UID: 30, Subject: "Re: original", Answered: false}, 115 + } 116 + 117 + items := make([]list.Item, len(emails)) 118 + for i, e := range emails { 119 + items[i] = emailItem{email: e, index: i + 1} 120 + } 121 + 122 + // Simulate the handler: mark UID 20 as Answered. 123 + replyToUID := uint32(20) 124 + for i, it := range items { 125 + if ei, ok := it.(emailItem); ok && ei.email.UID == replyToUID { 126 + ei.email.Answered = true 127 + items[i] = ei 128 + break 129 + } 130 + } 131 + 132 + // Verify only UID 20 was updated. 133 + for _, it := range items { 134 + ei := it.(emailItem) 135 + switch ei.email.UID { 136 + case 20: 137 + if !ei.email.Answered { 138 + t.Errorf("UID 20 should be Answered after send") 139 + } 140 + default: 141 + if ei.email.Answered { 142 + t.Errorf("UID %d should not be Answered", ei.email.UID) 143 + } 144 + } 145 + } 146 + }
+1
internal/ui/keys.go
··· 83 83 {"r", "reply (from inbox or reader)"}, 84 84 {"ctrl+r", "reply-all — reply to sender + all CC recipients (from inbox or reader)"}, 85 85 {"f", "forward email (from reader or inbox)"}, 86 + {"T", "show full conversation thread across folders (from inbox or reader)"}, 86 87 {"c", "compose new email"}, 87 88 {"ctrl+b (compose/pre-send)", "toggle Cc+Bcc fields (both hidden by default)"}, 88 89 {"ctrl+f (compose/pre-send)", "cycle From address through all accounts + [[senders]] aliases"},
+19 -2
internal/ui/model.go
··· 1307 1307 } 1308 1308 // Update local Answered flag so the reply indicator shows immediately. 1309 1309 if msg.replyToUID > 0 { 1310 - items := m.list.Items() 1310 + items := m.inbox.Items() 1311 1311 for i, it := range items { 1312 1312 if ei, ok := it.(emailItem); ok && ei.email.UID == msg.replyToUID { 1313 1313 ei.email.Answered = true ··· 1315 1315 break 1316 1316 } 1317 1317 } 1318 - m.list.SetItems(items) 1318 + m.inbox.SetItems(items) 1319 1319 } 1320 1320 return m, nil 1321 1321 ··· 1374 1374 1375 1375 case everythingResultMsg: 1376 1376 return m.handleEverythingResult(msg) 1377 + 1378 + case conversationResultMsg: 1379 + return m.handleConversationResult(msg) 1377 1380 1378 1381 case batchDoneMsg: 1379 1382 m.loading = false ··· 2020 2023 m.loading = true 2021 2024 return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) 2022 2025 2026 + case "T": 2027 + e := selectedEmail(m.inbox) 2028 + if e == nil { 2029 + return m, nil 2030 + } 2031 + m.loading = true 2032 + return m, tea.Batch(m.spinner.Tick, m.fetchConversationCmd(e)) 2033 + 2023 2034 case "m": // mark/unmark current email for batch, advance cursor 2024 2035 e := selectedEmail(m.inbox) 2025 2036 if e == nil { ··· 2324 2335 case "f": 2325 2336 if m.openEmail != nil { 2326 2337 return m.launchForwardCmd() 2338 + } 2339 + case "T": 2340 + if m.openEmail != nil { 2341 + m.loading = true 2342 + m.state = stateInbox 2343 + return m, tea.Batch(m.spinner.Tick, m.fetchConversationCmd(m.openEmail)) 2327 2344 } 2328 2345 case "1", "2", "3", "4", "5", "6", "7", "8", "9": 2329 2346 idx := int(msg.String()[0] - '1') // 0-based
+72
internal/ui/search.go
··· 163 163 return m, m.sortEmails() 164 164 } 165 165 166 + // conversationResultMsg carries results from a conversation/thread fetch. 167 + type conversationResultMsg struct { 168 + emails []imap.Email 169 + err error 170 + } 171 + 172 + // fetchConversationCmd fetches all emails related to the given email's 173 + // conversation across key folders (Inbox, Sent, Archive, etc.). 174 + func (m Model) fetchConversationCmd(e *imap.Email) tea.Cmd { 175 + cli := m.imapCli() 176 + f := m.cfg.Folders 177 + // Search folders likely to contain conversation parts. 178 + folders := []string{f.Inbox, f.Sent, f.Archive, f.Waiting, f.Someday, f.Scheduled} 179 + if f.Work != "" { 180 + folders = append(folders, f.Work) 181 + } 182 + // Add current folder if not already included. 183 + cur := e.Folder 184 + found := false 185 + for _, fo := range folders { 186 + if fo == cur { 187 + found = true 188 + break 189 + } 190 + } 191 + if !found && cur != "" { 192 + folders = append(folders, cur) 193 + } 194 + 195 + // Normalize subject and collect participants. 196 + subject := normalizeSubject(e.Subject) 197 + participants := make(map[string]bool) 198 + for _, addr := range imap.SplitAddrs(e.From) { 199 + participants[addr] = true 200 + } 201 + for _, addr := range imap.SplitAddrs(e.To) { 202 + participants[addr] = true 203 + } 204 + for _, addr := range imap.SplitAddrs(e.CC) { 205 + participants[addr] = true 206 + } 207 + 208 + return func() tea.Msg { 209 + emails, err := cli.FetchConversation(nil, folders, subject, participants) 210 + return conversationResultMsg{emails: emails, err: err} 211 + } 212 + } 213 + 214 + // handleConversationResult displays the conversation/thread view. 215 + func (m *Model) handleConversationResult(msg conversationResultMsg) (tea.Model, tea.Cmd) { 216 + m.loading = false 217 + if msg.err != nil { 218 + m.status = "Thread: " + msg.err.Error() 219 + m.isError = true 220 + return m, nil 221 + } 222 + if len(msg.emails) == 0 { 223 + m.status = "No related emails found." 224 + return m, nil 225 + } 226 + m.offTabFolder = "Thread" 227 + for i := range msg.emails { 228 + msg.emails[i].Subject = "[" + msg.emails[i].Folder + "] " + msg.emails[i].Subject 229 + } 230 + m.emails = msg.emails 231 + m.markedUIDs = make(map[uint32]bool) 232 + m.filterActive = false 233 + m.filterText = "" 234 + m.status = fmt.Sprintf("Thread — %d email(s) in conversation. esc to close.", len(msg.emails)) 235 + return m, m.sortEmails() 236 + } 237 + 166 238 // viewIMAPSearchBar renders the search prompt at the bottom of the inbox. 167 239 func (m Model) viewIMAPSearchBar() string { 168 240 cursor := ""