···88 "github.com/sspaeti/neomd/internal/imap"
99)
10101111-// replyPrefixRe matches common reply/forward prefixes (Re:, Fwd:, Fw:, AW:, SV:, VS:).
1212-var replyPrefixRe = regexp.MustCompile(`(?i)^(re|fwd?|aw|sv|vs)\s*(\[\d+\])?\s*:\s*`)
1111+// replyPrefixRe matches common reply/forward prefixes.
1212+var replyPrefixRe = regexp.MustCompile(`(?i)^(re|fwd?|fw|aw|sv|vs|ref|rif)\s*(\[\d+\])?\s*:\s*`)
1313+1414+// hasReplyPrefix returns true if the subject starts with a reply/forward prefix.
1515+func hasReplyPrefix(subject string) bool {
1616+ return replyPrefixRe.MatchString(strings.TrimSpace(subject))
1717+}
13181414-// normalizeSubject strips reply/forward prefixes and lowercases for thread grouping.
1919+// normalizeSubject strips all reply/forward prefixes and lowercases.
1520func normalizeSubject(subject string) string {
1621 s := strings.TrimSpace(subject)
1722 for {
···2833// threadedEmail pairs an email with its tree-drawing prefix for the inbox list.
2934type threadedEmail struct {
3035 email imap.Email
3131- threadPrefix string // e.g. "┌─>" or " " or ""
3636+ threadPrefix string // "│" = continuation, "╰" = root, "" = not threaded
3237}
33383439// threadEmails groups and reorders emails into threaded display order.
4040+//
4141+// Threading uses two strategies:
4242+// 1. InReplyTo/MessageID chain — direct header links (most reliable)
4343+// 2. Subject fallback — ONLY for emails whose subject has a reply prefix
4444+// (Re:, AW:, Fwd:, etc.). Emails without a prefix are never grouped by
4545+// subject, so recurring notifications/invoices with identical subjects
4646+// stay separate.
4747+//
3548// Each thread is sorted internally by date ascending (oldest = root at bottom,
3636-// newest replies on top), matching neomutt's reverse-thread style.
3737-// Threads are sorted relative to each other by the most recent email in each thread.
3838-// Returns the reordered emails with tree prefixes for rendering.
4949+// newest replies on top). Threads are sorted by most recent email.
3950func threadEmails(emails []imap.Email) []threadedEmail {
4051 if len(emails) == 0 {
4152 return nil
4253 }
43544444- // Phase 1: Build groups using InReplyTo/MessageID + subject+participant fallback.
5555+ // Union-find for grouping connected emails.
4556 parent := make([]int, len(emails))
4657 for i := range parent {
4758 parent[i] = i
···6071 }
6172 }
62736363- // Connect by InReplyTo -> MessageID.
7474+ // Phase 1: Connect by InReplyTo -> MessageID chain.
6475 byMsgID := make(map[string]int, len(emails))
6576 for i := range emails {
6677 if id := emails[i].MessageID; id != "" {
···7586 }
7687 }
77887878- // Subject+participant fallback: group emails with same normalized subject
7979- // where participants overlap (From of one appears in From/To of another).
8080- type subjGroup struct {
8181- indices []int
8282- from string // extractAddrFromField of first email
8383- to string
8484- }
8585- bySubject := make(map[string]*subjGroup)
8989+ // Phase 2: Subject fallback — only for emails with a reply/forward prefix.
9090+ // This catches threads where the InReplyTo points to an email in another
9191+ // folder (e.g. your reply in Sent). We group by normalized subject, but
9292+ // ONLY if at least one email in the pair has a reply prefix.
9393+ byNormSubj := make(map[string][]int) // normalized subject -> indices
8694 for i := range emails {
8795 subj := normalizeSubject(emails[i].Subject)
8896 if subj == "" {
8997 continue
9098 }
9191- from := extractAddrFromField(emails[i].From)
9292- to := extractAddrFromField(emails[i].To)
9393-9494- if g, ok := bySubject[subj]; ok {
9595- // Check participant overlap with existing group members.
9696- if from == g.from || from == g.to || to == g.from || to == g.to {
9797- union(i, g.indices[0])
9898- g.indices = append(g.indices, i)
9999+ byNormSubj[subj] = append(byNormSubj[subj], i)
100100+ }
101101+ for _, indices := range byNormSubj {
102102+ if len(indices) < 2 {
103103+ continue
104104+ }
105105+ // Only group if at least one email has a reply prefix.
106106+ hasReply := false
107107+ for _, idx := range indices {
108108+ if hasReplyPrefix(emails[idx].Subject) {
109109+ hasReply = true
110110+ break
99111 }
100100- } else {
101101- bySubject[subj] = &subjGroup{indices: []int{i}, from: from, to: to}
112112+ }
113113+ if !hasReply {
114114+ continue
115115+ }
116116+ // Connect all emails with this normalized subject.
117117+ first := indices[0]
118118+ for _, idx := range indices[1:] {
119119+ union(first, idx)
102120 }
103121 }
104122105105- // Phase 2: Collect threads.
123123+ // Collect threads.
106124 threadMap := make(map[int][]int) // root -> indices
107125 for i := range emails {
108126 root := find(i)
109127 threadMap[root] = append(threadMap[root], i)
110128 }
111129112112- // Phase 3: Sort each thread internally by date ascending (oldest first = root).
130130+ // Sort each thread internally by date ascending (oldest first = root).
113131 type thread struct {
114132 indices []int
115115- newestIdx int // index of most recent email (for inter-thread sorting)
133133+ newestIdx int
116134 }
117135 var threads []thread
118136 for _, indices := range threadMap {
···123141 threads = append(threads, thread{indices: indices, newestIdx: newest})
124142 }
125143126126- // Phase 4: Sort threads by most recent email (matching the overall sort: newest first).
144144+ // Sort threads by most recent email (newest first).
127145 sort.Slice(threads, func(i, j int) bool {
128146 return emails[threads[i].newestIdx].Date.After(emails[threads[j].newestIdx].Date)
129147 })
130148131131- // Phase 5: Build output with thread connector lines (Twitter-style).
132132- // Newest reply on top, root (oldest) at bottom.
149149+ // Build output with thread connector lines.
133150 // │ = continuation (more thread below), ╰ = root/last in thread.
134151 result := make([]threadedEmail, 0, len(emails))
135152 for _, t := range threads {
···153170154171 return result
155172}
156156-157157-// extractAddrFromField returns the bare email address from a "Name <addr>" or "addr" string.
158158-func extractAddrFromField(s string) string {
159159- if i := strings.LastIndex(s, "<"); i >= 0 {
160160- if j := strings.Index(s[i:], ">"); j >= 0 {
161161- return strings.ToLower(s[i+1 : i+j])
162162- }
163163- }
164164- return strings.ToLower(strings.TrimSpace(s))
165165-}