@@ -11,6 +11,64 @@ import (
1111// replyPrefixRe matches common reply/forward prefixes.
1212var replyPrefixRe = regexp .MustCompile (`(?i)^(re|fwd?|fw|aw|sv|vs|ref|rif)\s*(\[\d+\])?\s*:\s*` )
1313
14+ // compareEmails returns -1 if a < b, 0 if a == b, 1 if a > b.
15+ // Comparison uses the specified sortField with deterministic tie-breakers:
16+ // 1. Primary sort field (from/subject/size/date)
17+ // 2. Date (newest first) if primary keys match and sortField != "date"
18+ // 3. UID for fully deterministic ordering
19+ func compareEmails (a , b imap.Email , sortField string ) int {
20+ // Primary sort comparison
21+ var cmp int // -1 = a < b, 0 = equal, 1 = a > b
22+ switch sortField {
23+ case "from" :
24+ aFrom , bFrom := strings .ToLower (a .From ), strings .ToLower (b .From )
25+ if aFrom < bFrom {
26+ cmp = - 1
27+ } else if aFrom > bFrom {
28+ cmp = 1
29+ }
30+ case "subject" :
31+ aSubj , bSubj := strings .ToLower (a .Subject ), strings .ToLower (b .Subject )
32+ if aSubj < bSubj {
33+ cmp = - 1
34+ } else if aSubj > bSubj {
35+ cmp = 1
36+ }
37+ case "size" :
38+ if a .Size < b .Size {
39+ cmp = - 1
40+ } else if a .Size > b .Size {
41+ cmp = 1
42+ }
43+ default : // "date"
44+ if a .Date .Before (b .Date ) {
45+ cmp = - 1
46+ } else if a .Date .After (b .Date ) {
47+ cmp = 1
48+ }
49+ }
50+
51+ // Tie-breaker 1: date (newest first) if primary keys are equal
52+ if cmp == 0 && sortField != "date" {
53+ if a .Date .After (b .Date ) {
54+ cmp = - 1
55+ } else if a .Date .Before (b .Date ) {
56+ cmp = 1
57+ }
58+ }
59+
60+ // Tie-breaker 2: UID for deterministic ordering
61+ if cmp == 0 {
62+ if a .UID < b .UID {
63+ cmp = - 1
64+ } else if a .UID > b .UID {
65+ cmp = 1
66+ }
67+ }
68+
69+ return cmp
70+ }
71+
1472// hasReplyPrefix returns true if the subject starts with a reply/forward prefix.
1573func hasReplyPrefix (subject string ) bool {
1674 return replyPrefixRe .MatchString (strings .TrimSpace (subject ))
@@ -144,24 +202,16 @@ func threadEmails(emails []imap.Email, sortField string, sortReverse bool) []thr
144202
145203 // Sort threads by user's chosen sort field and order.
146204 // We use the newest email in each thread as the representative for sorting.
147- sort .Slice (threads , func (i , j int ) bool {
205+ sort .SliceStable (threads , func (i , j int ) bool {
148206 a := emails [threads [i ].newestIdx ]
149207 b := emails [threads [j ].newestIdx ]
150- var less bool
151- switch sortField {
152- case "from" :
153- less = strings .ToLower (a .From ) < strings .ToLower (b .From )
154- case "subject" :
155- less = strings .ToLower (a .Subject ) < strings .ToLower (b .Subject )
156- case "size" :
157- less = a .Size < b .Size
158- default : // "date"
159- less = a .Date .Before (b .Date )
160- }
208+ cmp := compareEmails (a , b , sortField )
209+
210+ // Apply sort direction
161211 if sortReverse {
162- return ! less
212+ return cmp > 0 // descending: a > b means a comes first
163213 }
164- return less
214+ return cmp < 0 // ascending: a < b means a comes first
165215 })
166216
167217 // Build output with thread connector lines.
0 commit comments