diff --git a/imapserver/capability.go b/imapserver/capability.go index 37da104b..825d98e4 100644 --- a/imapserver/capability.go +++ b/imapserver/capability.go @@ -93,6 +93,9 @@ func (c *Conn) availableCaps() []imap.Cap { imap.CapCreateSpecialUse, imap.CapLiteralPlus, imap.CapUnauthenticate, + imap.CapSort, + imap.CapSortDisplay, + imap.CapESort, }) if appendLimitSession, ok := c.session.(SessionAppendLimit); ok { diff --git a/imapserver/conn.go b/imapserver/conn.go index 291f37ec..5905719b 100644 --- a/imapserver/conn.go +++ b/imapserver/conn.go @@ -276,6 +276,8 @@ func (c *Conn) readCommand(dec *imapwire.Decoder) error { err = c.handleMove(dec, numKind) case "SEARCH", "UID SEARCH": err = c.handleSearch(tag, dec, numKind) + case "SORT", "UID SORT": + err = c.handleSort(tag, dec, numKind) default: if c.state == imap.ConnStateNotAuthenticated { // Don't allow a single unknown command before authentication to diff --git a/imapserver/imapmemserver/sort.go b/imapserver/imapmemserver/sort.go new file mode 100644 index 00000000..ab85af67 --- /dev/null +++ b/imapserver/imapmemserver/sort.go @@ -0,0 +1,224 @@ +package imapmemserver + +import ( + "bufio" + "bytes" + "net/mail" + "net/textproto" + "sort" + "strings" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapserver" +) + +// Sort performs a SORT command. +func (mbox *MailboxView) Sort(numKind imapserver.NumKind, criteria *imap.SearchCriteria, sortCriteria []imap.SortCriterion) ([]uint32, error) { + mbox.mutex.Lock() + defer mbox.mutex.Unlock() + + // Apply search criteria + mbox.staticSearchCriteria(criteria) + + // First find all messages that match the search criteria + var matchedMessages []*message + var matchedSeqNums []uint32 + var matchedIndices []int + for i, msg := range mbox.l { + seqNum := mbox.tracker.EncodeSeqNum(uint32(i) + 1) + + if !msg.search(seqNum, criteria) { + continue + } + + matchedMessages = append(matchedMessages, msg) + matchedSeqNums = append(matchedSeqNums, seqNum) + matchedIndices = append(matchedIndices, i) + } + + // Sort the matched messages based on the sort criteria + sortMatchedMessages(matchedMessages, matchedSeqNums, matchedIndices, sortCriteria) + + // Create sorted response + var data []uint32 + for i, msg := range matchedMessages { + var num uint32 + switch numKind { + case imapserver.NumKindSeq: + if matchedSeqNums[i] == 0 { + continue + } + num = matchedSeqNums[i] + case imapserver.NumKindUID: + num = uint32(msg.uid) + } + data = append(data, num) + } + + return data, nil +} + +// sortMatchedMessages sorts messages according to the specified sort criteria +func sortMatchedMessages(messages []*message, seqNums []uint32, indices []int, criteria []imap.SortCriterion) { + if len(messages) < 2 { + return // Nothing to sort + } + + // Create a slice of indices for sorting + indices2 := make([]int, len(messages)) + for i := range indices2 { + indices2[i] = i + } + + // Sort the indices based on the criteria + sort.SliceStable(indices2, func(i, j int) bool { + i2, j2 := indices2[i], indices2[j] + + // Apply each criterion in order until we find a difference + for _, criterion := range criteria { + result := compareByCriterion(messages[i2], messages[j2], criterion.Key) + + // Apply reverse if needed + if criterion.Reverse { + result = -result + } + + // If comparison yields a difference, return the result + if result < 0 { + return true + } else if result > 0 { + return false + } + // If equal, continue to the next criterion + } + + // If all criteria are equal, maintain original order + return i < j + }) + + // Reorder the original slices according to the sorted indices + newMessages := make([]*message, len(messages)) + newSeqNums := make([]uint32, len(seqNums)) + newIndices := make([]int, len(indices)) + + for i, idx := range indices2 { + newMessages[i] = messages[idx] + newSeqNums[i] = seqNums[idx] + newIndices[i] = indices[idx] + } + + // Copy sorted slices back to original slices + copy(messages, newMessages) + copy(seqNums, newSeqNums) + copy(indices, newIndices) +} + +// compareByCriterion compares two messages based on a single criterion +// returns -1 if a < b, 0 if a == b, 1 if a > b +func compareByCriterion(a, b *message, key imap.SortKey) int { + switch key { + case imap.SortKeyArrival: + // For ARRIVAL, we use the UID as the arrival order + if a.uid < b.uid { + return -1 + } else if a.uid > b.uid { + return 1 + } + return 0 + + case imap.SortKeyDate: + // Compare internal date + if a.t.Before(b.t) { + return -1 + } else if a.t.After(b.t) { + return 1 + } + return 0 + + case imap.SortKeySize: + // Compare message sizes + aSize := len(a.buf) + bSize := len(b.buf) + if aSize < bSize { + return -1 + } else if aSize > bSize { + return 1 + } + return 0 + + case imap.SortKeyFrom: + // NOTE: A fully compliant implementation as per RFC 5256 would parse + // the address and sort by mailbox, then host. This is a simplified + // case-insensitive comparison of the full header value. + fromA := getHeader(a.buf, "From") + fromB := getHeader(b.buf, "From") + return strings.Compare(strings.ToLower(fromA), strings.ToLower(fromB)) + + case imap.SortKeyTo: + // NOTE: Simplified comparison. See SortKeyFrom. + toA := getHeader(a.buf, "To") + toB := getHeader(b.buf, "To") + return strings.Compare(strings.ToLower(toA), strings.ToLower(toB)) + + case imap.SortKeyCc: + // NOTE: Simplified comparison. See SortKeyFrom. + ccA := getHeader(a.buf, "Cc") + ccB := getHeader(b.buf, "Cc") + return strings.Compare(strings.ToLower(ccA), strings.ToLower(ccB)) + + case imap.SortKeySubject: + // RFC 5256 specifies i;ascii-casemap collation, which is case-insensitive. + subjA := getHeader(a.buf, "Subject") + subjB := getHeader(b.buf, "Subject") + return strings.Compare(strings.ToLower(subjA), strings.ToLower(subjB)) + + case imap.SortKeyDisplay: + // RFC 5957: sort by display-name, fallback to mailbox. + fromA := getHeader(a.buf, "From") + fromB := getHeader(b.buf, "From") + + addrA, errA := mail.ParseAddress(fromA) + addrB, errB := mail.ParseAddress(fromB) + + var displayA, displayB string + + if errA == nil { + if addrA.Name != "" { + displayA = addrA.Name + } else { + displayA = addrA.Address + } + } else { + displayA = fromA // Fallback to raw header on parse error + } + + if errB == nil { + if addrB.Name != "" { + displayB = addrB.Name + } else { + displayB = addrB.Address + } + } else { + displayB = fromB // Fallback to raw header on parse error + } + + // A full implementation would use locale-aware sorting (e.g., golang.org/x/text/collate). + // A case-insensitive comparison is a reasonable and significant improvement. + return strings.Compare(strings.ToLower(displayA), strings.ToLower(displayB)) + + default: + // Default to no sorting for unknown criteria + return 0 + } +} + +// getHeader extracts a header value from a message's raw bytes. +// It performs a case-insensitive search for the key. +func getHeader(buf []byte, key string) string { + r := textproto.NewReader(bufio.NewReader(bytes.NewReader(buf))) + hdr, err := r.ReadMIMEHeader() + if err != nil { + return "" // Or log the error + } + return hdr.Get(key) +} diff --git a/imapserver/sort.go b/imapserver/sort.go new file mode 100644 index 00000000..7fd48e01 --- /dev/null +++ b/imapserver/sort.go @@ -0,0 +1,234 @@ +package imapserver + +import ( + "fmt" + "strings" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/internal/imapwire" +) + +type SortData struct { + Nums []uint32 + Min uint32 + Max uint32 + Count uint32 +} + +type SessionSort interface { + Session + + Sort(numKind NumKind, criteria *imap.SearchCriteria, sortCriteria []imap.SortCriterion) ([]uint32, error) +} + +type esortReturnOptions struct { + Min bool + Max bool + Count bool + All bool +} + +func (c *Conn) handleSort(tag string, dec *imapwire.Decoder, numKind NumKind) error { + if !dec.ExpectSP() { + return dec.Err() + } + + var esortReturnOpts esortReturnOptions + esortReturnOpts.All = true // Default if no RETURN or RETURN (ALL) + + var atom string + // dec.Func returns true if an atom is read; 'atom' will contain it. + // If the next token is not an atom (e.g., '('), it returns false and dec.Err() is nil. + if dec.Func(&atom, imapwire.IsAtomChar) && strings.EqualFold(atom, "RETURN") { + // Atom "RETURN" was successfully read and consumed. + if !dec.ExpectSP() { + return dec.Err() + } + + esortReturnOpts.All = false // Explicit RETURN given, so default ALL is off unless specified in list + + parseReturnErr := dec.ExpectList(func() error { + var opt string + if !dec.ExpectAtom(&opt) { + return dec.Err() + } + opt = strings.ToUpper(opt) + switch opt { + case "MIN": + esortReturnOpts.Min = true + case "MAX": + esortReturnOpts.Max = true + case "COUNT": + esortReturnOpts.Count = true + case "ALL": + esortReturnOpts.All = true + default: + // RFC 5267: Servers MUST ignore any unknown sort-return-opt. + } + return nil + }) + if parseReturnErr != nil { + return parseReturnErr + } + + if esortReturnOpts.All && (esortReturnOpts.Min || esortReturnOpts.Max || esortReturnOpts.Count) { + return &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: "ESORT RETURN ALL cannot be combined with MIN, MAX, or COUNT", + } + } + + // If RETURN was specified but resulted in no recognized options, default to ALL. + // This means if RETURN () or RETURN (UNKNOWN_OPT) is sent, it behaves as if RETURN (ALL) or no RETURN was sent. + if !esortReturnOpts.Min && !esortReturnOpts.Max && !esortReturnOpts.Count && !esortReturnOpts.All { + esortReturnOpts.All = true + } + + if !dec.ExpectSP() { // Expect SP after RETURN (...) + return dec.Err() + } + } else if dec.Err() != nil { + // dec.Func failed for a reason other than the first char not matching (e.g. EOF or malformed atom) + return dec.Err() + } + + var sortCriteria []imap.SortCriterion + listErr := dec.ExpectList(func() error { + for { // Loop to correctly parse multiple sort criteria items + var criterion imap.SortCriterion + var atom string + if !dec.ExpectAtom(&atom) { + return dec.Err() + } + + if strings.EqualFold(atom, "REVERSE") { + criterion.Reverse = true + if !dec.ExpectSP() || !dec.ExpectAtom(&atom) { + return dec.Err() + } + } + + criterion.Key = imap.SortKey(strings.ToUpper(atom)) + sortCriteria = append(sortCriteria, criterion) + + if !dec.SP() { // If no more SP, then no more sort criteria in this list + break + } + } + return nil + }) + if listErr != nil { + return listErr + } + + // Parse charset - must be UTF-8 for SORT + if !dec.ExpectSP() { + return dec.Err() + } + var charset string + if !dec.ExpectAtom(&charset) || !dec.ExpectSP() { + return dec.Err() + } + if !strings.EqualFold(charset, "UTF-8") { + return &imap.Error{ + Type: imap.StatusResponseTypeNo, + Code: imap.ResponseCodeBadCharset, + Text: "Only UTF-8 is supported for SORT", + } + } + + // Parse search criteria + var criteria imap.SearchCriteria + for { + if err := readSearchKey(&criteria, dec); err != nil { + return fmt.Errorf("in search-key: %w", err) + } + if !dec.SP() { // If no more SP, then no more search keys + break + } + } + + if !dec.ExpectCRLF() { + return dec.Err() + } + + if err := c.checkState(imap.ConnStateSelected); err != nil { + return err + } + + var sortedNums []uint32 + if sortSession, ok := c.session.(SessionSort); ok { + var sortErr error + sortedNums, sortErr = sortSession.Sort(numKind, &criteria, sortCriteria) + if sortErr != nil { + return sortErr + } + } else { + return &imap.Error{ + Type: imap.StatusResponseTypeNo, + Code: imap.ResponseCodeCannot, + Text: "SORT command is not supported by this session", + } + } + + data := &SortData{Nums: sortedNums} + if len(sortedNums) > 0 { + data.Count = uint32(len(sortedNums)) + min, max := sortedNums[0], sortedNums[0] + for _, num := range sortedNums { + if num < min { + min = num + } + if num > max { + max = num + } + } + data.Min = min + data.Max = max + } + + return c.writeSortResponse(tag, numKind, data, &esortReturnOpts) +} + +func (c *Conn) writeSortResponse(tag string, numKind NumKind, data *SortData, returnOpts *esortReturnOptions) error { + // For ESORT, if RETURN options other than ALL are specified, send an ESEARCH response. + // See RFC 5267 section 4.2. + if c.server.options.caps().Has(imap.CapESort) && !returnOpts.All { + enc := newResponseEncoder(c) + enc.Atom("*").SP().Atom("ESEARCH") + if tag != "" { + enc.SP().Special('(').Atom("TAG").SP().String(tag).Special(')') + } + if numKind == NumKindUID { + enc.SP().Atom("UID") + } + if returnOpts.Min { + if data.Count > 0 { + enc.SP().Atom("MIN").SP().Number(data.Min) + } + } + if returnOpts.Max { + if data.Count > 0 { + enc.SP().Atom("MAX").SP().Number(data.Max) + } + } + if returnOpts.Count { + enc.SP().Atom("COUNT").SP().Number(data.Count) + } + if err := enc.CRLF(); err != nil { + enc.end() + return err + } + enc.end() + } + + // A SORT response is always sent, either for a regular SORT, or following + // an ESEARCH response for an ESORT. + enc := newResponseEncoder(c) + defer enc.end() + enc.Atom("*").SP().Atom("SORT") + for _, num := range data.Nums { + enc.SP().Number(num) + } + return enc.CRLF() +} diff --git a/sort.go b/sort.go new file mode 100644 index 00000000..9ba89cdd --- /dev/null +++ b/sort.go @@ -0,0 +1,19 @@ +package imap + +type SortKey string + +const ( + SortKeyArrival SortKey = "ARRIVAL" + SortKeyCc SortKey = "CC" + SortKeyDate SortKey = "DATE" + SortKeyDisplay SortKey = "DISPLAY" // RFC 5957 + SortKeyFrom SortKey = "FROM" + SortKeySize SortKey = "SIZE" + SortKeySubject SortKey = "SUBJECT" + SortKeyTo SortKey = "TO" +) + +type SortCriterion struct { + Key SortKey + Reverse bool +}