Skip to content

Commit 1506e71

Browse files
authored
Merge pull request emersion#6 from migadu/sort
Sort
2 parents e03666f + f8689b4 commit 1506e71

File tree

5 files changed

+482
-0
lines changed

5 files changed

+482
-0
lines changed

imapserver/capability.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ func (c *Conn) availableCaps() []imap.Cap {
9393
imap.CapCreateSpecialUse,
9494
imap.CapLiteralPlus,
9595
imap.CapUnauthenticate,
96+
imap.CapSort,
97+
imap.CapSortDisplay,
98+
imap.CapESort,
9699
imap.CapID,
97100
})
98101

imapserver/conn.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,8 @@ func (c *Conn) readCommand(dec *imapwire.Decoder) error {
299299
err = c.handleMove(dec, numKind)
300300
case "SEARCH", "UID SEARCH":
301301
err = c.handleSearch(tag, dec, numKind)
302+
case "SORT", "UID SORT":
303+
err = c.handleSort(tag, dec, numKind)
302304
default:
303305
if c.state == imap.ConnStateNotAuthenticated {
304306
// Don't allow a single unknown command before authentication to

imapserver/imapmemserver/sort.go

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package imapmemserver
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"net/mail"
7+
"net/textproto"
8+
"sort"
9+
"strings"
10+
11+
"github.com/emersion/go-imap/v2"
12+
"github.com/emersion/go-imap/v2/imapserver"
13+
)
14+
15+
// Sort performs a SORT command.
16+
func (mbox *MailboxView) Sort(numKind imapserver.NumKind, criteria *imap.SearchCriteria, sortCriteria []imap.SortCriterion) ([]uint32, error) {
17+
mbox.mutex.Lock()
18+
defer mbox.mutex.Unlock()
19+
20+
// Apply search criteria
21+
mbox.staticSearchCriteria(criteria)
22+
23+
// First find all messages that match the search criteria
24+
var matchedMessages []*message
25+
var matchedSeqNums []uint32
26+
var matchedIndices []int
27+
for i, msg := range mbox.l {
28+
seqNum := mbox.tracker.EncodeSeqNum(uint32(i) + 1)
29+
30+
if !msg.search(seqNum, criteria) {
31+
continue
32+
}
33+
34+
matchedMessages = append(matchedMessages, msg)
35+
matchedSeqNums = append(matchedSeqNums, seqNum)
36+
matchedIndices = append(matchedIndices, i)
37+
}
38+
39+
// Sort the matched messages based on the sort criteria
40+
sortMatchedMessages(matchedMessages, matchedSeqNums, matchedIndices, sortCriteria)
41+
42+
// Create sorted response
43+
var data []uint32
44+
for i, msg := range matchedMessages {
45+
var num uint32
46+
switch numKind {
47+
case imapserver.NumKindSeq:
48+
if matchedSeqNums[i] == 0 {
49+
continue
50+
}
51+
num = matchedSeqNums[i]
52+
case imapserver.NumKindUID:
53+
num = uint32(msg.uid)
54+
}
55+
data = append(data, num)
56+
}
57+
58+
return data, nil
59+
}
60+
61+
// sortMatchedMessages sorts messages according to the specified sort criteria
62+
func sortMatchedMessages(messages []*message, seqNums []uint32, indices []int, criteria []imap.SortCriterion) {
63+
if len(messages) < 2 {
64+
return // Nothing to sort
65+
}
66+
67+
// Create a slice of indices for sorting
68+
indices2 := make([]int, len(messages))
69+
for i := range indices2 {
70+
indices2[i] = i
71+
}
72+
73+
// Sort the indices based on the criteria
74+
sort.SliceStable(indices2, func(i, j int) bool {
75+
i2, j2 := indices2[i], indices2[j]
76+
77+
// Apply each criterion in order until we find a difference
78+
for _, criterion := range criteria {
79+
result := compareByCriterion(messages[i2], messages[j2], criterion.Key)
80+
81+
// Apply reverse if needed
82+
if criterion.Reverse {
83+
result = -result
84+
}
85+
86+
// If comparison yields a difference, return the result
87+
if result < 0 {
88+
return true
89+
} else if result > 0 {
90+
return false
91+
}
92+
// If equal, continue to the next criterion
93+
}
94+
95+
// If all criteria are equal, maintain original order
96+
return i < j
97+
})
98+
99+
// Reorder the original slices according to the sorted indices
100+
newMessages := make([]*message, len(messages))
101+
newSeqNums := make([]uint32, len(seqNums))
102+
newIndices := make([]int, len(indices))
103+
104+
for i, idx := range indices2 {
105+
newMessages[i] = messages[idx]
106+
newSeqNums[i] = seqNums[idx]
107+
newIndices[i] = indices[idx]
108+
}
109+
110+
// Copy sorted slices back to original slices
111+
copy(messages, newMessages)
112+
copy(seqNums, newSeqNums)
113+
copy(indices, newIndices)
114+
}
115+
116+
// compareByCriterion compares two messages based on a single criterion
117+
// returns -1 if a < b, 0 if a == b, 1 if a > b
118+
func compareByCriterion(a, b *message, key imap.SortKey) int {
119+
switch key {
120+
case imap.SortKeyArrival:
121+
// For ARRIVAL, we use the UID as the arrival order
122+
if a.uid < b.uid {
123+
return -1
124+
} else if a.uid > b.uid {
125+
return 1
126+
}
127+
return 0
128+
129+
case imap.SortKeyDate:
130+
// Compare internal date
131+
if a.t.Before(b.t) {
132+
return -1
133+
} else if a.t.After(b.t) {
134+
return 1
135+
}
136+
return 0
137+
138+
case imap.SortKeySize:
139+
// Compare message sizes
140+
aSize := len(a.buf)
141+
bSize := len(b.buf)
142+
if aSize < bSize {
143+
return -1
144+
} else if aSize > bSize {
145+
return 1
146+
}
147+
return 0
148+
149+
case imap.SortKeyFrom:
150+
// NOTE: A fully compliant implementation as per RFC 5256 would parse
151+
// the address and sort by mailbox, then host. This is a simplified
152+
// case-insensitive comparison of the full header value.
153+
fromA := getHeader(a.buf, "From")
154+
fromB := getHeader(b.buf, "From")
155+
return strings.Compare(strings.ToLower(fromA), strings.ToLower(fromB))
156+
157+
case imap.SortKeyTo:
158+
// NOTE: Simplified comparison. See SortKeyFrom.
159+
toA := getHeader(a.buf, "To")
160+
toB := getHeader(b.buf, "To")
161+
return strings.Compare(strings.ToLower(toA), strings.ToLower(toB))
162+
163+
case imap.SortKeyCc:
164+
// NOTE: Simplified comparison. See SortKeyFrom.
165+
ccA := getHeader(a.buf, "Cc")
166+
ccB := getHeader(b.buf, "Cc")
167+
return strings.Compare(strings.ToLower(ccA), strings.ToLower(ccB))
168+
169+
case imap.SortKeySubject:
170+
// RFC 5256 specifies i;ascii-casemap collation, which is case-insensitive.
171+
subjA := getHeader(a.buf, "Subject")
172+
subjB := getHeader(b.buf, "Subject")
173+
return strings.Compare(strings.ToLower(subjA), strings.ToLower(subjB))
174+
175+
case imap.SortKeyDisplay:
176+
// RFC 5957: sort by display-name, fallback to mailbox.
177+
fromA := getHeader(a.buf, "From")
178+
fromB := getHeader(b.buf, "From")
179+
180+
addrA, errA := mail.ParseAddress(fromA)
181+
addrB, errB := mail.ParseAddress(fromB)
182+
183+
var displayA, displayB string
184+
185+
if errA == nil {
186+
if addrA.Name != "" {
187+
displayA = addrA.Name
188+
} else {
189+
displayA = addrA.Address
190+
}
191+
} else {
192+
displayA = fromA // Fallback to raw header on parse error
193+
}
194+
195+
if errB == nil {
196+
if addrB.Name != "" {
197+
displayB = addrB.Name
198+
} else {
199+
displayB = addrB.Address
200+
}
201+
} else {
202+
displayB = fromB // Fallback to raw header on parse error
203+
}
204+
205+
// A full implementation would use locale-aware sorting (e.g., golang.org/x/text/collate).
206+
// A case-insensitive comparison is a reasonable and significant improvement.
207+
return strings.Compare(strings.ToLower(displayA), strings.ToLower(displayB))
208+
209+
default:
210+
// Default to no sorting for unknown criteria
211+
return 0
212+
}
213+
}
214+
215+
// getHeader extracts a header value from a message's raw bytes.
216+
// It performs a case-insensitive search for the key.
217+
func getHeader(buf []byte, key string) string {
218+
r := textproto.NewReader(bufio.NewReader(bytes.NewReader(buf)))
219+
hdr, err := r.ReadMIMEHeader()
220+
if err != nil {
221+
return "" // Or log the error
222+
}
223+
return hdr.Get(key)
224+
}

0 commit comments

Comments
 (0)