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.

fix threading AW and Re:

sspaeti ce9c703b 0af966fa

+50 -43
+50 -43
internal/ui/thread.go
··· 8 8 "github.com/sspaeti/neomd/internal/imap" 9 9 ) 10 10 11 - // replyPrefixRe matches common reply/forward prefixes (Re:, Fwd:, Fw:, AW:, SV:, VS:). 12 - var replyPrefixRe = regexp.MustCompile(`(?i)^(re|fwd?|aw|sv|vs)\s*(\[\d+\])?\s*:\s*`) 11 + // replyPrefixRe matches common reply/forward prefixes. 12 + var replyPrefixRe = regexp.MustCompile(`(?i)^(re|fwd?|fw|aw|sv|vs|ref|rif)\s*(\[\d+\])?\s*:\s*`) 13 + 14 + // hasReplyPrefix returns true if the subject starts with a reply/forward prefix. 15 + func hasReplyPrefix(subject string) bool { 16 + return replyPrefixRe.MatchString(strings.TrimSpace(subject)) 17 + } 13 18 14 - // normalizeSubject strips reply/forward prefixes and lowercases for thread grouping. 19 + // normalizeSubject strips all reply/forward prefixes and lowercases. 15 20 func normalizeSubject(subject string) string { 16 21 s := strings.TrimSpace(subject) 17 22 for { ··· 28 33 // threadedEmail pairs an email with its tree-drawing prefix for the inbox list. 29 34 type threadedEmail struct { 30 35 email imap.Email 31 - threadPrefix string // e.g. "┌─>" or " " or "" 36 + threadPrefix string // "│" = continuation, "╰" = root, "" = not threaded 32 37 } 33 38 34 39 // threadEmails groups and reorders emails into threaded display order. 40 + // 41 + // Threading uses two strategies: 42 + // 1. InReplyTo/MessageID chain — direct header links (most reliable) 43 + // 2. Subject fallback — ONLY for emails whose subject has a reply prefix 44 + // (Re:, AW:, Fwd:, etc.). Emails without a prefix are never grouped by 45 + // subject, so recurring notifications/invoices with identical subjects 46 + // stay separate. 47 + // 35 48 // Each thread is sorted internally by date ascending (oldest = root at bottom, 36 - // newest replies on top), matching neomutt's reverse-thread style. 37 - // Threads are sorted relative to each other by the most recent email in each thread. 38 - // Returns the reordered emails with tree prefixes for rendering. 49 + // newest replies on top). Threads are sorted by most recent email. 39 50 func threadEmails(emails []imap.Email) []threadedEmail { 40 51 if len(emails) == 0 { 41 52 return nil 42 53 } 43 54 44 - // Phase 1: Build groups using InReplyTo/MessageID + subject+participant fallback. 55 + // Union-find for grouping connected emails. 45 56 parent := make([]int, len(emails)) 46 57 for i := range parent { 47 58 parent[i] = i ··· 60 71 } 61 72 } 62 73 63 - // Connect by InReplyTo -> MessageID. 74 + // Phase 1: Connect by InReplyTo -> MessageID chain. 64 75 byMsgID := make(map[string]int, len(emails)) 65 76 for i := range emails { 66 77 if id := emails[i].MessageID; id != "" { ··· 75 86 } 76 87 } 77 88 78 - // Subject+participant fallback: group emails with same normalized subject 79 - // where participants overlap (From of one appears in From/To of another). 80 - type subjGroup struct { 81 - indices []int 82 - from string // extractAddrFromField of first email 83 - to string 84 - } 85 - bySubject := make(map[string]*subjGroup) 89 + // Phase 2: Subject fallback — only for emails with a reply/forward prefix. 90 + // This catches threads where the InReplyTo points to an email in another 91 + // folder (e.g. your reply in Sent). We group by normalized subject, but 92 + // ONLY if at least one email in the pair has a reply prefix. 93 + byNormSubj := make(map[string][]int) // normalized subject -> indices 86 94 for i := range emails { 87 95 subj := normalizeSubject(emails[i].Subject) 88 96 if subj == "" { 89 97 continue 90 98 } 91 - from := extractAddrFromField(emails[i].From) 92 - to := extractAddrFromField(emails[i].To) 93 - 94 - if g, ok := bySubject[subj]; ok { 95 - // Check participant overlap with existing group members. 96 - if from == g.from || from == g.to || to == g.from || to == g.to { 97 - union(i, g.indices[0]) 98 - g.indices = append(g.indices, i) 99 + byNormSubj[subj] = append(byNormSubj[subj], i) 100 + } 101 + for _, indices := range byNormSubj { 102 + if len(indices) < 2 { 103 + continue 104 + } 105 + // Only group if at least one email has a reply prefix. 106 + hasReply := false 107 + for _, idx := range indices { 108 + if hasReplyPrefix(emails[idx].Subject) { 109 + hasReply = true 110 + break 99 111 } 100 - } else { 101 - bySubject[subj] = &subjGroup{indices: []int{i}, from: from, to: to} 112 + } 113 + if !hasReply { 114 + continue 115 + } 116 + // Connect all emails with this normalized subject. 117 + first := indices[0] 118 + for _, idx := range indices[1:] { 119 + union(first, idx) 102 120 } 103 121 } 104 122 105 - // Phase 2: Collect threads. 123 + // Collect threads. 106 124 threadMap := make(map[int][]int) // root -> indices 107 125 for i := range emails { 108 126 root := find(i) 109 127 threadMap[root] = append(threadMap[root], i) 110 128 } 111 129 112 - // Phase 3: Sort each thread internally by date ascending (oldest first = root). 130 + // Sort each thread internally by date ascending (oldest first = root). 113 131 type thread struct { 114 132 indices []int 115 - newestIdx int // index of most recent email (for inter-thread sorting) 133 + newestIdx int 116 134 } 117 135 var threads []thread 118 136 for _, indices := range threadMap { ··· 123 141 threads = append(threads, thread{indices: indices, newestIdx: newest}) 124 142 } 125 143 126 - // Phase 4: Sort threads by most recent email (matching the overall sort: newest first). 144 + // Sort threads by most recent email (newest first). 127 145 sort.Slice(threads, func(i, j int) bool { 128 146 return emails[threads[i].newestIdx].Date.After(emails[threads[j].newestIdx].Date) 129 147 }) 130 148 131 - // Phase 5: Build output with thread connector lines (Twitter-style). 132 - // Newest reply on top, root (oldest) at bottom. 149 + // Build output with thread connector lines. 133 150 // │ = continuation (more thread below), ╰ = root/last in thread. 134 151 result := make([]threadedEmail, 0, len(emails)) 135 152 for _, t := range threads { ··· 153 170 154 171 return result 155 172 } 156 - 157 - // extractAddrFromField returns the bare email address from a "Name <addr>" or "addr" string. 158 - func extractAddrFromField(s string) string { 159 - if i := strings.LastIndex(s, "<"); i >= 0 { 160 - if j := strings.Index(s[i:], ">"); j >= 0 { 161 - return strings.ToLower(s[i+1 : i+j]) 162 - } 163 - } 164 - return strings.ToLower(strings.TrimSpace(s)) 165 - }