···1111- **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
1212- **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)
1313- **Default signature for new users** — new installs get `*sent from [neomd](https://neomd.ssp.sh)*` as the default signature
1414+- **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)
1515+- **`\Answered` flag on reply** — after sending a reply, the original email is automatically marked as `\Answered` on the IMAP server
1616+- **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
1717+- **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
14181519# 2026-04-05
1620- **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
···8282- **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
8383- **Address autocomplete** — To/Cc/Bcc fields autocomplete from screener lists; navigate with `ctrl+n`/`ctrl+p`, accept with `tab`
8484- **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
8585-- **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
8585+- **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
8686+- **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
8687- **Glamour reading** — incoming emails rendered as styled Markdown in the terminal
8788- **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
8889- **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut
+1
docs/configuration.md
···5656scheduled = "Scheduled"
5757someday = "Someday"
5858spam = "spam" #check capitalization of your pre-existing Spam folder, sometimes might be `Spam` with `S`
5959+# 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)
5960# tab_order controls the left-to-right tab sequence; omit to use the built-in default order. e.g.:
6061# tab_order = ["inbox", "to_screen", "feed", "papertrail", "waiting", "someday", "scheduled", "sent", "archive", "screened_out", "drafts", "trash"]
6162# Gmail uses different folder names — see docs/gmail.md for the correct mapping.
+1
docs/keybindings.md
···111111| `r` | reply (from inbox or reader) |
112112| `ctrl+r` | reply-all — reply to sender + all CC recipients (from inbox or reader) |
113113| `f` | forward email (from reader or inbox) |
114114+| `T` | show full conversation thread across folders (from inbox or reader) |
114115| `c` | compose new email |
115116| `ctrl+b (compose/pre-send)` | toggle Cc+Bcc fields (both hidden by default) |
116117| `ctrl+f (compose/pre-send)` | cycle From address through all accounts + [[senders]] aliases |
+22-3
docs/reading.md
···5757Threads display with a Twitter-style vertical connector line:
58585959```
6060- 1 17:43 │ rafaelxxxxxxxxxxx@g… Re: Re: AUR Neomd (12K)
6161- 2 16:30 ╰ rafaelxxxxxxxxxxx@g… Re: AUR Neomd (10K)
6060+ 1 ·17:43 │ rafaelxxxxxxxxxxx@g… Re: Re: AUR Neomd (12K)
6161+ 2 ·16:30 ·╰ rafaelxxxxxxxxxxx@g… Re: AUR Neomd (10K)
6262 3 N 19:50 │ Bla blabla via Li… Jenna just messaged you (38K)
6363 4 N 18:53 │ Bla blabla via Li… Jenna just messaged you (38K)
6464 5 N 17:59 ╰ Bla blabla via Li… Jenna just messaged you (38K)
6565 6 18:46 LinkedIn tom Weller replied to ... (45K)
6666+ 7 ·14:22 · Simon Späti Data pipeline question (5K)
6667```
67686969+- `·` reply indicator — you've replied to this email (IMAP `\Answered` flag, works across clients)
7070+- `·╰ ` reply indicator within a thread
6871- `│` connects thread members (newest on top)
6972- `╰` marks the root/oldest email at the bottom of each thread
7073- Non-threaded emails show no connector (clean, no visual noise)
···7982| Key | Action |
8083|-----|--------|
8184| `r` | reply to sender |
8282-| `R` | reply-all (sender + all CC recipients) |
8585+| `ctrl+r` | reply-all (sender + all CC recipients) |
8386| `f` | forward email |
8787+| `T` | show full conversation thread across folders |
8488| `E` | continue draft (only in Drafts folder) — re-opens as editable compose |
8989+9090+## Conversation View
9191+9292+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.
9393+9494+Results display in a temporary "Thread" tab:
9595+- Each email shows `[Folder]` prefix (e.g. `[Sent]`, `[Inbox]`, `[Archive]`)
9696+- Threading connectors (`│`/`╰`) show the conversation structure
9797+- Press Enter to read any email, Esc to return to previous view
9898+9999+Also available as `:thread` (alias `:t`) from the command line.
100100+101101+## Reply Indicator
102102+103103+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
···458458 return all, nil
459459}
460460461461+// FetchConversation searches across folders for emails related to the given
462462+// subject, filtered by participant overlap. Used for the conversation/thread view.
463463+// The subject should be the normalized base subject (Re:/Fwd: stripped).
464464+// Participants is a set of email addresses involved in the conversation.
465465+func (c *Client) FetchConversation(ctx context.Context, folders []string, subject string, participants map[string]bool) ([]Email, error) {
466466+ if subject == "" {
467467+ return nil, nil
468468+ }
469469+ if ctx == nil {
470470+ ctx = context.Background()
471471+ }
472472+ var all []Email
473473+ for _, folder := range folders {
474474+ emails, err := c.SearchMessages(ctx, folder, "subject:"+subject)
475475+ if err != nil {
476476+ continue
477477+ }
478478+ all = append(all, emails...)
479479+ }
480480+ // Filter: keep only emails where at least one participant matches.
481481+ if len(participants) > 0 {
482482+ var filtered []Email
483483+ for _, e := range all {
484484+ if participantMatch(e, participants) {
485485+ filtered = append(filtered, e)
486486+ }
487487+ }
488488+ all = filtered
489489+ }
490490+ return all, nil
491491+}
492492+493493+// participantMatch returns true if any address in the email's From/To/CC
494494+// is in the participants set.
495495+func participantMatch(e Email, participants map[string]bool) bool {
496496+ for _, addr := range append(SplitAddrs(e.From), append(SplitAddrs(e.To), SplitAddrs(e.CC)...)...) {
497497+ if participants[strings.ToLower(addr)] {
498498+ return true
499499+ }
500500+ }
501501+ return false
502502+}
503503+504504+// SplitAddrs splits a comma-separated address field and extracts bare lowercase addresses.
505505+func SplitAddrs(field string) []string {
506506+ var out []string
507507+ for _, part := range strings.Split(field, ",") {
508508+ part = strings.TrimSpace(part)
509509+ if part == "" {
510510+ continue
511511+ }
512512+ // Extract from "Name <addr>" or bare "addr"
513513+ if i := strings.IndexByte(part, '<'); i >= 0 {
514514+ if j := strings.IndexByte(part, '>'); j > i {
515515+ part = part[i+1 : j]
516516+ }
517517+ }
518518+ part = strings.TrimSpace(strings.ToLower(part))
519519+ if part != "" {
520520+ out = append(out, part)
521521+ }
522522+ }
523523+ return out
524524+}
525525+461526// buildSearchCriteria parses a query string into IMAP SearchCriteria.
462527// Supports prefixes: "from:value", "subject:value", "to:value".
463528// Plain text without a prefix searches OR(FROM, SUBJECT, TO).
···11+package ui
22+33+import (
44+ "bytes"
55+ "strings"
66+ "testing"
77+ "time"
88+99+ "github.com/charmbracelet/bubbles/list"
1010+ "github.com/sspaeti/neomd/internal/imap"
1111+)
1212+1313+// renderRow renders a single emailItem via emailDelegate and returns the raw string.
1414+func renderRow(item emailItem, width int) string {
1515+ d := emailDelegate{}
1616+ l := list.New([]list.Item{item}, d, width, 1)
1717+ l.SetShowTitle(false)
1818+ l.SetShowStatusBar(false)
1919+ l.SetShowHelp(false)
2020+2121+ var buf bytes.Buffer
2222+ d.Render(&buf, l, 0, item)
2323+ return buf.String()
2424+}
2525+2626+func TestReplyIndicator(t *testing.T) {
2727+ base := imap.Email{
2828+ UID: 1,
2929+ From: "Alice <alice@example.com>",
3030+ Subject: "Hello",
3131+ Date: time.Now(),
3232+ Seen: true,
3333+ Size: 1024,
3434+ }
3535+3636+ t.Run("no reply indicator when not answered", func(t *testing.T) {
3737+ e := base
3838+ e.Answered = false
3939+ row := renderRow(emailItem{email: e, index: 1}, 100)
4040+ // The reply column should be a space, not ·
4141+ if strings.Contains(row, "·") {
4242+ t.Errorf("expected no reply indicator, got: %s", row)
4343+ }
4444+ })
4545+4646+ t.Run("reply indicator shown when answered", func(t *testing.T) {
4747+ e := base
4848+ e.Answered = true
4949+ row := renderRow(emailItem{email: e, index: 1}, 100)
5050+ if !strings.Contains(row, "·") {
5151+ t.Errorf("expected · reply indicator, got: %s", row)
5252+ }
5353+ })
5454+}
5555+5656+func TestReplyIndicatorWithThread(t *testing.T) {
5757+ base := imap.Email{
5858+ UID: 1,
5959+ From: "Bob <bob@example.com>",
6060+ Subject: "test reply mode",
6161+ Date: time.Now(),
6262+ Seen: true,
6363+ Size: 2048,
6464+ }
6565+6666+ t.Run("reply dot with thread root", func(t *testing.T) {
6767+ e := base
6868+ e.Answered = true
6969+ row := renderRow(emailItem{email: e, index: 2, threadPrefix: "╰"}, 100)
7070+ if !strings.Contains(row, "·") {
7171+ t.Errorf("expected · reply indicator with thread, got: %s", row)
7272+ }
7373+ if !strings.Contains(row, "╰") {
7474+ t.Errorf("expected thread root prefix ╰, got: %s", row)
7575+ }
7676+ // · should appear before ╰
7777+ dotIdx := strings.Index(row, "·")
7878+ threadIdx := strings.Index(row, "╰")
7979+ if dotIdx >= threadIdx {
8080+ t.Errorf("expected · before ╰, dot at %d, thread at %d", dotIdx, threadIdx)
8181+ }
8282+ })
8383+8484+ t.Run("reply dot with thread continuation", func(t *testing.T) {
8585+ e := base
8686+ e.Answered = true
8787+ row := renderRow(emailItem{email: e, index: 1, threadPrefix: "│"}, 100)
8888+ if !strings.Contains(row, "·") {
8989+ t.Errorf("expected · reply indicator, got: %s", row)
9090+ }
9191+ if !strings.Contains(row, "│") {
9292+ t.Errorf("expected thread continuation │, got: %s", row)
9393+ }
9494+ })
9595+9696+ t.Run("no reply dot without answered in thread", func(t *testing.T) {
9797+ e := base
9898+ e.Answered = false
9999+ row := renderRow(emailItem{email: e, index: 2, threadPrefix: "╰"}, 100)
100100+ if strings.Contains(row, "·") {
101101+ t.Errorf("expected no reply indicator, got: %s", row)
102102+ }
103103+ if !strings.Contains(row, "╰") {
104104+ t.Errorf("expected thread root prefix ╰, got: %s", row)
105105+ }
106106+ })
107107+}
108108+109109+func TestSendDoneMsgUpdatesAnsweredFlag(t *testing.T) {
110110+ // Simulate the local list update logic from the sendDoneMsg handler.
111111+ emails := []imap.Email{
112112+ {UID: 10, Subject: "unrelated", Answered: false},
113113+ {UID: 20, Subject: "original", Answered: false},
114114+ {UID: 30, Subject: "Re: original", Answered: false},
115115+ }
116116+117117+ items := make([]list.Item, len(emails))
118118+ for i, e := range emails {
119119+ items[i] = emailItem{email: e, index: i + 1}
120120+ }
121121+122122+ // Simulate the handler: mark UID 20 as Answered.
123123+ replyToUID := uint32(20)
124124+ for i, it := range items {
125125+ if ei, ok := it.(emailItem); ok && ei.email.UID == replyToUID {
126126+ ei.email.Answered = true
127127+ items[i] = ei
128128+ break
129129+ }
130130+ }
131131+132132+ // Verify only UID 20 was updated.
133133+ for _, it := range items {
134134+ ei := it.(emailItem)
135135+ switch ei.email.UID {
136136+ case 20:
137137+ if !ei.email.Answered {
138138+ t.Errorf("UID 20 should be Answered after send")
139139+ }
140140+ default:
141141+ if ei.email.Answered {
142142+ t.Errorf("UID %d should not be Answered", ei.email.UID)
143143+ }
144144+ }
145145+ }
146146+}
+1
internal/ui/keys.go
···8383 {"r", "reply (from inbox or reader)"},
8484 {"ctrl+r", "reply-all — reply to sender + all CC recipients (from inbox or reader)"},
8585 {"f", "forward email (from reader or inbox)"},
8686+ {"T", "show full conversation thread across folders (from inbox or reader)"},
8687 {"c", "compose new email"},
8788 {"ctrl+b (compose/pre-send)", "toggle Cc+Bcc fields (both hidden by default)"},
8889 {"ctrl+f (compose/pre-send)", "cycle From address through all accounts + [[senders]] aliases"},
+19-2
internal/ui/model.go
···13071307 }
13081308 // Update local Answered flag so the reply indicator shows immediately.
13091309 if msg.replyToUID > 0 {
13101310- items := m.list.Items()
13101310+ items := m.inbox.Items()
13111311 for i, it := range items {
13121312 if ei, ok := it.(emailItem); ok && ei.email.UID == msg.replyToUID {
13131313 ei.email.Answered = true
···13151315 break
13161316 }
13171317 }
13181318- m.list.SetItems(items)
13181318+ m.inbox.SetItems(items)
13191319 }
13201320 return m, nil
13211321···1374137413751375 case everythingResultMsg:
13761376 return m.handleEverythingResult(msg)
13771377+13781378+ case conversationResultMsg:
13791379+ return m.handleConversationResult(msg)
1377138013781381 case batchDoneMsg:
13791382 m.loading = false
···20202023 m.loading = true
20212024 return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e))
2022202520262026+ case "T":
20272027+ e := selectedEmail(m.inbox)
20282028+ if e == nil {
20292029+ return m, nil
20302030+ }
20312031+ m.loading = true
20322032+ return m, tea.Batch(m.spinner.Tick, m.fetchConversationCmd(e))
20332033+20232034 case "m": // mark/unmark current email for batch, advance cursor
20242035 e := selectedEmail(m.inbox)
20252036 if e == nil {
···23242335 case "f":
23252336 if m.openEmail != nil {
23262337 return m.launchForwardCmd()
23382338+ }
23392339+ case "T":
23402340+ if m.openEmail != nil {
23412341+ m.loading = true
23422342+ m.state = stateInbox
23432343+ return m, tea.Batch(m.spinner.Tick, m.fetchConversationCmd(m.openEmail))
23272344 }
23282345 case "1", "2", "3", "4", "5", "6", "7", "8", "9":
23292346 idx := int(msg.String()[0] - '1') // 0-based
+72
internal/ui/search.go
···163163 return m, m.sortEmails()
164164}
165165166166+// conversationResultMsg carries results from a conversation/thread fetch.
167167+type conversationResultMsg struct {
168168+ emails []imap.Email
169169+ err error
170170+}
171171+172172+// fetchConversationCmd fetches all emails related to the given email's
173173+// conversation across key folders (Inbox, Sent, Archive, etc.).
174174+func (m Model) fetchConversationCmd(e *imap.Email) tea.Cmd {
175175+ cli := m.imapCli()
176176+ f := m.cfg.Folders
177177+ // Search folders likely to contain conversation parts.
178178+ folders := []string{f.Inbox, f.Sent, f.Archive, f.Waiting, f.Someday, f.Scheduled}
179179+ if f.Work != "" {
180180+ folders = append(folders, f.Work)
181181+ }
182182+ // Add current folder if not already included.
183183+ cur := e.Folder
184184+ found := false
185185+ for _, fo := range folders {
186186+ if fo == cur {
187187+ found = true
188188+ break
189189+ }
190190+ }
191191+ if !found && cur != "" {
192192+ folders = append(folders, cur)
193193+ }
194194+195195+ // Normalize subject and collect participants.
196196+ subject := normalizeSubject(e.Subject)
197197+ participants := make(map[string]bool)
198198+ for _, addr := range imap.SplitAddrs(e.From) {
199199+ participants[addr] = true
200200+ }
201201+ for _, addr := range imap.SplitAddrs(e.To) {
202202+ participants[addr] = true
203203+ }
204204+ for _, addr := range imap.SplitAddrs(e.CC) {
205205+ participants[addr] = true
206206+ }
207207+208208+ return func() tea.Msg {
209209+ emails, err := cli.FetchConversation(nil, folders, subject, participants)
210210+ return conversationResultMsg{emails: emails, err: err}
211211+ }
212212+}
213213+214214+// handleConversationResult displays the conversation/thread view.
215215+func (m *Model) handleConversationResult(msg conversationResultMsg) (tea.Model, tea.Cmd) {
216216+ m.loading = false
217217+ if msg.err != nil {
218218+ m.status = "Thread: " + msg.err.Error()
219219+ m.isError = true
220220+ return m, nil
221221+ }
222222+ if len(msg.emails) == 0 {
223223+ m.status = "No related emails found."
224224+ return m, nil
225225+ }
226226+ m.offTabFolder = "Thread"
227227+ for i := range msg.emails {
228228+ msg.emails[i].Subject = "[" + msg.emails[i].Folder + "] " + msg.emails[i].Subject
229229+ }
230230+ m.emails = msg.emails
231231+ m.markedUIDs = make(map[uint32]bool)
232232+ m.filterActive = false
233233+ m.filterText = ""
234234+ m.status = fmt.Sprintf("Thread — %d email(s) in conversation. esc to close.", len(msg.emails))
235235+ return m, m.sortEmails()
236236+}
237237+166238// viewIMAPSearchBar renders the search prompt at the bottom of the inbox.
167239func (m Model) viewIMAPSearchBar() string {
168240 cursor := ""