From 7e8c5a19ea5efeae960e24a38091e67b6e76638f Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sat, 7 Jun 2025 01:09:37 +0200 Subject: [PATCH 01/29] add independent capabilities --- imapserver/capability.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/imapserver/capability.go b/imapserver/capability.go index b3e7c99b..ebed9264 100644 --- a/imapserver/capability.go +++ b/imapserver/capability.go @@ -75,6 +75,8 @@ func (c *Conn) availableCaps() []imap.Cap { imap.CapMove, imap.CapStatusSize, imap.CapBinary, + imap.CapChildren, + imap.CapID, }) } addAvailableCaps(&caps, available, []imap.Cap{ From b6e4b9c1010b854315cf426d0cd5ea21f788a7a1 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sat, 7 Jun 2025 15:10:36 +0200 Subject: [PATCH 02/29] imapserver: add SORT and SORT=DISPLAY capabilities --- imapserver/capability.go | 2 + imapserver/conn.go | 2 + imapserver/imapmemserver/session.go | 12 ++ imapserver/imapmemserver/sort.go | 172 ++++++++++++++++++++++++++++ imapserver/sort.go | 133 +++++++++++++++++++++ sort.go | 19 +++ 6 files changed, 340 insertions(+) create mode 100644 imapserver/imapmemserver/sort.go create mode 100644 imapserver/sort.go create mode 100644 sort.go diff --git a/imapserver/capability.go b/imapserver/capability.go index b3e7c99b..1649bb9c 100644 --- a/imapserver/capability.go +++ b/imapserver/capability.go @@ -81,6 +81,8 @@ func (c *Conn) availableCaps() []imap.Cap { imap.CapCreateSpecialUse, imap.CapLiteralPlus, imap.CapUnauthenticate, + imap.CapSort, + imap.CapSortDisplay, }) } return caps 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/session.go b/imapserver/imapmemserver/session.go index 70e9d2f8..a00200e2 100644 --- a/imapserver/imapmemserver/session.go +++ b/imapserver/imapmemserver/session.go @@ -20,6 +20,7 @@ type UserSession struct { } var _ imapserver.SessionIMAP4rev2 = (*UserSession)(nil) +var _ imapserver.SessionSort = (*UserSession)(nil) // NewUserSession creates a new user session. func NewUserSession(user *User) *UserSession { @@ -138,3 +139,14 @@ func (sess *UserSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) } return sess.mailbox.Idle(w, stop) } + +// Sort implements the imapserver.SessionSort interface. +func (sess *UserSession) Sort(numKind imapserver.NumKind, criteria *imap.SearchCriteria, sortCriteria []imap.SortCriterion) (*imapserver.SortData, error) { + if sess.mailbox == nil { + return nil, &imap.Error{ + Type: imap.StatusResponseTypeNo, + Text: "No mailbox selected", + } + } + return sess.mailbox.Sort(numKind, criteria, sortCriteria) +} diff --git a/imapserver/imapmemserver/sort.go b/imapserver/imapmemserver/sort.go new file mode 100644 index 00000000..d670c7bf --- /dev/null +++ b/imapserver/imapmemserver/sort.go @@ -0,0 +1,172 @@ +package imapmemserver + +import ( + "sort" + + "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) (*imapserver.SortData, 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 imapserver.SortData + 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.Nums = append(data.Nums, 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: + // TODO: For a real implementation, extract the From header and compare + return 0 + + case imap.SortKeyTo: + // TODO: For a real implementation, you would extract the To header and compare + return 0 + + case imap.SortKeyCc: + // TODO: For a real implementation, you would extract the Cc header and compare + return 0 + + case imap.SortKeySubject: + // TODO: For a real implementation, you would extract the Subject header and compare + return 0 + + case imap.SortKeyDisplay: + // SORT=DISPLAY (RFC 5957) - Use a locale-sensitive version of the string + // For now, treat it the same as the subject sorting for this implementation + // TODO: For a real implementation, use proper locale-aware sorting of display names + // A full implementation would handle internationalized text according to + // the user's locale settings and apply proper collation rules + return 0 + + default: + // Default to no sorting for unknown criteria + return 0 + } +} diff --git a/imapserver/sort.go b/imapserver/sort.go new file mode 100644 index 00000000..e12ee5fb --- /dev/null +++ b/imapserver/sort.go @@ -0,0 +1,133 @@ +package imapserver + +import ( + "fmt" + "strings" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/internal/imapwire" +) + +type SortData struct { + Nums []uint32 +} + +type SessionSort interface { + Session + + Sort(numKind NumKind, criteria *imap.SearchCriteria, sortCriteria []imap.SortCriterion) (*SortData, error) +} + +func (c *Conn) handleSort(tag string, dec *imapwire.Decoder, numKind NumKind) error { + if !dec.ExpectSP() { + return dec.Err() + } + + var sortCriteria []imap.SortCriterion + listErr := dec.ExpectList(func() error { + for { + 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() { + 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 + var atom string + if maybeReadSearchKeyAtom(dec, &atom) { + if err := readSearchKeyWithAtom(&criteria, dec, atom); err != nil { + return fmt.Errorf("in search-key: %w", err) + } + } else { + if err := readSearchKey(&criteria, dec); err != nil { + return fmt.Errorf("in search-key: %w", err) + } + } + + for dec.SP() { + atom = "" + if maybeReadSearchKeyAtom(dec, &atom) { + if err := readSearchKeyWithAtom(&criteria, dec, atom); err != nil { + return fmt.Errorf("in search-key: %w", err) + } + } else { + if err := readSearchKey(&criteria, dec); err != nil { + return fmt.Errorf("in search-key: %w", err) + } + } + } + + if !dec.ExpectCRLF() { + return dec.Err() + } + + if err := c.checkState(imap.ConnStateSelected); err != nil { + return err + } + + var data *SortData + if sortSession, ok := c.session.(SessionSort); ok { + var sortErr error + data, sortErr = sortSession.Sort(numKind, &criteria, sortCriteria) + if sortErr != nil { + return sortErr + } + } else { + return &imap.Error{ + Type: imap.StatusResponseTypeNo, + Code: imap.ResponseCodeCannot, + Text: "SORT not implemented", + } + } + + return c.writeSortResponse(data.Nums) +} + +func (c *Conn) writeSortResponse(nums []uint32) error { + enc := newResponseEncoder(c) + defer enc.end() + + enc.Atom("*").SP().Atom("SORT") + for _, num := range 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 +} From 08495b9dca81c627ade88d88246693c4d85a07bb Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sat, 7 Jun 2025 16:40:08 +0200 Subject: [PATCH 03/29] imapserver: add ESORT capability --- imapserver/capability.go | 1 + imapserver/imapmemserver/sort.go | 18 +++++ imapserver/sort.go | 135 ++++++++++++++++++++++++------- 3 files changed, 127 insertions(+), 27 deletions(-) diff --git a/imapserver/capability.go b/imapserver/capability.go index 1649bb9c..7b9b2f96 100644 --- a/imapserver/capability.go +++ b/imapserver/capability.go @@ -83,6 +83,7 @@ func (c *Conn) availableCaps() []imap.Cap { imap.CapUnauthenticate, imap.CapSort, imap.CapSortDisplay, + imap.CapESort, }) } return caps diff --git a/imapserver/imapmemserver/sort.go b/imapserver/imapmemserver/sort.go index d670c7bf..f44e6818 100644 --- a/imapserver/imapmemserver/sort.go +++ b/imapserver/imapmemserver/sort.go @@ -50,6 +50,24 @@ func (mbox *MailboxView) Sort(numKind imapserver.NumKind, criteria *imap.SearchC data.Nums = append(data.Nums, num) } + // Calculate ESORT data fields if there are results + if len(data.Nums) > 0 { + // Find min and max values + min, max := data.Nums[0], data.Nums[0] + for _, num := range data.Nums { + if num < min { + min = num + } + if num > max { + max = num + } + } + data.Min = min + data.Max = max + } + // Set count regardless of whether there are results + data.Count = uint32(len(data.Nums)) + return &data, nil } diff --git a/imapserver/sort.go b/imapserver/sort.go index e12ee5fb..8c4a3ba9 100644 --- a/imapserver/sort.go +++ b/imapserver/sort.go @@ -9,7 +9,10 @@ import ( ) type SortData struct { - Nums []uint32 + Nums []uint32 + Min uint32 + Max uint32 + Count uint32 } type SessionSort interface { @@ -18,14 +21,80 @@ type SessionSort interface { Sort(numKind NumKind, criteria *imap.SearchCriteria, sortCriteria []imap.SortCriterion) (*SortData, 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 { + for { // Loop to correctly parse multiple sort criteria items var criterion imap.SortCriterion var atom string if !dec.ExpectAtom(&atom) { @@ -42,7 +111,7 @@ func (c *Conn) handleSort(tag string, dec *imapwire.Decoder, numKind NumKind) er criterion.Key = imap.SortKey(strings.ToUpper(atom)) sortCriteria = append(sortCriteria, criterion) - if !dec.SP() { + if !dec.SP() { // If no more SP, then no more sort criteria in this list break } } @@ -70,27 +139,12 @@ func (c *Conn) handleSort(tag string, dec *imapwire.Decoder, numKind NumKind) er // Parse search criteria var criteria imap.SearchCriteria - var atom string - if maybeReadSearchKeyAtom(dec, &atom) { - if err := readSearchKeyWithAtom(&criteria, dec, atom); err != nil { - return fmt.Errorf("in search-key: %w", err) - } - } else { + for { if err := readSearchKey(&criteria, dec); err != nil { return fmt.Errorf("in search-key: %w", err) } - } - - for dec.SP() { - atom = "" - if maybeReadSearchKeyAtom(dec, &atom) { - if err := readSearchKeyWithAtom(&criteria, dec, atom); err != nil { - return fmt.Errorf("in search-key: %w", err) - } - } else { - 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 } } @@ -113,20 +167,47 @@ func (c *Conn) handleSort(tag string, dec *imapwire.Decoder, numKind NumKind) er return &imap.Error{ Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeCannot, - Text: "SORT not implemented", + Text: "SORT not implemented by session", } } - return c.writeSortResponse(data.Nums) + return c.writeSortResponse(tag, numKind, data, &esortReturnOpts) } -func (c *Conn) writeSortResponse(nums []uint32) error { +func (c *Conn) writeSortResponse(tag string, numKind NumKind, data *SortData, returnOpts *esortReturnOptions) error { enc := newResponseEncoder(c) defer enc.end() - enc.Atom("*").SP().Atom("SORT") - for _, num := range nums { - enc.SP().Number(num) + // For ESORT, use ESEARCH response format if not RETURN ALL + if c.server.options.caps().Has(imap.CapESort) && !returnOpts.All { + enc.Atom("*").SP().Atom("ESEARCH") + if tag != "" { + enc.SP().Special('(').Atom("TAG").SP().Atom(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) + } + // Note: No "ALL " here for ESORT as per RFC 5267 + } else { + // Use regular SORT response for non-ESORT clients or when RETURN (ALL) or no RETURN option is specified + enc.Atom("*").SP().Atom("SORT") + for _, num := range data.Nums { + enc.SP().Number(num) + } } return enc.CRLF() From 384cff22e14bf4442fe133d8838c193c2b8dc1f5 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sat, 7 Jun 2025 17:18:43 +0200 Subject: [PATCH 04/29] use a list instead of a struct --- imapserver/sort.go | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/imapserver/sort.go b/imapserver/sort.go index 8c4a3ba9..099fedcf 100644 --- a/imapserver/sort.go +++ b/imapserver/sort.go @@ -8,19 +8,13 @@ import ( "github.com/emersion/go-imap/v2/internal/imapwire" ) -type SortData struct { +type sortData struct { Nums []uint32 Min uint32 Max uint32 Count uint32 } -type SessionSort interface { - Session - - Sort(numKind NumKind, criteria *imap.SearchCriteria, sortCriteria []imap.SortCriterion) (*SortData, error) -} - type esortReturnOptions struct { Min bool Max bool @@ -28,6 +22,12 @@ type esortReturnOptions struct { All bool } +type SessionSort interface { + Session + + Sort(numKind NumKind, criteria *imap.SearchCriteria, sortCriteria []imap.SortCriterion) ([]uint32, error) +} + func (c *Conn) handleSort(tag string, dec *imapwire.Decoder, numKind NumKind) error { if !dec.ExpectSP() { return dec.Err() @@ -156,10 +156,10 @@ func (c *Conn) handleSort(tag string, dec *imapwire.Decoder, numKind NumKind) er return err } - var data *SortData + var sortedNums []uint32 if sortSession, ok := c.session.(SessionSort); ok { var sortErr error - data, sortErr = sortSession.Sort(numKind, &criteria, sortCriteria) + sortedNums, sortErr = sortSession.Sort(numKind, &criteria, sortCriteria) if sortErr != nil { return sortErr } @@ -167,14 +167,21 @@ func (c *Conn) handleSort(tag string, dec *imapwire.Decoder, numKind NumKind) er return &imap.Error{ Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeCannot, - Text: "SORT not implemented by session", + Text: "SORT command is not supported by this session", } } + data := &sortData{Nums: sortedNums} + if len(sortedNums) > 0 { + data.Count = uint32(len(sortedNums)) + data.Min = sortedNums[0] + data.Max = sortedNums[len(sortedNums)-1] + } + return c.writeSortResponse(tag, numKind, data, &esortReturnOpts) } -func (c *Conn) writeSortResponse(tag string, numKind NumKind, data *SortData, returnOpts *esortReturnOptions) error { +func (c *Conn) writeSortResponse(tag string, numKind NumKind, data *sortData, returnOpts *esortReturnOptions) error { enc := newResponseEncoder(c) defer enc.end() From 6c983fab58882f768e4d314444507eb59a610b3b Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sat, 7 Jun 2025 17:34:33 +0200 Subject: [PATCH 05/29] capability ID required separate parsing, adding it now --- imapserver/conn.go | 3 + imapserver/id.go | 154 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 imapserver/id.go diff --git a/imapserver/conn.go b/imapserver/conn.go index 291f37ec..637e62f9 100644 --- a/imapserver/conn.go +++ b/imapserver/conn.go @@ -220,6 +220,9 @@ func (c *Conn) readCommand(dec *imapwire.Decoder) error { err = c.handleLogout(dec) case "CAPABILITY": err = c.handleCapability(dec) + case "ID": + err = c.handleID(tag, dec) + sendOK = false case "STARTTLS": err = c.handleStartTLS(tag, dec) sendOK = false diff --git a/imapserver/id.go b/imapserver/id.go new file mode 100644 index 00000000..3fe5d41f --- /dev/null +++ b/imapserver/id.go @@ -0,0 +1,154 @@ +package imapserver + +import ( + "fmt" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/internal/imapwire" +) + +func (c *Conn) handleID(tag string, dec *imapwire.Decoder) error { + idData, err := readID(dec) + if err != nil { + return fmt.Errorf("in id: %v", err) + } + + if !dec.ExpectCRLF() { + return dec.Err() + } + + var serverIDData *imap.IDData + if idSess, ok := c.session.(SessionID); ok { + serverIDData = idSess.ID(idData) + } + + enc := newResponseEncoder(c) + defer enc.end() + enc.Atom("*").SP().Atom("ID") + + if serverIDData == nil { + enc.SP().NIL() + } else { + enc.SP().Special('(') + isFirstKey := true + if serverIDData.Name != "" { + addIDKeyValue(enc.Encoder, &isFirstKey, "name", serverIDData.Name) + } + if serverIDData.Version != "" { + addIDKeyValue(enc.Encoder, &isFirstKey, "version", serverIDData.Version) + } + if serverIDData.OS != "" { + addIDKeyValue(enc.Encoder, &isFirstKey, "os", serverIDData.OS) + } + if serverIDData.OSVersion != "" { + addIDKeyValue(enc.Encoder, &isFirstKey, "os-version", serverIDData.OSVersion) + } + if serverIDData.Vendor != "" { + addIDKeyValue(enc.Encoder, &isFirstKey, "vendor", serverIDData.Vendor) + } + if serverIDData.SupportURL != "" { + addIDKeyValue(enc.Encoder, &isFirstKey, "support-url", serverIDData.SupportURL) + } + if serverIDData.Address != "" { + addIDKeyValue(enc.Encoder, &isFirstKey, "address", serverIDData.Address) + } + if serverIDData.Date != "" { + addIDKeyValue(enc.Encoder, &isFirstKey, "date", serverIDData.Date) + } + if serverIDData.Command != "" { + addIDKeyValue(enc.Encoder, &isFirstKey, "command", serverIDData.Command) + } + if serverIDData.Arguments != "" { + addIDKeyValue(enc.Encoder, &isFirstKey, "arguments", serverIDData.Arguments) + } + if serverIDData.Environment != "" { + addIDKeyValue(enc.Encoder, &isFirstKey, "environment", serverIDData.Environment) + } + enc.Special(')') + } + + if err := enc.CRLF(); err != nil { + return err + } + + return c.writeStatusResp(tag, &imap.StatusResponse{ + Type: imap.StatusResponseTypeOK, + Text: "ID completed", + }) +} + +func readID(dec *imapwire.Decoder) (*imap.IDData, error) { + if !dec.ExpectSP() { + return nil, dec.Err() + } + + if dec.ExpectNIL() { + return nil, nil + } + + data := &imap.IDData{} + currKey := "" + err := dec.ExpectList(func() error { + var keyOrValue string + if !dec.String(&keyOrValue) { + return fmt.Errorf("in id key-val list: %v", dec.Err()) + } + + if currKey == "" { + currKey = keyOrValue + return nil + } + + switch currKey { + case "name": + data.Name = keyOrValue + case "version": + data.Version = keyOrValue + case "os": + data.OS = keyOrValue + case "os-version": + data.OSVersion = keyOrValue + case "vendor": + data.Vendor = keyOrValue + case "support-url": + data.SupportURL = keyOrValue + case "address": + data.Address = keyOrValue + case "date": + data.Date = keyOrValue + case "command": + data.Command = keyOrValue + case "arguments": + data.Arguments = keyOrValue + case "environment": + data.Environment = keyOrValue + default: + // Ignore unknown key + } + currKey = "" + + return nil + }) + + if err != nil { + return nil, err + } + + return data, nil +} + +func addIDKeyValue(enc *imapwire.Encoder, isFirstKey *bool, key, value string) { + if *isFirstKey { + enc.Quoted(key).SP().Quoted(value) + } else { + enc.SP().Quoted(key).SP().Quoted(value) + } + *isFirstKey = false +} + +// SessionID is an interface for sessions that can provide server ID information. +type SessionID interface { + // ID returns server information in response to a client ID command. + // The client's ID information is provided if available. + ID(clientID *imap.IDData) *imap.IDData +} From 025cfbc11a171728d97dd194651195ebf9859bb2 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 8 Jun 2025 19:01:10 +0200 Subject: [PATCH 06/29] fix: ensure response encoder is properly finalized in handleID --- imapserver/id.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/imapserver/id.go b/imapserver/id.go index 3fe5d41f..c130209a 100644 --- a/imapserver/id.go +++ b/imapserver/id.go @@ -23,7 +23,6 @@ func (c *Conn) handleID(tag string, dec *imapwire.Decoder) error { } enc := newResponseEncoder(c) - defer enc.end() enc.Atom("*").SP().Atom("ID") if serverIDData == nil { @@ -67,7 +66,9 @@ func (c *Conn) handleID(tag string, dec *imapwire.Decoder) error { enc.Special(')') } - if err := enc.CRLF(); err != nil { + err = enc.CRLF() + enc.end() + if err != nil { return err } From 94bb68afd4e505b5b4ed54a098352b637c2a2fb6 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Mon, 9 Jun 2025 16:59:01 +0200 Subject: [PATCH 07/29] moving CapChildren to own PR --- imapserver/capability.go | 1 - 1 file changed, 1 deletion(-) diff --git a/imapserver/capability.go b/imapserver/capability.go index 204aafb7..39a9dd3a 100644 --- a/imapserver/capability.go +++ b/imapserver/capability.go @@ -80,7 +80,6 @@ func (c *Conn) availableCaps() []imap.Cap { imap.CapMove, imap.CapStatusSize, imap.CapBinary, - imap.CapChildren, imap.CapID, }) } From bb05d235e806b2c6a8e4c98a05e248cc8ee1df00 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Wed, 11 Jun 2025 21:34:32 +0200 Subject: [PATCH 08/29] mkae CapID applicable to both rev1 and rev2 --- imapserver/capability.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/imapserver/capability.go b/imapserver/capability.go index d592a48a..7c3306f1 100644 --- a/imapserver/capability.go +++ b/imapserver/capability.go @@ -80,7 +80,6 @@ func (c *Conn) availableCaps() []imap.Cap { imap.CapMove, imap.CapStatusSize, imap.CapBinary, - imap.CapID, }) } @@ -91,6 +90,7 @@ func (c *Conn) availableCaps() []imap.Cap { imap.CapCreateSpecialUse, imap.CapLiteralPlus, imap.CapUnauthenticate, + imap.CapID, }) } return caps From 44d415fb0e2f5d7f1dbb55a222a01920e8136501 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Wed, 11 Jun 2025 21:55:11 +0200 Subject: [PATCH 09/29] moved logic for sortdata to library from memserver --- imapserver/imapmemserver/session.go | 12 ------------ imapserver/imapmemserver/sort.go | 26 ++++---------------------- imapserver/sort.go | 18 +++++++++--------- 3 files changed, 13 insertions(+), 43 deletions(-) diff --git a/imapserver/imapmemserver/session.go b/imapserver/imapmemserver/session.go index a00200e2..70e9d2f8 100644 --- a/imapserver/imapmemserver/session.go +++ b/imapserver/imapmemserver/session.go @@ -20,7 +20,6 @@ type UserSession struct { } var _ imapserver.SessionIMAP4rev2 = (*UserSession)(nil) -var _ imapserver.SessionSort = (*UserSession)(nil) // NewUserSession creates a new user session. func NewUserSession(user *User) *UserSession { @@ -139,14 +138,3 @@ func (sess *UserSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) } return sess.mailbox.Idle(w, stop) } - -// Sort implements the imapserver.SessionSort interface. -func (sess *UserSession) Sort(numKind imapserver.NumKind, criteria *imap.SearchCriteria, sortCriteria []imap.SortCriterion) (*imapserver.SortData, error) { - if sess.mailbox == nil { - return nil, &imap.Error{ - Type: imap.StatusResponseTypeNo, - Text: "No mailbox selected", - } - } - return sess.mailbox.Sort(numKind, criteria, sortCriteria) -} diff --git a/imapserver/imapmemserver/sort.go b/imapserver/imapmemserver/sort.go index f44e6818..0c9043e2 100644 --- a/imapserver/imapmemserver/sort.go +++ b/imapserver/imapmemserver/sort.go @@ -8,7 +8,7 @@ import ( ) // Sort performs a SORT command. -func (mbox *MailboxView) Sort(numKind imapserver.NumKind, criteria *imap.SearchCriteria, sortCriteria []imap.SortCriterion) (*imapserver.SortData, error) { +func (mbox *MailboxView) Sort(numKind imapserver.NumKind, criteria *imap.SearchCriteria, sortCriteria []imap.SortCriterion) ([]uint32, error) { mbox.mutex.Lock() defer mbox.mutex.Unlock() @@ -35,7 +35,7 @@ func (mbox *MailboxView) Sort(numKind imapserver.NumKind, criteria *imap.SearchC sortMatchedMessages(matchedMessages, matchedSeqNums, matchedIndices, sortCriteria) // Create sorted response - var data imapserver.SortData + var data []uint32 for i, msg := range matchedMessages { var num uint32 switch numKind { @@ -47,28 +47,10 @@ func (mbox *MailboxView) Sort(numKind imapserver.NumKind, criteria *imap.SearchC case imapserver.NumKindUID: num = uint32(msg.uid) } - data.Nums = append(data.Nums, num) + data = append(data, num) } - // Calculate ESORT data fields if there are results - if len(data.Nums) > 0 { - // Find min and max values - min, max := data.Nums[0], data.Nums[0] - for _, num := range data.Nums { - if num < min { - min = num - } - if num > max { - max = num - } - } - data.Min = min - data.Max = max - } - // Set count regardless of whether there are results - data.Count = uint32(len(data.Nums)) - - return &data, nil + return data, nil } // sortMatchedMessages sorts messages according to the specified sort criteria diff --git a/imapserver/sort.go b/imapserver/sort.go index 099fedcf..bb0766d1 100644 --- a/imapserver/sort.go +++ b/imapserver/sort.go @@ -8,13 +8,19 @@ import ( "github.com/emersion/go-imap/v2/internal/imapwire" ) -type sortData struct { +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 @@ -22,12 +28,6 @@ type esortReturnOptions struct { All bool } -type SessionSort interface { - Session - - Sort(numKind NumKind, criteria *imap.SearchCriteria, sortCriteria []imap.SortCriterion) ([]uint32, error) -} - func (c *Conn) handleSort(tag string, dec *imapwire.Decoder, numKind NumKind) error { if !dec.ExpectSP() { return dec.Err() @@ -171,7 +171,7 @@ func (c *Conn) handleSort(tag string, dec *imapwire.Decoder, numKind NumKind) er } } - data := &sortData{Nums: sortedNums} + data := &SortData{Nums: sortedNums} if len(sortedNums) > 0 { data.Count = uint32(len(sortedNums)) data.Min = sortedNums[0] @@ -181,7 +181,7 @@ func (c *Conn) handleSort(tag string, dec *imapwire.Decoder, numKind NumKind) er return c.writeSortResponse(tag, numKind, data, &esortReturnOpts) } -func (c *Conn) writeSortResponse(tag string, numKind NumKind, data *sortData, returnOpts *esortReturnOptions) error { +func (c *Conn) writeSortResponse(tag string, numKind NumKind, data *SortData, returnOpts *esortReturnOptions) error { enc := newResponseEncoder(c) defer enc.end() From 6ed205a343d0f6f33e1e96c2a321139af258b34b Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Fri, 6 Jun 2025 12:38:07 +0200 Subject: [PATCH 10/29] Add CONDSTORE extension support --- imapclient/client_test.go | 1 + imapclient/condstore_test.go | 311 ++++++++++++++++++++++++++++ imapserver/capability.go | 1 + imapserver/fetch.go | 28 +++ imapserver/imapmemserver/mailbox.go | 82 ++++++-- imapserver/imapmemserver/message.go | 32 ++- imapserver/search.go | 27 +++ imapserver/select.go | 62 +++++- imapserver/status.go | 5 + imapserver/store.go | 27 ++- 10 files changed, 551 insertions(+), 25 deletions(-) create mode 100644 imapclient/condstore_test.go diff --git a/imapclient/client_test.go b/imapclient/client_test.go index 9e5c206f..1d5158c3 100644 --- a/imapclient/client_test.go +++ b/imapclient/client_test.go @@ -102,6 +102,7 @@ func newMemClientServerPair(t *testing.T) (net.Conn, io.Closer) { Caps: imap.CapSet{ imap.CapIMAP4rev1: {}, imap.CapIMAP4rev2: {}, + imap.CapCondStore: {}, }, }) diff --git a/imapclient/condstore_test.go b/imapclient/condstore_test.go new file mode 100644 index 00000000..54b9aab4 --- /dev/null +++ b/imapclient/condstore_test.go @@ -0,0 +1,311 @@ +package imapclient_test + +import ( + "testing" + + "github.com/emersion/go-imap/v2" +) + +func TestSelect_CondStore(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + // Test SELECT with CONDSTORE parameter + options := &imap.SelectOptions{ + CondStore: true, + } + data, err := client.Select("INBOX", options).Wait() + if err != nil { + t.Fatalf("Select() with CONDSTORE = %v", err) + } + + // Verify that HighestModSeq is returned + if data.HighestModSeq == 0 { + t.Errorf("SelectData.HighestModSeq is 0, expected non-zero value when CONDSTORE is enabled") + } + t.Logf("Mailbox HIGHESTMODSEQ: %d", data.HighestModSeq) +} + +func TestFetch_ModSeq(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateSelected) + defer client.Close() + defer server.Close() + + // Test FETCH with MODSEQ item + seqSet := imap.SeqSetNum(1) + fetchOptions := &imap.FetchOptions{ + ModSeq: true, + } + messages, err := client.Fetch(seqSet, fetchOptions).Collect() + if err != nil { + t.Fatalf("Fetch() with MODSEQ = %v", err) + } else if len(messages) != 1 { + t.Fatalf("len(messages) = %v, want 1", len(messages)) + } + + msg := messages[0] + if msg.ModSeq == 0 { + t.Errorf("msg.ModSeq is 0, expected non-zero value") + } + t.Logf("Message MODSEQ: %d", msg.ModSeq) +} + +func TestFetch_ChangedSince(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateSelected) + defer client.Close() + defer server.Close() + + // First, get current ModSeq + seqSet := imap.SeqSetNum(1) + firstFetch, err := client.Fetch(seqSet, &imap.FetchOptions{ + ModSeq: true, + }).Collect() + if err != nil { + t.Fatalf("Initial Fetch() = %v", err) + } + currentModSeq := firstFetch[0].ModSeq + t.Logf("Initial ModSeq: %d", currentModSeq) + + // Now fetch with CHANGEDSINCE using the current ModSeq + // This should return no messages since nothing has changed + fetchOptions := &imap.FetchOptions{ + Flags: true, + ChangedSince: currentModSeq, + } + messages, err := client.Fetch(seqSet, fetchOptions).Collect() + if err != nil { + t.Fatalf("Fetch() with CHANGEDSINCE = %v", err) + } + + // No messages should be returned since nothing has changed + if len(messages) != 0 { + t.Errorf("Fetch() with CHANGEDSINCE returned %d messages, want 0", len(messages)) + } + + // Now modify the message + storeFlags := imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Flags: []imap.Flag{imap.FlagSeen}, + } + storeCmd := client.Store(seqSet, &storeFlags, nil) + storeResults, err := storeCmd.Collect() + if err != nil { + t.Fatalf("Store() = %v", err) + } + t.Logf("Store results: %d messages", len(storeResults)) + + // Fetch the current ModSeq again to verify it changed + secondFetch, err := client.Fetch(seqSet, &imap.FetchOptions{ + ModSeq: true, + }).Collect() + if err != nil { + t.Fatalf("Second Fetch() = %v", err) + } + newModSeq := secondFetch[0].ModSeq + t.Logf("New ModSeq after flag change: %d", newModSeq) + + // Now fetch again with the old modseq - should return the message + messages, err = client.Fetch(seqSet, fetchOptions).Collect() + if err != nil { + t.Fatalf("Fetch() with CHANGEDSINCE after change = %v", err) + } + t.Logf("Messages returned after change: %d", len(messages)) + if len(messages) != 1 { + t.Errorf("Fetch() with CHANGEDSINCE after change returned %d messages, want 1", len(messages)) + } +} + +func TestStore_UnchangedSince(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateSelected) + defer client.Close() + defer server.Close() + + // First, get current ModSeq + seqSet := imap.SeqSetNum(1) + firstFetch, err := client.Fetch(seqSet, &imap.FetchOptions{ + ModSeq: true, + }).Collect() + if err != nil { + t.Fatalf("Initial Fetch() = %v", err) + } + currentModSeq := firstFetch[0].ModSeq + + // Now modify the message using UNCHANGEDSINCE with the current ModSeq + // This should succeed because the message hasn't been modified + storeFlags := imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Flags: []imap.Flag{imap.FlagSeen}, + } + storeOptions := &imap.StoreOptions{ + UnchangedSince: currentModSeq, + } + messages, err := client.Store(seqSet, &storeFlags, storeOptions).Collect() + if err != nil { + t.Fatalf("Store() with UNCHANGEDSINCE = %v", err) + } + if len(messages) != 1 { + t.Errorf("Store() with UNCHANGEDSINCE returned %d messages, want 1", len(messages)) + } + + // Get the new ModSeq + secondFetch, err := client.Fetch(seqSet, &imap.FetchOptions{ + ModSeq: true, + }).Collect() + if err != nil { + t.Fatalf("Second Fetch() = %v", err) + } + newModSeq := secondFetch[0].ModSeq + + // The ModSeq should have increased + if newModSeq <= currentModSeq { + t.Errorf("ModSeq after update = %d, want > %d", newModSeq, currentModSeq) + } + + // Try to modify again with the old ModSeq + // This should not modify the message because it has changed since + storeFlags = imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Flags: []imap.Flag{imap.FlagDeleted}, + } + storeOptions = &imap.StoreOptions{ + UnchangedSince: currentModSeq, // Use the old ModSeq + } + messages, err = client.Store(seqSet, &storeFlags, storeOptions).Collect() + if err != nil { + t.Fatalf("Second Store() with UNCHANGEDSINCE = %v", err) + } + + // The operation should not have modified any messages + if len(messages) != 0 { + t.Errorf("Second Store() with UNCHANGEDSINCE returned %d messages, should be 0", len(messages)) + } +} +func TestStatus_HighestModSeq(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + // Test STATUS with HIGHESTMODSEQ parameter + options := &imap.StatusOptions{ + HighestModSeq: true, + } + data, err := client.Status("INBOX", options).Wait() + if err != nil { + t.Fatalf("Status() with HIGHESTMODSEQ = %v", err) + } + + // Verify that HighestModSeq is returned + if data.HighestModSeq == 0 { + t.Errorf("StatusData.HighestModSeq is 0, expected non-zero value") + } + t.Logf("Mailbox HIGHESTMODSEQ from STATUS: %d", data.HighestModSeq) +} + +func TestSearch_ModSeq(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateSelected) + defer client.Close() + defer server.Close() + + // First, get current ModSeq for our message + seqSet := imap.SeqSetNum(1) + firstFetch, err := client.Fetch(seqSet, &imap.FetchOptions{ + ModSeq: true, + }).Collect() + if err != nil { + t.Fatalf("Initial Fetch() = %v", err) + } + currentModSeq := firstFetch[0].ModSeq + t.Logf("Initial ModSeq: %d", currentModSeq) + + // Now search with MODSEQ criterion using a value lower than current + // This should find the message + searchCriteria := &imap.SearchCriteria{ + ModSeq: &imap.SearchCriteriaModSeq{ + ModSeq: currentModSeq - 1, + }, + } + searchOptions := &imap.SearchOptions{ + ReturnCount: true, + } + results, err := client.Search(searchCriteria, searchOptions).Wait() + if err != nil { + t.Fatalf("Search with MODSEQ = %v", err) + } + + // There should be one message that matches + if results.Count != 1 { + t.Errorf("Search with MODSEQ < current returned %d messages, want 1", results.Count) + } + + // Now search with MODSEQ criterion using current value + // This should find the message (since MODSEQ criterion is >= not >) + searchCriteria = &imap.SearchCriteria{ + ModSeq: &imap.SearchCriteriaModSeq{ + ModSeq: currentModSeq, + }, + } + results, err = client.Search(searchCriteria, searchOptions).Wait() + if err != nil { + t.Fatalf("Search with MODSEQ = %v", err) + } + + // There should be one message that matches + if results.Count != 1 { + t.Errorf("Search with MODSEQ = current returned %d messages, want 1", results.Count) + } + + // Now search with MODSEQ criterion using a higher value + // This should NOT find the message + searchCriteria = &imap.SearchCriteria{ + ModSeq: &imap.SearchCriteriaModSeq{ + ModSeq: currentModSeq + 1, + }, + } + results, err = client.Search(searchCriteria, searchOptions).Wait() + if err != nil { + t.Fatalf("Search with MODSEQ = %v", err) + } + + // There should be no messages that match + if results.Count != 0 { + t.Errorf("Search with MODSEQ > current returned %d messages, want 0", results.Count) + } +} + +func TestCapability_CondStore(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateNotAuthenticated) + defer client.Close() + defer server.Close() + + // Check capabilities after connecting + capCmd := client.Capability() + caps, err := capCmd.Wait() + if err != nil { + t.Fatalf("Capability() = %v", err) + } + + _, hasCondStore := caps[imap.CapCondStore] + if hasCondStore { + t.Errorf("CapCondStore should not be available before authentication") + } + + // Login + if err := client.Login(testUsername, testPassword).Wait(); err != nil { + t.Fatalf("Login() = %v", err) + } + + // Check capabilities after login + capCmd = client.Capability() + caps, err = capCmd.Wait() + if err != nil { + t.Fatalf("Capability() after login = %v", err) + } + + _, hasCondStore = caps[imap.CapCondStore] + if !hasCondStore { + t.Errorf("CapCondStore should be available after authentication") + } else { + t.Logf("CONDSTORE capability correctly announced after authentication") + } +} diff --git a/imapserver/capability.go b/imapserver/capability.go index 203140a7..a07d0f8e 100644 --- a/imapserver/capability.go +++ b/imapserver/capability.go @@ -91,6 +91,7 @@ func (c *Conn) availableCaps() []imap.Cap { imap.CapCreateSpecialUse, imap.CapLiteralPlus, imap.CapUnauthenticate, + imap.CapCondStore, }) } return caps diff --git a/imapserver/fetch.go b/imapserver/fetch.go index 6dcdcbdb..fbd0b12d 100644 --- a/imapserver/fetch.go +++ b/imapserver/fetch.go @@ -75,6 +75,26 @@ func (c *Conn) handleFetch(dec *imapwire.Decoder, numKind NumKind) error { } } + if dec.SP() && dec.Special('(') { + var param string + if !dec.ExpectAtom(¶m) { + return dec.Err() + } + + if strings.ToUpper(param) == "CHANGEDSINCE" { + if !dec.ExpectSP() || !dec.ExpectModSeq(&options.ChangedSince) { + return dec.Err() + } + options.ModSeq = true + } else { + return fmt.Errorf("unknown FETCH modifier: %v", param) + } + + if !dec.ExpectSpecial(')') { + return dec.Err() + } + } + if !dec.ExpectCRLF() { return dec.Err() } @@ -108,6 +128,8 @@ func handleFetchAtt(dec *imapwire.Decoder, attName string, options *imap.FetchOp options.RFC822Size = true case "UID": options.UID = true + case "MODSEQ": + options.ModSeq = true case "RFC822": // equivalent to BODY[] bs := &imap.FetchItemBodySection{} writerOptions.obsolete[bs] = attName @@ -456,6 +478,12 @@ func (w *FetchResponseWriter) WriteEnvelope(envelope *imap.Envelope) { writeEnvelope(enc, envelope) } +// WriteModSeq writes the message's MODSEQ. +func (w *FetchResponseWriter) WriteModSeq(modSeq uint64) { + w.writeItemSep() + w.enc.Atom("MODSEQ").SP().Special('(').ModSeq(modSeq).Special(')') +} + // WriteBodyStructure writes the message's body structure (either BODYSTRUCTURE // or BODY). func (w *FetchResponseWriter) WriteBodyStructure(bs imap.BodyStructure) { diff --git a/imapserver/imapmemserver/mailbox.go b/imapserver/imapmemserver/mailbox.go index dee9a67b..e570cd08 100644 --- a/imapserver/imapmemserver/mailbox.go +++ b/imapserver/imapmemserver/mailbox.go @@ -18,21 +18,23 @@ type Mailbox struct { tracker *imapserver.MailboxTracker uidValidity uint32 - mutex sync.Mutex - name string - subscribed bool - specialUse []imap.MailboxAttr - l []*message - uidNext imap.UID + mutex sync.Mutex + name string + subscribed bool + specialUse []imap.MailboxAttr + l []*message + uidNext imap.UID + highestModSeq uint64 } // NewMailbox creates a new mailbox. func NewMailbox(name string, uidValidity uint32) *Mailbox { return &Mailbox{ - tracker: imapserver.NewMailboxTracker(0), - uidValidity: uidValidity, - name: name, - uidNext: 1, + tracker: imapserver.NewMailboxTracker(0), + uidValidity: uidValidity, + name: name, + uidNext: 1, + highestModSeq: 1, } } @@ -94,9 +96,8 @@ func (mbox *Mailbox) statusDataLocked(options *imap.StatusOptions) *imap.StatusD size := mbox.sizeLocked() data.Size = &size } - if options.NumRecent { - num := uint32(0) - data.NumRecent = &num + if options.HighestModSeq { + data.HighestModSeq = mbox.highestModSeq } return &data } @@ -156,6 +157,9 @@ func (mbox *Mailbox) appendBytes(buf []byte, options *imap.AppendOptions) *imap. msg.uid = mbox.uidNext mbox.uidNext++ + msg.modSeq = mbox.highestModSeq + mbox.highestModSeq++ + mbox.l = append(mbox.l, msg) mbox.tracker.QueueNumMessages(uint32(len(mbox.l))) @@ -196,7 +200,18 @@ func (mbox *Mailbox) selectDataLocked() *imap.SelectData { FirstUnseenSeqNum: firstUnseenSeqNum, UIDNext: mbox.uidNext, UIDValidity: mbox.uidValidity, + HighestModSeq: mbox.highestModSeq, + } +} + +func (mbox *Mailbox) firstUnseenSeqNumLocked() uint32 { + for i, msg := range mbox.l { + seqNum := uint32(i) + 1 + if _, ok := msg.flags[canonicalFlag(imap.FlagSeen)]; !ok { + return seqNum + } } + return 0 } func (mbox *Mailbox) firstUnseenSeqNumLocked() uint32 { @@ -324,6 +339,10 @@ func (mbox *MailboxView) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, op return } + if options.ChangedSince > 0 && msg.modSeq <= options.ChangedSince { + return + } + if markSeen { msg.flags[canonicalFlag(imap.FlagSeen)] = struct{}{} mbox.Mailbox.tracker.QueueMessageFlags(seqNum, msg.uid, msg.flagList(), nil) @@ -418,13 +437,40 @@ func (mbox *MailboxView) staticSearchCriteria(criteria *imap.SearchCriteria) { } func (mbox *MailboxView) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error { - mbox.forEach(numSet, func(seqNum uint32, msg *message) { - msg.store(flags) - mbox.Mailbox.tracker.QueueMessageFlags(seqNum, msg.uid, msg.flagList(), mbox.tracker) + var modifiedSeqNums []uint32 + var modifiedMsgs []*message + + mbox.mutex.Lock() + mbox.forEachLocked(numSet, func(seqNum uint32, msg *message) { + if options != nil && options.UnchangedSince > 0 && msg.modSeq > options.UnchangedSince { + return + } + + if changed := msg.store(flags); changed { + mbox.highestModSeq++ + msg.modSeq = mbox.highestModSeq + + modifiedSeqNums = append(modifiedSeqNums, seqNum) + modifiedMsgs = append(modifiedMsgs, msg) + } }) - if !flags.Silent { - return mbox.Fetch(w, numSet, &imap.FetchOptions{Flags: true}) + mbox.mutex.Unlock() + + for i, seqNum := range modifiedSeqNums { + msg := modifiedMsgs[i] + mbox.Mailbox.tracker.QueueMessageFlags(seqNum, msg.uid, msg.flagList(), mbox.tracker) } + + if !flags.Silent && len(modifiedMsgs) > 0 { + for i, seqNum := range modifiedSeqNums { + msg := modifiedMsgs[i] + respWriter := w.CreateMessage(mbox.tracker.EncodeSeqNum(seqNum)) + if err := msg.fetch(respWriter, &imap.FetchOptions{Flags: true, ModSeq: true}); err != nil { + return err + } + } + } + return nil } diff --git a/imapserver/imapmemserver/message.go b/imapserver/imapmemserver/message.go index d5580459..6b882036 100644 --- a/imapserver/imapmemserver/message.go +++ b/imapserver/imapmemserver/message.go @@ -22,7 +22,8 @@ type message struct { t time.Time // mutable, protected by Mailbox.mutex - flags map[imap.Flag]struct{} + flags map[imap.Flag]struct{} + modSeq uint64 } func (msg *message) fetch(w *imapserver.FetchResponseWriter, options *imap.FetchOptions) error { @@ -43,6 +44,9 @@ func (msg *message) fetch(w *imapserver.FetchResponseWriter, options *imap.Fetch if options.BodyStructure != nil { w.WriteBodyStructure(imapserver.ExtractBodyStructure(bytes.NewReader(msg.buf))) } + if options.ModSeq { + w.WriteModSeq(msg.modSeq) + } for _, bs := range options.BodySection { buf := imapserver.ExtractBodySection(bytes.NewReader(msg.buf), bs) @@ -95,7 +99,12 @@ func (msg *message) flagList() []imap.Flag { return flags } -func (msg *message) store(store *imap.StoreFlags) { +func (msg *message) store(store *imap.StoreFlags) bool { + oldFlags := make(map[imap.Flag]struct{}, len(msg.flags)) + for k, v := range msg.flags { + oldFlags[k] = v + } + switch store.Op { case imap.StoreFlagsSet: msg.flags = make(map[imap.Flag]struct{}) @@ -111,6 +120,21 @@ func (msg *message) store(store *imap.StoreFlags) { default: panic(fmt.Errorf("unknown STORE flag operation: %v", store.Op)) } + + if len(oldFlags) != len(msg.flags) { + return true + } + for k := range oldFlags { + if _, ok := msg.flags[k]; !ok { + return true + } + } + for k := range msg.flags { + if _, ok := oldFlags[k]; !ok { + return true + } + } + return false } func (msg *message) reader() *gomessage.Entity { @@ -135,7 +159,9 @@ func (msg *message) search(seqNum uint32, criteria *imap.SearchCriteria) bool { if !matchDate(msg.t, criteria.Since, criteria.Before) { return false } - + if criteria.ModSeq != nil && msg.modSeq < criteria.ModSeq.ModSeq { + return false + } for _, flag := range criteria.Flag { if _, ok := msg.flags[canonicalFlag(flag)]; !ok { return false diff --git a/imapserver/search.go b/imapserver/search.go index 91466818..7a600be1 100644 --- a/imapserver/search.go +++ b/imapserver/search.go @@ -328,6 +328,33 @@ func readSearchKeyWithAtom(criteria *imap.SearchCriteria, dec *imapwire.Decoder, criteria.Or = append(criteria.Or, or) case "$": criteria.UID = append(criteria.UID, imap.SearchRes()) + case "MODSEQ": + if !dec.ExpectSP() { + return dec.Err() + } + var name string + var metadataType imap.SearchCriteriaMetadataType + if dec.Quoted(&name) { + if !dec.ExpectSP() { + return dec.Err() + } + var typeName string + if !dec.ExpectAtom(&typeName) || !dec.ExpectSP() { + return dec.Err() + } + metadataType = imap.SearchCriteriaMetadataType(strings.ToLower(typeName)) + } + + var modSeq uint64 + if !dec.ExpectModSeq(&modSeq) { + return dec.Err() + } + + criteria.ModSeq = &imap.SearchCriteriaModSeq{ + ModSeq: modSeq, + MetadataName: name, + MetadataType: metadataType, + } default: seqSet, err := imapwire.ParseSeqSet(key) if err != nil { diff --git a/imapserver/select.go b/imapserver/select.go index 3535fe73..daa0c179 100644 --- a/imapserver/select.go +++ b/imapserver/select.go @@ -3,13 +3,41 @@ package imapserver import ( "fmt" + "strings" + "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/internal/imapwire" ) func (c *Conn) handleSelect(tag string, dec *imapwire.Decoder, readOnly bool) error { var mailbox string - if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || !dec.ExpectCRLF() { + if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) { + return dec.Err() + } + + options := imap.SelectOptions{ReadOnly: readOnly} + + if dec.SP() { + err := dec.ExpectList(func() error { + var param string + if !dec.ExpectAtom(¶m) { + return dec.Err() + } + + switch strings.ToUpper(param) { + case "CONDSTORE": + options.CondStore = true + default: + return newClientBugError(fmt.Sprintf("unknown SELECT parameter: %v", param)) + } + return nil + }) + if err != nil { + return err + } + } + + if !dec.ExpectCRLF() { return dec.Err() } @@ -32,7 +60,6 @@ func (c *Conn) handleSelect(tag string, dec *imapwire.Decoder, readOnly bool) er } } - options := imap.SelectOptions{ReadOnly: readOnly} data, err := c.session.Select(mailbox, &options) if err != nil { return err @@ -69,6 +96,19 @@ func (c *Conn) handleSelect(tag string, dec *imapwire.Decoder, readOnly bool) er } } + shouldSendModSeqStatus := c.enabled.Has(imap.CapIMAP4rev2) || c.server.options.caps().Has(imap.CapCondStore) + if shouldSendModSeqStatus { + if data.HighestModSeq > 0 { + if err := c.writeHighestModSeq(data.HighestModSeq); err != nil { + return err + } + } else { + if err := c.writeNoModSeq(); err != nil { + return err + } + } + } + c.state = imap.ConnStateSelected // TODO: forbid write commands in read-only mode @@ -172,3 +212,21 @@ func (c *Conn) writePermanentFlags(flags []imap.Flag) error { enc.SP().Text("Permanent flags") return enc.CRLF() } + +func (c *Conn) writeHighestModSeq(highestModSeq uint64) error { + enc := newResponseEncoder(c) + defer enc.end() + enc.Atom("*").SP().Atom("OK").SP() + enc.Special('[').Atom("HIGHESTMODSEQ").SP().ModSeq(highestModSeq).Special(']') + enc.SP().Text("Highest modification sequence") + return enc.CRLF() +} + +func (c *Conn) writeNoModSeq() error { + enc := newResponseEncoder(c) + defer enc.end() + enc.Atom("*").SP().Atom("OK").SP() + enc.Special('[').Atom("NOMODSEQ").Special(']') + enc.SP().Text("Mailbox does not support modification sequences") + return enc.CRLF() +} diff --git a/imapserver/status.go b/imapserver/status.go index b2b5feb6..e7a128ce 100644 --- a/imapserver/status.go +++ b/imapserver/status.go @@ -86,6 +86,9 @@ func (c *Conn) writeStatus(data *imap.StatusData, options *imap.StatusOptions) e if options.NumRecent { listEnc.Item().Atom("RECENT").SP().Number(*data.NumRecent) } + if options.HighestModSeq { + listEnc.Item().Atom("HIGHESTMODSEQ").SP().ModSeq(data.HighestModSeq) + } listEnc.End() return enc.CRLF() @@ -115,6 +118,8 @@ func readStatusItem(dec *imapwire.Decoder, options *imap.StatusOptions) error { options.DeletedStorage = true case "RECENT": options.NumRecent = true + case "HIGHESTMODSEQ": + options.HighestModSeq = true default: return &imap.Error{ Type: imap.StatusResponseTypeBad, diff --git a/imapserver/store.go b/imapserver/store.go index 848fac55..50e4dd29 100644 --- a/imapserver/store.go +++ b/imapserver/store.go @@ -1,6 +1,7 @@ package imapserver import ( + "fmt" "strings" "github.com/emersion/go-imap/v2" @@ -13,7 +14,30 @@ func (c *Conn) handleStore(dec *imapwire.Decoder, numKind NumKind) error { numSet imap.NumSet item string ) - if !dec.ExpectSP() || !dec.ExpectNumSet(numKind.wire(), &numSet) || !dec.ExpectSP() || !dec.ExpectAtom(&item) || !dec.ExpectSP() { + if !dec.ExpectSP() || !dec.ExpectNumSet(numKind.wire(), &numSet) || !dec.ExpectSP() { + return dec.Err() + } + + options := imap.StoreOptions{} + if dec.Special('(') { + var param string + if !dec.ExpectAtom(¶m) { + return dec.Err() + } + + if strings.ToUpper(param) == "UNCHANGEDSINCE" { + if !dec.ExpectSP() || !dec.ExpectModSeq(&options.UnchangedSince) { + return dec.Err() + } + } else { + return newClientBugError(fmt.Sprintf("unknown STORE modifier: %v", param)) + } + if !dec.ExpectSpecial(')') || !dec.ExpectSP() { + return dec.Err() + } + } + + if !dec.ExpectAtom(&item) || !dec.ExpectSP() { return dec.Err() } var flags []imap.Flag @@ -69,7 +93,6 @@ func (c *Conn) handleStore(dec *imapwire.Decoder, numKind NumKind) error { } w := &FetchWriter{conn: c} - options := imap.StoreOptions{} return c.session.Store(w, numSet, &imap.StoreFlags{ Op: op, Silent: silent, From 8a698689ed63d0d10e5e4d89d4c046cd90a345e4 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Fri, 6 Jun 2025 12:38:07 +0200 Subject: [PATCH 11/29] Add CONDSTORE extension support --- imapserver/imapmemserver/mailbox.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/imapserver/imapmemserver/mailbox.go b/imapserver/imapmemserver/mailbox.go index e570cd08..b4df71d4 100644 --- a/imapserver/imapmemserver/mailbox.go +++ b/imapserver/imapmemserver/mailbox.go @@ -99,6 +99,9 @@ func (mbox *Mailbox) statusDataLocked(options *imap.StatusOptions) *imap.StatusD if options.HighestModSeq { data.HighestModSeq = mbox.highestModSeq } + if options.HighestModSeq { + data.HighestModSeq = mbox.highestModSeq + } return &data } From aa89543215b7c34e47f24add22a9ae9ab383a25e Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Wed, 11 Jun 2025 22:07:06 +0200 Subject: [PATCH 12/29] resolve merge error and fmt --- imapserver/imapmemserver/mailbox.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/imapserver/imapmemserver/mailbox.go b/imapserver/imapmemserver/mailbox.go index b4df71d4..bbe60cb4 100644 --- a/imapserver/imapmemserver/mailbox.go +++ b/imapserver/imapmemserver/mailbox.go @@ -99,8 +99,9 @@ func (mbox *Mailbox) statusDataLocked(options *imap.StatusOptions) *imap.StatusD if options.HighestModSeq { data.HighestModSeq = mbox.highestModSeq } - if options.HighestModSeq { - data.HighestModSeq = mbox.highestModSeq + if options.NumRecent { + num := uint32(0) + data.NumRecent = &num } return &data } From 43bd92a84a35227920b2d9ab643bdc15a948fd37 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Wed, 11 Jun 2025 22:19:14 +0200 Subject: [PATCH 13/29] removing merge conflicts leftovers --- imapserver/imapmemserver/mailbox.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/imapserver/imapmemserver/mailbox.go b/imapserver/imapmemserver/mailbox.go index bbe60cb4..42077a29 100644 --- a/imapserver/imapmemserver/mailbox.go +++ b/imapserver/imapmemserver/mailbox.go @@ -218,16 +218,6 @@ func (mbox *Mailbox) firstUnseenSeqNumLocked() uint32 { return 0 } -func (mbox *Mailbox) firstUnseenSeqNumLocked() uint32 { - for i, msg := range mbox.l { - seqNum := uint32(i) + 1 - if _, ok := msg.flags[canonicalFlag(imap.FlagSeen)]; !ok { - return seqNum - } - } - return 0 -} - func (mbox *Mailbox) flagsLocked() []imap.Flag { m := make(map[imap.Flag]struct{}) for _, msg := range mbox.l { From 0e418b8fbdb4f590a3749d667fd943988e39bf79 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 21 Sep 2025 11:05:40 +0200 Subject: [PATCH 14/29] Extend ID command on both client and server for ID forwarding in Dovecot --- id.go | 4 ++++ imapclient/id.go | 23 +++++++++++++++++++++-- imapserver/id.go | 24 +++++++++++++++++++++--- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/id.go b/id.go index de7ca0e1..eb53e0f5 100644 --- a/id.go +++ b/id.go @@ -12,4 +12,8 @@ type IDData struct { Command string Arguments string Environment string + + // Raw contains all raw key-value pairs. Standard keys are also present + // in this map. Keys are case-insensitive and are normalized to lowercase. + Raw map[string]string } diff --git a/imapclient/id.go b/imapclient/id.go index 0c10d605..66f75367 100644 --- a/imapclient/id.go +++ b/imapclient/id.go @@ -2,6 +2,7 @@ package imapclient import ( "fmt" + "strings" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/internal/imapwire" @@ -60,6 +61,18 @@ func (c *Client) ID(idData *imap.IDData) *IDCommand { if idData.Environment != "" { addIDKeyValue(enc, &isFirstKey, "environment", idData.Environment) } + if idData.Raw != nil { + stdKeys := map[string]struct{}{ + "name": {}, "version": {}, "os": {}, "os-version": {}, "vendor": {}, + "support-url": {}, "address": {}, "date": {}, "command": {}, + "arguments": {}, "environment": {}, + } + for k, v := range idData.Raw { + if _, ok := stdKeys[strings.ToLower(k)]; !ok { + addIDKeyValue(enc, &isFirstKey, k, v) + } + } + } enc.Special(')') enc.end() @@ -91,7 +104,9 @@ func (c *Client) handleID() error { } func (c *Client) readID(dec *imapwire.Decoder) (*imap.IDData, error) { - var data = imap.IDData{} + var data = imap.IDData{ + Raw: make(map[string]string), + } if !dec.ExpectSP() { return nil, dec.Err() @@ -113,7 +128,10 @@ func (c *Client) readID(dec *imapwire.Decoder) (*imap.IDData, error) { return nil } - switch currKey { + lowerKey := strings.ToLower(currKey) + data.Raw[lowerKey] = keyOrValue + + switch lowerKey { case "name": data.Name = keyOrValue case "version": @@ -138,6 +156,7 @@ func (c *Client) readID(dec *imapwire.Decoder) (*imap.IDData, error) { data.Environment = keyOrValue default: // Ignore unknown key + // Unknown key is already stored in Raw // Yahoo server sends "host" and "remote-host" keys // which are not defined in RFC 2971 } diff --git a/imapserver/id.go b/imapserver/id.go index c130209a..5997b774 100644 --- a/imapserver/id.go +++ b/imapserver/id.go @@ -2,6 +2,7 @@ package imapserver import ( "fmt" + "strings" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/internal/imapwire" @@ -63,6 +64,18 @@ func (c *Conn) handleID(tag string, dec *imapwire.Decoder) error { if serverIDData.Environment != "" { addIDKeyValue(enc.Encoder, &isFirstKey, "environment", serverIDData.Environment) } + if serverIDData.Raw != nil { + stdKeys := map[string]struct{}{ + "name": {}, "version": {}, "os": {}, "os-version": {}, "vendor": {}, + "support-url": {}, "address": {}, "date": {}, "command": {}, + "arguments": {}, "environment": {}, + } + for k, v := range serverIDData.Raw { + if _, ok := stdKeys[strings.ToLower(k)]; !ok { + addIDKeyValue(enc.Encoder, &isFirstKey, k, v) + } + } + } enc.Special(')') } @@ -87,7 +100,9 @@ func readID(dec *imapwire.Decoder) (*imap.IDData, error) { return nil, nil } - data := &imap.IDData{} + data := &imap.IDData{ + Raw: make(map[string]string), + } currKey := "" err := dec.ExpectList(func() error { var keyOrValue string @@ -100,7 +115,10 @@ func readID(dec *imapwire.Decoder) (*imap.IDData, error) { return nil } - switch currKey { + lowerKey := strings.ToLower(currKey) + data.Raw[lowerKey] = keyOrValue + + switch lowerKey { case "name": data.Name = keyOrValue case "version": @@ -124,7 +142,7 @@ func readID(dec *imapwire.Decoder) (*imap.IDData, error) { case "environment": data.Environment = keyOrValue default: - // Ignore unknown key + // Unknown key, already stored in Raw } currKey = "" From f2fdf37604af6ab4f805f7a4a22ca85de0991632 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 21 Sep 2025 12:37:57 +0200 Subject: [PATCH 15/29] corrected ESORT handling as per RFC5267 --- imapserver/imapmemserver/sort.go | 80 ++++++++++++++++++++++++++------ imapserver/sort.go | 41 ++++++++++------ 2 files changed, 93 insertions(+), 28 deletions(-) diff --git a/imapserver/imapmemserver/sort.go b/imapserver/imapmemserver/sort.go index 0c9043e2..ab85af67 100644 --- a/imapserver/imapmemserver/sort.go +++ b/imapserver/imapmemserver/sort.go @@ -1,7 +1,12 @@ package imapmemserver import ( + "bufio" + "bytes" + "net/mail" + "net/textproto" "sort" + "strings" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapserver" @@ -142,31 +147,78 @@ func compareByCriterion(a, b *message, key imap.SortKey) int { return 0 case imap.SortKeyFrom: - // TODO: For a real implementation, extract the From header and compare - return 0 + // 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: - // TODO: For a real implementation, you would extract the To header and compare - return 0 + // 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: - // TODO: For a real implementation, you would extract the Cc header and compare - return 0 + // 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: - // TODO: For a real implementation, you would extract the Subject header and compare - return 0 + // 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: - // SORT=DISPLAY (RFC 5957) - Use a locale-sensitive version of the string - // For now, treat it the same as the subject sorting for this implementation - // TODO: For a real implementation, use proper locale-aware sorting of display names - // A full implementation would handle internationalized text according to - // the user's locale settings and apply proper collation rules - return 0 + // 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 index bb0766d1..7fd48e01 100644 --- a/imapserver/sort.go +++ b/imapserver/sort.go @@ -174,27 +174,34 @@ func (c *Conn) handleSort(tag string, dec *imapwire.Decoder, numKind NumKind) er data := &SortData{Nums: sortedNums} if len(sortedNums) > 0 { data.Count = uint32(len(sortedNums)) - data.Min = sortedNums[0] - data.Max = sortedNums[len(sortedNums)-1] + 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 { - enc := newResponseEncoder(c) - defer enc.end() - - // For ESORT, use ESEARCH response format if not RETURN ALL + // 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().Atom(tag).Special(')') + 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) @@ -208,14 +215,20 @@ func (c *Conn) writeSortResponse(tag string, numKind NumKind, data *SortData, re if returnOpts.Count { enc.SP().Atom("COUNT").SP().Number(data.Count) } - // Note: No "ALL " here for ESORT as per RFC 5267 - } else { - // Use regular SORT response for non-ESORT clients or when RETURN (ALL) or no RETURN option is specified - enc.Atom("*").SP().Atom("SORT") - for _, num := range data.Nums { - enc.SP().Number(num) + 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() } From 6793216391d19f485183c83e1ac2fcf31464b0a1 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 21 Sep 2025 14:03:02 +0200 Subject: [PATCH 16/29] According to RFC 4731, a server must ignore any unrecognized RETURN options; readSearchKeyWithAtom should return errors instead of swallowing them; tag in search response should be quoted; replaced deprecated strings.Title --- go.mod | 1 + go.sum | 1 + imapserver/search.go | 70 +++++++++++++++++++++++++++----------------- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 4700a303..49935675 100644 --- a/go.mod +++ b/go.mod @@ -5,4 +5,5 @@ go 1.18 require ( github.com/emersion/go-message v0.18.1 github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 + golang.org/x/text v0.14.0 ) diff --git a/go.sum b/go.sum index 1a059484..ce241ed1 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,7 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/imapserver/search.go b/imapserver/search.go index 91466818..bf06784a 100644 --- a/imapserver/search.go +++ b/imapserver/search.go @@ -8,6 +8,8 @@ import ( "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/internal" "github.com/emersion/go-imap/v2/internal/imapwire" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) func (c *Conn) handleSearch(tag string, dec *imapwire.Decoder, numKind NumKind) error { @@ -98,15 +100,17 @@ func (c *Conn) writeESearch(tag string, data *imap.SearchData, options *imap.Sea enc.Atom("*").SP().Atom("ESEARCH") if tag != "" { - enc.SP().Special('(').Atom("TAG").SP().Atom(tag).Special(')') + enc.SP().Special('(').Atom("TAG").SP().String(tag).Special(')') } if numKind == NumKindUID { enc.SP().Atom("UID") } - // When there is no result, we need to send an ESEARCH response with no ALL - // keyword - if options.ReturnAll && !isNumSetEmpty(data.All) { - enc.SP().Atom("ALL").SP().NumSet(data.All) + // When there is no result, we need to send an ESEARCH response with no sequence set after ALL. + if options.ReturnAll { + enc.SP().Atom("ALL") + if data.All != nil && !isNumSetEmpty(data.All) { + enc.SP().NumSet(data.All) + } } if options.ReturnMin && data.Min > 0 { enc.SP().Atom("MIN").SP().Number(data.Min) @@ -136,24 +140,28 @@ func (c *Conn) writeSearch(numSet imap.NumSet) error { defer enc.end() enc.Atom("*").SP().Atom("SEARCH") - var ok bool - switch numSet := numSet.(type) { - case imap.SeqSet: - var nums []uint32 - nums, ok = numSet.Nums() - for _, num := range nums { - enc.SP().Number(num) + + if numSet != nil { + var ok bool + switch numSet := numSet.(type) { + case imap.SeqSet: + var nums []uint32 + nums, ok = numSet.Nums() + for _, num := range nums { + enc.SP().Number(num) + } + case imap.UIDSet: + var uids []imap.UID + uids, ok = numSet.Nums() + for _, uid := range uids { + enc.SP().UID(uid) + } } - case imap.UIDSet: - var uids []imap.UID - uids, ok = numSet.Nums() - for _, uid := range uids { - enc.SP().UID(uid) + if !ok { + return fmt.Errorf("imapserver: failed to enumerate message numbers in SEARCH response (dynamic set?)") } } - if !ok { - return fmt.Errorf("imapserver: failed to enumerate message numbers in SEARCH response") - } + return enc.CRLF() } @@ -178,7 +186,7 @@ func readSearchReturnOpts(dec *imapwire.Decoder, options *imap.SearchOptions) er case "SAVE": options.ReturnSave = true default: - return newClientBugError("unknown SEARCH RETURN option") + // RFC 4731: A server MUST ignore any unrecognized return options. } return nil }) @@ -196,7 +204,15 @@ func readSearchKey(criteria *imap.SearchCriteria, dec *imapwire.Decoder) error { return readSearchKeyWithAtom(criteria, dec, key) } return dec.ExpectList(func() error { - return readSearchKey(criteria, dec) + for { + if err := readSearchKey(criteria, dec); err != nil { + return err + } + if !dec.SP() { + break + } + } + return nil }) } @@ -241,7 +257,7 @@ func readSearchKeyWithAtom(criteria *imap.SearchCriteria, dec *imapwire.Decoder, return dec.Err() } criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{ - Key: strings.Title(strings.ToLower(key)), + Key: cases.Title(language.English).String(strings.ToLower(key)), Value: value, }) case "HEADER": @@ -308,7 +324,7 @@ func readSearchKeyWithAtom(criteria *imap.SearchCriteria, dec *imapwire.Decoder, } var not imap.SearchCriteria if err := readSearchKey(¬, dec); err != nil { - return nil + return err } criteria.Not = append(criteria.Not, not) case "OR": @@ -317,13 +333,13 @@ func readSearchKeyWithAtom(criteria *imap.SearchCriteria, dec *imapwire.Decoder, } var or [2]imap.SearchCriteria if err := readSearchKey(&or[0], dec); err != nil { - return nil + return err } if !dec.ExpectSP() { return dec.Err() } if err := readSearchKey(&or[1], dec); err != nil { - return nil + return err } criteria.Or = append(criteria.Or, or) case "$": @@ -339,5 +355,5 @@ func readSearchKeyWithAtom(criteria *imap.SearchCriteria, dec *imapwire.Decoder, } func searchKeyFlag(key string) imap.Flag { - return imap.Flag("\\" + strings.Title(strings.ToLower(key))) + return imap.Flag("\\" + cases.Title(language.English).String(strings.ToLower(key))) } From 1f423a7e3604913bbe5547a1a98a2c336e2207b9 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 21 Sep 2025 15:02:33 +0200 Subject: [PATCH 17/29] updates of requested changes --- capability.go | 8 ++++- imapserver/imapmemserver/mailbox.go | 53 +++++++++++++++++------------ imapserver/imapmemserver/message.go | 33 ++++++++++++------ imapserver/select.go | 7 ++-- 4 files changed, 65 insertions(+), 36 deletions(-) diff --git a/capability.go b/capability.go index e8b656c9..50019c47 100644 --- a/capability.go +++ b/capability.go @@ -130,7 +130,13 @@ func (set CapSet) Has(c Cap) bool { if c == CapLiteralMinus && set.has(CapLiteralPlus) { return true } - if c == CapCondStore && set.has(CapQResync) { + + // IMAP4rev2 implies QRESYNC, which in turn implies CONDSTORE. + isQResync := set.has(CapQResync) || set.has(CapIMAP4rev2) + if c == CapQResync && isQResync { + return true + } + if c == CapCondStore && isQResync { return true } if c == CapUTF8Accept && set.has(CapUTF8Only) { diff --git a/imapserver/imapmemserver/mailbox.go b/imapserver/imapmemserver/mailbox.go index 42077a29..c3baab61 100644 --- a/imapserver/imapmemserver/mailbox.go +++ b/imapserver/imapmemserver/mailbox.go @@ -161,8 +161,8 @@ func (mbox *Mailbox) appendBytes(buf []byte, options *imap.AppendOptions) *imap. msg.uid = mbox.uidNext mbox.uidNext++ - msg.modSeq = mbox.highestModSeq mbox.highestModSeq++ + msg.modSeq = mbox.highestModSeq mbox.l = append(mbox.l, msg) mbox.tracker.QueueNumMessages(uint32(len(mbox.l))) @@ -430,36 +430,45 @@ func (mbox *MailboxView) staticSearchCriteria(criteria *imap.SearchCriteria) { } } -func (mbox *MailboxView) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error { - var modifiedSeqNums []uint32 - var modifiedMsgs []*message +type modifiedMessageData struct { + seqNum uint32 + uid imap.UID + flags []imap.Flag + modSeq uint64 +} - mbox.mutex.Lock() - mbox.forEachLocked(numSet, func(seqNum uint32, msg *message) { +func writeStoreFetchResponse(w *imapserver.FetchWriter, tracker *imapserver.SessionTracker, mod modifiedMessageData) error { + respWriter := w.CreateMessage(tracker.EncodeSeqNum(mod.seqNum)) + respWriter.WriteUID(mod.uid) + respWriter.WriteFlags(mod.flags) + respWriter.WriteModSeq(mod.modSeq) + return respWriter.Close() +} + +func (mbox *MailboxView) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error { + var modified []modifiedMessageData + mbox.forEach(numSet, func(seqNum uint32, msg *message) { if options != nil && options.UnchangedSince > 0 && msg.modSeq > options.UnchangedSince { return } - if changed := msg.store(flags); changed { - mbox.highestModSeq++ - msg.modSeq = mbox.highestModSeq + if changed := msg.store(mbox.Mailbox, flags); changed { + mbox.Mailbox.tracker.QueueMessageFlags(seqNum, msg.uid, msg.flagList(), mbox.tracker) - modifiedSeqNums = append(modifiedSeqNums, seqNum) - modifiedMsgs = append(modifiedMsgs, msg) + if !flags.Silent { + modified = append(modified, modifiedMessageData{ + seqNum: seqNum, + uid: msg.uid, + flags: msg.flagList(), + modSeq: msg.modSeq, + }) + } } }) - mbox.mutex.Unlock() - - for i, seqNum := range modifiedSeqNums { - msg := modifiedMsgs[i] - mbox.Mailbox.tracker.QueueMessageFlags(seqNum, msg.uid, msg.flagList(), mbox.tracker) - } - if !flags.Silent && len(modifiedMsgs) > 0 { - for i, seqNum := range modifiedSeqNums { - msg := modifiedMsgs[i] - respWriter := w.CreateMessage(mbox.tracker.EncodeSeqNum(seqNum)) - if err := msg.fetch(respWriter, &imap.FetchOptions{Flags: true, ModSeq: true}); err != nil { + if !flags.Silent { + for _, mod := range modified { + if err := writeStoreFetchResponse(w, mbox.tracker, mod); err != nil { return err } } diff --git a/imapserver/imapmemserver/message.go b/imapserver/imapmemserver/message.go index 6b882036..bbb26f68 100644 --- a/imapserver/imapmemserver/message.go +++ b/imapserver/imapmemserver/message.go @@ -99,7 +99,7 @@ func (msg *message) flagList() []imap.Flag { return flags } -func (msg *message) store(store *imap.StoreFlags) bool { +func (msg *message) store(mbox *Mailbox, store *imap.StoreFlags) bool { oldFlags := make(map[imap.Flag]struct{}, len(msg.flags)) for k, v := range msg.flags { oldFlags[k] = v @@ -121,20 +121,31 @@ func (msg *message) store(store *imap.StoreFlags) bool { panic(fmt.Errorf("unknown STORE flag operation: %v", store.Op)) } + changed := false if len(oldFlags) != len(msg.flags) { - return true - } - for k := range oldFlags { - if _, ok := msg.flags[k]; !ok { - return true + changed = true + } else { + for k := range oldFlags { + if _, ok := msg.flags[k]; !ok { + changed = true + break + } } - } - for k := range msg.flags { - if _, ok := oldFlags[k]; !ok { - return true + if !changed { + for k := range msg.flags { + if _, ok := oldFlags[k]; !ok { + changed = true + break + } + } } } - return false + + if changed { + mbox.highestModSeq++ + msg.modSeq = mbox.highestModSeq + } + return changed } func (msg *message) reader() *gomessage.Entity { diff --git a/imapserver/select.go b/imapserver/select.go index daa0c179..292ae5a4 100644 --- a/imapserver/select.go +++ b/imapserver/select.go @@ -26,7 +26,10 @@ func (c *Conn) handleSelect(tag string, dec *imapwire.Decoder, readOnly bool) er switch strings.ToUpper(param) { case "CONDSTORE": - options.CondStore = true + // Per RFC 7162, ignore the parameter if not supported. + if c.server.options.caps().Has(imap.CapCondStore) || c.enabled.Has(imap.CapIMAP4rev2) { + options.CondStore = true + } default: return newClientBugError(fmt.Sprintf("unknown SELECT parameter: %v", param)) } @@ -96,7 +99,7 @@ func (c *Conn) handleSelect(tag string, dec *imapwire.Decoder, readOnly bool) er } } - shouldSendModSeqStatus := c.enabled.Has(imap.CapIMAP4rev2) || c.server.options.caps().Has(imap.CapCondStore) + shouldSendModSeqStatus := c.server.options.caps().Has(imap.CapCondStore) if shouldSendModSeqStatus { if data.HighestModSeq > 0 { if err := c.writeHighestModSeq(data.HighestModSeq); err != nil { From a1954be11217e762258c515541eca053410c7b57 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 21 Sep 2025 19:15:34 +0200 Subject: [PATCH 18/29] Missing CONDSTORE, Search fixes and QRESYNC support --- fetch.go | 1 + imapclient/client.go | 30 ++++ imapclient/condstore_test.go | 129 +++++++++++++++++ imapclient/enable.go | 2 +- imapclient/fetch.go | 3 + imapclient/qresync_test.go | 205 ++++++++++++++++++++++++++++ imapclient/search.go | 34 ++++- imapclient/select.go | 22 ++- imapserver/capability.go | 1 + imapserver/conn.go | 20 +++ imapserver/enable.go | 2 +- imapserver/fetch.go | 14 ++ imapserver/imapmemserver/mailbox.go | 65 +++++++-- imapserver/imapmemserver/message.go | 3 + imapserver/imapmemserver/session.go | 7 +- imapserver/search.go | 9 ++ imapserver/select.go | 170 ++++++++++++++--------- imapserver/vanished.go | 32 +++++ search.go | 7 +- select.go | 28 ++++ vanished.go | 10 ++ 21 files changed, 704 insertions(+), 90 deletions(-) create mode 100644 imapclient/qresync_test.go create mode 100644 imapserver/vanished.go create mode 100644 vanished.go diff --git a/fetch.go b/fetch.go index f146c897..1f93b6c2 100644 --- a/fetch.go +++ b/fetch.go @@ -21,6 +21,7 @@ type FetchOptions struct { ModSeq bool // requires CONDSTORE ChangedSince uint64 // requires CONDSTORE + Vanished bool // requires QRESYNC, only valid for UID FETCH } // FetchItemBodyStructure contains FETCH options for the body structure. diff --git a/imapclient/client.go b/imapclient/client.go index 620bce36..342f7f9f 100644 --- a/imapclient/client.go +++ b/imapclient/client.go @@ -973,6 +973,11 @@ func (c *Client) readResponseData(typ string) error { return c.handleFetch(num) case "EXPUNGE": return c.handleExpunge(num) + case "VANISHED": + if !c.dec.ExpectSP() { + return c.dec.Err() + } + return c.handleVanished() case "SEARCH": return c.handleSearch() case "ESEARCH": @@ -1013,6 +1018,28 @@ func (c *Client) readResponseData(typ string) error { return nil } +func (c *Client) handleVanished() error { + var data imap.VanishedData + isParen := c.dec.Special('(') + if isParen { + var tag string + if !c.dec.ExpectAtom(&tag) || !c.dec.ExpectSpecial(')') { + return c.dec.Err() + } + data.Earlier = strings.ToUpper(tag) == "EARLIER" + } + + if !c.dec.ExpectSP() || !c.dec.ExpectUIDSet(&data.UIDs) { + return c.dec.Err() + } + + if handler := c.options.unilateralDataHandler().Vanished; handler != nil { + handler(&data) + } + + return nil +} + // WaitGreeting waits for the server's initial greeting. func (c *Client) WaitGreeting() error { select { @@ -1185,6 +1212,9 @@ type UnilateralDataHandler struct { Mailbox func(data *UnilateralDataMailbox) Fetch func(msg *FetchMessageData) + // requires ENABLE QRESYNC + Vanished func(data *imap.VanishedData) + // requires ENABLE METADATA or ENABLE SERVER-METADATA Metadata func(mailbox string, entries []string) } diff --git a/imapclient/condstore_test.go b/imapclient/condstore_test.go index 54b9aab4..54e2574a 100644 --- a/imapclient/condstore_test.go +++ b/imapclient/condstore_test.go @@ -309,3 +309,132 @@ func TestCapability_CondStore(t *testing.T) { t.Logf("CONDSTORE capability correctly announced after authentication") } } + +func TestSearch_ChangedSince(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateSelected) + defer client.Close() + defer server.Close() + + // First, get current ModSeq for our message + seqSet := imap.SeqSetNum(1) + firstFetch, err := client.Fetch(seqSet, &imap.FetchOptions{ + ModSeq: true, + }).Collect() + if err != nil { + t.Fatalf("Initial Fetch() = %v", err) + } + currentModSeq := firstFetch[0].ModSeq + t.Logf("Initial ModSeq: %d", currentModSeq) + + // Search with CHANGEDSINCE using a value lower than current + // This should find the message + searchCriteria := &imap.SearchCriteria{ + ChangedSince: currentModSeq - 1, + } + searchOptions := &imap.SearchOptions{ + ReturnCount: true, + } + results, err := client.Search(searchCriteria, searchOptions).Wait() + if err != nil { + t.Fatalf("Search with CHANGEDSINCE = %v", err) + } + + // There should be one message that matches + if results.Count != 1 { + t.Errorf("Search with CHANGEDSINCE < current returned %d messages, want 1", results.Count) + } + + // Search with CHANGEDSINCE using current value + // This should NOT find the message (since CHANGEDSINCE criterion is > not >=) + searchCriteria = &imap.SearchCriteria{ + ChangedSince: currentModSeq, + } + results, err = client.Search(searchCriteria, searchOptions).Wait() + if err != nil { + t.Fatalf("Search with CHANGEDSINCE = %v", err) + } + + // There should be no messages that match + if results.Count != 0 { + t.Errorf("Search with CHANGEDSINCE = current returned %d messages, want 0", results.Count) + } + + // Search with CHANGEDSINCE using a higher value + // This should NOT find the message + searchCriteria = &imap.SearchCriteria{ + ChangedSince: currentModSeq + 1, + } + results, err = client.Search(searchCriteria, searchOptions).Wait() + if err != nil { + t.Fatalf("Search with CHANGEDSINCE = %v", err) + } + + // There should be no messages that match + if results.Count != 0 { + t.Errorf("Search with CHANGEDSINCE > current returned %d messages, want 0", results.Count) + } +} + +func TestUIDSearch_ChangedSince(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateSelected) + defer client.Close() + defer server.Close() + + // First, get current ModSeq for our message + seqSet := imap.SeqSetNum(1) + firstFetch, err := client.Fetch(seqSet, &imap.FetchOptions{ + ModSeq: true, + UID: true, + }).Collect() + if err != nil { + t.Fatalf("Initial Fetch() = %v", err) + } + currentModSeq := firstFetch[0].ModSeq + expectedUID := firstFetch[0].UID + t.Logf("Initial ModSeq: %d, UID: %d", currentModSeq, expectedUID) + + // UID Search with CHANGEDSINCE + searchCriteria := &imap.SearchCriteria{ + ChangedSince: currentModSeq - 1, + } + searchOptions := &imap.SearchOptions{ + ReturnAll: true, + } + results, err := client.UIDSearch(searchCriteria, searchOptions).Wait() + if err != nil { + t.Fatalf("UID Search with CHANGEDSINCE = %v", err) + } + + // Check that we got the expected UID + uids := results.AllUIDs() + if len(uids) != 1 { + t.Errorf("UID Search with CHANGEDSINCE returned %d UIDs, want 1", len(uids)) + } else if uids[0] != expectedUID { + t.Errorf("UID Search with CHANGEDSINCE returned UID %d, want %d", uids[0], expectedUID) + } +} + +func TestSearch_ChangedSince_Combined(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateSelected) + defer client.Close() + defer server.Close() + + // Test CHANGEDSINCE combined with other search criteria + searchCriteria := &imap.SearchCriteria{ + ChangedSince: 1, // Use a low modseq to match existing messages + Flag: []imap.Flag{}, // Search for any flags (no specific flag required) + } + searchOptions := &imap.SearchOptions{ + ReturnCount: true, + } + results, err := client.Search(searchCriteria, searchOptions).Wait() + if err != nil { + t.Fatalf("Combined Search with CHANGEDSINCE = %v", err) + } + + t.Logf("Combined search returned %d messages", results.Count) + // We expect at least one message in the test mailbox + if results.Count == 0 { + t.Errorf("Combined search with CHANGEDSINCE should find at least one message") + } +} diff --git a/imapclient/enable.go b/imapclient/enable.go index 89576664..25062d22 100644 --- a/imapclient/enable.go +++ b/imapclient/enable.go @@ -14,7 +14,7 @@ func (c *Client) Enable(caps ...imap.Cap) *EnableCommand { // extensions we support here for _, name := range caps { switch name { - case imap.CapIMAP4rev2, imap.CapUTF8Accept, imap.CapMetadata, imap.CapMetadataServer: + case imap.CapIMAP4rev2, imap.CapUTF8Accept, imap.CapMetadata, imap.CapMetadataServer, imap.CapQResync: // ok default: done := make(chan error) diff --git a/imapclient/fetch.go b/imapclient/fetch.go index f60256fc..60730aa8 100644 --- a/imapclient/fetch.go +++ b/imapclient/fetch.go @@ -36,6 +36,9 @@ func (c *Client) Fetch(numSet imap.NumSet, options *imap.FetchOptions) *FetchCom if options.ChangedSince != 0 { enc.SP().Special('(').Atom("CHANGEDSINCE").SP().ModSeq(options.ChangedSince).Special(')') } + if options.Vanished && numKind == imapwire.NumKindUID { + enc.SP().Atom("VANISHED") + } enc.end() return cmd } diff --git a/imapclient/qresync_test.go b/imapclient/qresync_test.go new file mode 100644 index 00000000..057979cb --- /dev/null +++ b/imapclient/qresync_test.go @@ -0,0 +1,205 @@ +package imapclient_test + +import ( + "testing" + + "github.com/emersion/go-imap/v2" +) + +func TestEnable_QResync(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + // Enable QRESYNC + data, err := client.Enable(imap.CapQResync).Wait() + if err != nil { + t.Fatalf("Enable(QRESYNC) = %v", err) + } + + if !data.Caps.Has(imap.CapQResync) { + t.Errorf("QRESYNC capability not enabled") + } + t.Logf("Successfully enabled QRESYNC") +} + +func TestSelect_QResync(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + // Enable QRESYNC first + _, err := client.Enable(imap.CapQResync).Wait() + if err != nil { + t.Fatalf("Enable(QRESYNC) = %v", err) + } + + // First SELECT to get initial state + firstSelect, err := client.Select("INBOX", nil).Wait() + if err != nil { + t.Fatalf("First Select() = %v", err) + } + t.Logf("Initial SELECT - UIDValidity: %d, HighestModSeq: %d", + firstSelect.UIDValidity, firstSelect.HighestModSeq) + + // Unselect to test QRESYNC SELECT + if err := client.Unselect().Wait(); err != nil { + t.Fatalf("Unselect() = %v", err) + } + + // SELECT with QRESYNC + qresyncOptions := &imap.SelectOptions{ + QResync: &imap.QResyncData{ + UIDValidity: firstSelect.UIDValidity, + ModSeq: firstSelect.HighestModSeq, + }, + } + + secondSelect, err := client.Select("INBOX", qresyncOptions).Wait() + if err != nil { + t.Fatalf("QRESYNC Select() = %v", err) + } + + // Verify QRESYNC worked + if secondSelect.UIDValidity != firstSelect.UIDValidity { + t.Errorf("UIDValidity changed: %d != %d", secondSelect.UIDValidity, firstSelect.UIDValidity) + } + if secondSelect.HighestModSeq < firstSelect.HighestModSeq { + t.Errorf("HighestModSeq decreased: %d < %d", secondSelect.HighestModSeq, firstSelect.HighestModSeq) + } + t.Logf("QRESYNC SELECT successful") +} + +func TestSelect_QResync_WithKnownUIDs(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + // Enable QRESYNC first + _, err := client.Enable(imap.CapQResync).Wait() + if err != nil { + t.Fatalf("Enable(QRESYNC) = %v", err) + } + + // First SELECT to get initial state + firstSelect, err := client.Select("INBOX", nil).Wait() + if err != nil { + t.Fatalf("First Select() = %v", err) + } + + // Get some UIDs to test with + fetchOptions := &imap.FetchOptions{UID: true} + messages, err := client.Fetch(imap.SeqSetNum(1), fetchOptions).Collect() + if err != nil { + t.Fatalf("Fetch UIDs = %v", err) + } + + var knownUIDs imap.UIDSet + if len(messages) > 0 { + knownUIDs = imap.UIDSetNum(messages[0].UID) + } + + // Unselect to test QRESYNC SELECT with known UIDs + if err := client.Unselect().Wait(); err != nil { + t.Fatalf("Unselect() = %v", err) + } + + // SELECT with QRESYNC and known UIDs + qresyncOptions := &imap.SelectOptions{ + QResync: &imap.QResyncData{ + UIDValidity: firstSelect.UIDValidity, + ModSeq: firstSelect.HighestModSeq, + KnownUIDs: knownUIDs, + }, + } + + _, err = client.Select("INBOX", qresyncOptions).Wait() + if err != nil { + t.Fatalf("QRESYNC Select() with known UIDs = %v", err) + } + + t.Logf("QRESYNC SELECT with known UIDs successful") +} + +func TestUIDFetch_Vanished(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateSelected) + defer client.Close() + defer server.Close() + + // Enable QRESYNC first + _, err := client.Enable(imap.CapQResync).Wait() + if err != nil { + t.Fatalf("Enable(QRESYNC) = %v", err) + } + + // Test UID FETCH with VANISHED modifier + fetchOptions := &imap.FetchOptions{ + Flags: true, + ChangedSince: 1, // Use a low modseq to potentially get some results + Vanished: true, + } + + uidSet := imap.UIDSetNum(1) + uidSet.AddRange(1, 0) // 1:* + messages, err := client.Fetch(uidSet, fetchOptions).Collect() + if err != nil { + t.Fatalf("UID FETCH with VANISHED = %v", err) + } + + t.Logf("UID FETCH with VANISHED returned %d messages", len(messages)) +} + +func TestVanished_Response(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateSelected) + defer client.Close() + defer server.Close() + + // Enable QRESYNC first + _, err := client.Enable(imap.CapQResync).Wait() + if err != nil { + t.Fatalf("Enable(QRESYNC) = %v", err) + } + + // Note: In a real test, we would need to trigger an expunge that causes + // a VANISHED response. For now, we just verify QRESYNC is enabled. + // The VANISHED responses would be handled by the UnilateralDataHandler + // which can be set when creating the client. + + // This test just verifies that QRESYNC is properly enabled + // and the client can handle the expected protocol + + t.Logf("VANISHED response handler test completed") +} + +func TestCapability_QResync_Implications(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateNotAuthenticated) + defer client.Close() + defer server.Close() + + // Check that QRESYNC implies CONDSTORE + caps, err := client.Capability().Wait() + if err != nil { + t.Fatalf("Capability() = %v", err) + } + + // Login first + if err := client.Login(testUsername, testPassword).Wait(); err != nil { + t.Fatalf("Login() = %v", err) + } + + // Enable QRESYNC + enableData, err := client.Enable(imap.CapQResync).Wait() + if err != nil { + t.Fatalf("Enable(QRESYNC) = %v", err) + } + + // Verify QRESYNC implies CONDSTORE + if enableData.Caps.Has(imap.CapQResync) && !enableData.Caps.Has(imap.CapCondStore) { + // Check if CONDSTORE is implied by QRESYNC in the capability system + if !caps.Has(imap.CapCondStore) { + t.Errorf("QRESYNC should imply CONDSTORE capability") + } + } + + t.Logf("QRESYNC capability implications verified") +} \ No newline at end of file diff --git a/imapclient/search.go b/imapclient/search.go index 17ac1161..05cb69b4 100644 --- a/imapclient/search.go +++ b/imapclient/search.go @@ -246,6 +246,10 @@ func writeSearchKey(enc *imapwire.Encoder, criteria *imap.SearchCriteria) { } } + if criteria.ChangedSince > 0 { + encodeItem().Atom("CHANGEDSINCE").SP().ModSeq(criteria.ChangedSince) + } + for _, not := range criteria.Not { encodeItem().Atom("NOT").SP() enc.Special('(') @@ -340,12 +344,6 @@ func readESearchResponse(dec *imapwire.Decoder) (tag string, data *imap.SearchDa return "", nil, dec.Err() } data.Count = num - case "MODSEQ": - var modSeq uint64 - if !dec.ExpectModSeq(&modSeq) { - return "", nil, dec.Err() - } - data.ModSeq = modSeq default: if !dec.DiscardValue() { return "", nil, dec.Err() @@ -354,6 +352,30 @@ func readESearchResponse(dec *imapwire.Decoder) (tag string, data *imap.SearchDa if !dec.SP() { break + } else if dec.Special('(') { + // Handle parenthesized items like (MODSEQ 123) + var atomName string + if !dec.ExpectAtom(&atomName) { + return "", nil, dec.Err() + } + if strings.ToUpper(atomName) == "MODSEQ" { + var modSeq uint64 + if !dec.ExpectSP() || !dec.ExpectModSeq(&modSeq) || !dec.ExpectSpecial(')') { + return "", nil, dec.Err() + } + data.ModSeq = modSeq + } else { + // Unknown parenthesized item, skip it + if !dec.DiscardValue() || !dec.ExpectSpecial(')') { + return "", nil, dec.Err() + } + } + // Continue to check for more items + if !dec.SP() { + break + } else if !dec.ExpectAtom(&name) { + return "", nil, dec.Err() + } } else if !dec.ExpectAtom(&name) { return "", nil, dec.Err() } diff --git a/imapclient/select.go b/imapclient/select.go index 90cd1154..c4b824a4 100644 --- a/imapclient/select.go +++ b/imapclient/select.go @@ -17,9 +17,27 @@ func (c *Client) Select(mailbox string, options *imap.SelectOptions) *SelectComm cmd := &SelectCommand{mailbox: mailbox} enc := c.beginCommand(cmdName, cmd) enc.SP().Mailbox(mailbox) - if options != nil && options.CondStore { - enc.SP().Special('(').Atom("CONDSTORE").Special(')') + + if options != nil { + if options.CondStore { + enc.SP().Special('(').Atom("CONDSTORE").Special(')') + } else if options.QResync != nil { + enc.SP().Special('(').Atom("QRESYNC").SP().Special('(') + enc.Number(options.QResync.UIDValidity) + enc.SP().ModSeq(options.QResync.ModSeq) + if len(options.QResync.KnownUIDs) > 0 { + enc.SP().NumSet(options.QResync.KnownUIDs) + if options.QResync.SeqMatch != nil { + enc.SP().Special('(') + enc.NumSet(options.QResync.SeqMatch.SeqNums) + enc.SP().NumSet(options.QResync.SeqMatch.UIDs) + enc.Special(')') + } + } + enc.Special(')').Special(')') + } } + enc.end() return cmd } diff --git a/imapserver/capability.go b/imapserver/capability.go index 3de60715..4d17558d 100644 --- a/imapserver/capability.go +++ b/imapserver/capability.go @@ -94,6 +94,7 @@ func (c *Conn) availableCaps() []imap.Cap { imap.CapLiteralPlus, imap.CapUnauthenticate, imap.CapCondStore, + imap.CapQResync, }) if appendLimitSession, ok := c.session.(SessionAppendLimit); ok { diff --git a/imapserver/conn.go b/imapserver/conn.go index 291f37ec..c3aaa0ff 100644 --- a/imapserver/conn.go +++ b/imapserver/conn.go @@ -574,6 +574,26 @@ func newClientBugError(text string) error { } } +func (c *Conn) writeExists(numMessages uint32) error { + enc := newResponseEncoder(c) + defer enc.end() + return writeExists(enc.Encoder, numMessages) +} + +func writeExists(enc *imapwire.Encoder, numMessages uint32) error { + return enc.Atom("*").SP().Number(numMessages).SP().Atom("EXISTS").CRLF() +} + +func (c *Conn) writeObsoleteRecent(n uint32) error { + enc := newResponseEncoder(c) + defer enc.end() + return writeObsoleteRecent(enc.Encoder, n) +} + +func writeObsoleteRecent(enc *imapwire.Encoder, n uint32) error { + return enc.Atom("*").SP().Number(n).SP().Atom("RECENT").CRLF() +} + // UpdateWriter writes status updates. type UpdateWriter struct { conn *Conn diff --git a/imapserver/enable.go b/imapserver/enable.go index 051e6c62..0e0bb5b9 100644 --- a/imapserver/enable.go +++ b/imapserver/enable.go @@ -26,7 +26,7 @@ func (c *Conn) handleEnable(dec *imapwire.Decoder) error { var enabled []imap.Cap for _, req := range requested { switch req { - case imap.CapIMAP4rev2, imap.CapUTF8Accept: + case imap.CapIMAP4rev2, imap.CapUTF8Accept, imap.CapQResync: enabled = append(enabled, req) } } diff --git a/imapserver/fetch.go b/imapserver/fetch.go index 29e7381a..d2a99049 100644 --- a/imapserver/fetch.go +++ b/imapserver/fetch.go @@ -95,6 +95,20 @@ func (c *Conn) handleFetch(dec *imapwire.Decoder, numKind NumKind) error { } } + // Check for VANISHED modifier (separate from parenthesized modifiers) + if dec.SP() { + var atom string + if dec.ExpectAtom(&atom) && strings.ToUpper(atom) == "VANISHED" { + if numKind != NumKindUID { + return fmt.Errorf("VANISHED modifier only allowed with UID FETCH") + } + options.Vanished = true + } else { + // Put the atom back by returning an error that we don't recognize it + return fmt.Errorf("unexpected token: %v", atom) + } + } + if !dec.ExpectCRLF() { return dec.Err() } diff --git a/imapserver/imapmemserver/mailbox.go b/imapserver/imapmemserver/mailbox.go index c3baab61..812a2feb 100644 --- a/imapserver/imapmemserver/mailbox.go +++ b/imapserver/imapmemserver/mailbox.go @@ -25,6 +25,12 @@ type Mailbox struct { l []*message uidNext imap.UID highestModSeq uint64 + expunged []expungedMessage +} + +type expungedMessage struct { + uid imap.UID + modSeq uint64 } // NewMailbox creates a new mailbox. @@ -263,29 +269,23 @@ func (mbox *Mailbox) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) err } func (mbox *Mailbox) expungeLocked(expunged map[*message]struct{}) (seqNums []uint32) { - // TODO: optimize + mbox.highestModSeq++ + expungeModSeq := mbox.highestModSeq - // Iterate in reverse order, to keep sequence numbers consistent - var filtered []*message - for i := len(mbox.l) - 1; i >= 0; i-- { + n := 0 + for i := 0; i < len(mbox.l); i++ { msg := mbox.l[i] if _, ok := expunged[msg]; ok { seqNum := uint32(i) + 1 seqNums = append(seqNums, seqNum) mbox.tracker.QueueExpunge(seqNum) + mbox.expunged = append(mbox.expunged, expungedMessage{uid: msg.uid, modSeq: expungeModSeq}) } else { - filtered = append(filtered, msg) + mbox.l[n] = msg + n++ } } - - // Reverse filtered - for i := 0; i < len(filtered)/2; i++ { - j := len(filtered) - i - 1 - filtered[i], filtered[j] = filtered[j], filtered[i] - } - - mbox.l = filtered - + mbox.l = mbox.l[:n] return seqNums } @@ -318,6 +318,42 @@ func (mbox *MailboxView) Close() { mbox.tracker.Close() } +func (mbox *MailboxView) selectData(options *imap.SelectOptions) (*imap.SelectData, error) { + mbox.mutex.Lock() + defer mbox.mutex.Unlock() + + data := mbox.selectDataLocked() + + if options.QResync != nil && mbox.uidValidity == options.QResync.UIDValidity { + var vanished imap.UIDSet + for _, expunged := range mbox.expunged { + if expunged.modSeq > options.QResync.ModSeq { + vanished.AddNum(expunged.uid) + } + } + data.Vanished = vanished + + var modified []imap.SelectModifiedData + for i, msg := range mbox.l { + if msg.modSeq > options.QResync.ModSeq { + seqNum := mbox.tracker.EncodeSeqNum(uint32(i + 1)) + if seqNum == 0 { + continue // message has been expunged in this session + } + modified = append(modified, imap.SelectModifiedData{ + SeqNum: seqNum, + UID: msg.uid, + Flags: msg.flagList(), + ModSeq: msg.modSeq, + }) + } + } + data.Modified = modified + } + + return data, nil +} + func (mbox *MailboxView) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error { markSeen := false for _, bs := range options.BodySection { @@ -396,6 +432,7 @@ func (mbox *MailboxView) Search(numKind imapserver.NumKind, criteria *imap.Searc data.All = uidSet } + data.ModSeq = mbox.highestModSeq if options.ReturnSave { mbox.searchRes = uidSet } diff --git a/imapserver/imapmemserver/message.go b/imapserver/imapmemserver/message.go index bbb26f68..b55064f6 100644 --- a/imapserver/imapmemserver/message.go +++ b/imapserver/imapmemserver/message.go @@ -173,6 +173,9 @@ func (msg *message) search(seqNum uint32, criteria *imap.SearchCriteria) bool { if criteria.ModSeq != nil && msg.modSeq < criteria.ModSeq.ModSeq { return false } + if criteria.ChangedSince > 0 && msg.modSeq <= criteria.ChangedSince { + return false + } for _, flag := range criteria.Flag { if _, ok := msg.flags[canonicalFlag(flag)]; !ok { return false diff --git a/imapserver/imapmemserver/session.go b/imapserver/imapmemserver/session.go index 70e9d2f8..65451d3e 100644 --- a/imapserver/imapmemserver/session.go +++ b/imapserver/imapmemserver/session.go @@ -38,10 +38,11 @@ func (sess *UserSession) Select(name string, options *imap.SelectOptions) (*imap if err != nil { return nil, err } - mbox.mutex.Lock() - defer mbox.mutex.Unlock() + if sess.mailbox != nil { + sess.mailbox.Close() + } sess.mailbox = mbox.NewView() - return mbox.selectDataLocked(), nil + return sess.mailbox.selectData(options) } func (sess *UserSession) Unselect() error { diff --git a/imapserver/search.go b/imapserver/search.go index 7a600be1..dde0d812 100644 --- a/imapserver/search.go +++ b/imapserver/search.go @@ -117,6 +117,9 @@ func (c *Conn) writeESearch(tag string, data *imap.SearchData, options *imap.Sea if options.ReturnCount { enc.SP().Atom("COUNT").SP().Number(data.Count) } + if data.ModSeq > 0 { + enc.SP().Special('(').Atom("MODSEQ").SP().ModSeq(data.ModSeq).Special(')') + } return enc.CRLF() } @@ -355,6 +358,12 @@ func readSearchKeyWithAtom(criteria *imap.SearchCriteria, dec *imapwire.Decoder, MetadataName: name, MetadataType: metadataType, } + case "CHANGEDSINCE": + var modSeq uint64 + if !dec.ExpectSP() || !dec.ExpectModSeq(&modSeq) { + return dec.Err() + } + criteria.ChangedSince = modSeq default: seqSet, err := imapwire.ParseSeqSet(key) if err != nil { diff --git a/imapserver/select.go b/imapserver/select.go index 292ae5a4..ae1d7b3c 100644 --- a/imapserver/select.go +++ b/imapserver/select.go @@ -30,6 +30,56 @@ func (c *Conn) handleSelect(tag string, dec *imapwire.Decoder, readOnly bool) er if c.server.options.caps().Has(imap.CapCondStore) || c.enabled.Has(imap.CapIMAP4rev2) { options.CondStore = true } + case "QRESYNC": + // Per RFC 7162, QRESYNC requires ENABLE QRESYNC + if c.enabled.Has(imap.CapQResync) { + if !dec.ExpectSP() { + return dec.Err() + } + err := dec.ExpectList(func() error { + var uidValidity uint32 + var modSeq uint64 + if !dec.ExpectNumber(&uidValidity) || !dec.ExpectSP() || !dec.ExpectModSeq(&modSeq) { + return dec.Err() + } + + qresyncData := &imap.QResyncData{ + UIDValidity: uidValidity, + ModSeq: modSeq, + } + + if dec.SP() { + var knownUIDs imap.UIDSet + if dec.ExpectUIDSet(&knownUIDs) { + qresyncData.KnownUIDs = knownUIDs + + if dec.SP() { + // Optional sequence/UID match data + err := dec.ExpectList(func() error { + var seqNums, uids imap.UIDSet + if !dec.ExpectUIDSet(&seqNums) || !dec.ExpectSP() || !dec.ExpectUIDSet(&uids) { + return dec.Err() + } + qresyncData.SeqMatch = &imap.QResyncSeqMatch{ + SeqNums: seqNums, + UIDs: uids, + } + return nil + }) + if err != nil { + return err + } + } + } + } + + options.QResync = qresyncData + return nil + }) + if err != nil { + return err + } + } default: return newClientBugError(fmt.Sprintf("unknown SELECT parameter: %v", param)) } @@ -68,52 +118,51 @@ func (c *Conn) handleSelect(tag string, dec *imapwire.Decoder, readOnly bool) er return err } - if err := c.writeExists(data.NumMessages); err != nil { - return err - } - if !c.enabled.Has(imap.CapIMAP4rev2) && c.server.options.caps().Has(imap.CapIMAP4rev1) { - if err := c.writeObsoleteRecent(data.NumRecent); err != nil { - return err - } - if data.FirstUnseenSeqNum != 0 { - if err := c.writeObsoleteUnseen(data.FirstUnseenSeqNum); err != nil { - return err + enc := newResponseEncoder(c) + defer enc.end() + + isQResync := options.QResync != nil && data.UIDValidity == options.QResync.UIDValidity + if !isQResync { + writeExists(enc.Encoder, data.NumMessages) + if !c.enabled.Has(imap.CapIMAP4rev2) && c.server.options.caps().Has(imap.CapIMAP4rev1) { + writeObsoleteRecent(enc.Encoder, data.NumRecent) + if data.FirstUnseenSeqNum != 0 { + writeObsoleteUnseen(enc.Encoder, data.FirstUnseenSeqNum) } } } - if err := c.writeUIDValidity(data.UIDValidity); err != nil { - return err - } - if err := c.writeUIDNext(data.UIDNext); err != nil { - return err - } - if err := c.writeFlags(data.Flags); err != nil { - return err + + if len(data.Vanished) > 0 { + writeVanished(enc.Encoder, data.Vanished) } - if err := c.writePermanentFlags(data.PermanentFlags); err != nil { - return err + + if len(data.Modified) > 0 { + for _, mod := range data.Modified { + if err := writeQResyncFetch(enc.Encoder, mod); err != nil { + return err + } + } } + + writeUIDValidity(enc.Encoder, data.UIDValidity) + writeUIDNext(enc.Encoder, data.UIDNext) + writeFlags(enc.Encoder, data.Flags) + writePermanentFlags(enc.Encoder, data.PermanentFlags) if data.List != nil { if err := c.writeList(data.List); err != nil { return err } } - shouldSendModSeqStatus := c.server.options.caps().Has(imap.CapCondStore) - if shouldSendModSeqStatus { + if c.server.options.caps().Has(imap.CapCondStore) { if data.HighestModSeq > 0 { - if err := c.writeHighestModSeq(data.HighestModSeq); err != nil { - return err - } + writeHighestModSeq(enc.Encoder, data.HighestModSeq) } else { - if err := c.writeNoModSeq(); err != nil { - return err - } + writeNoModSeq(enc.Encoder) } } c.state = imap.ConnStateSelected - // TODO: forbid write commands in read-only mode var ( cmdName string @@ -126,7 +175,7 @@ func (c *Conn) handleSelect(tag string, dec *imapwire.Decoder, readOnly bool) er cmdName = "SELECT" code = "READ-WRITE" } - return c.writeStatusResp(tag, &imap.StatusResponse{ + return writeStatusResp(enc.Encoder, tag, &imap.StatusResponse{ Type: imap.StatusResponseTypeOK, Code: code, Text: fmt.Sprintf("%v completed", cmdName), @@ -143,7 +192,7 @@ func (c *Conn) handleUnselect(dec *imapwire.Decoder, expunge bool) error { } if expunge { - w := &ExpungeWriter{} + w := &ExpungeWriter{conn: c} if err := c.session.Expunge(w, nil); err != nil { return err } @@ -157,57 +206,41 @@ func (c *Conn) handleUnselect(dec *imapwire.Decoder, expunge bool) error { return nil } -func (c *Conn) writeExists(numMessages uint32) error { - enc := newResponseEncoder(c) - defer enc.end() - return enc.Atom("*").SP().Number(numMessages).SP().Atom("EXISTS").CRLF() -} - -func (c *Conn) writeObsoleteRecent(n uint32) error { - enc := newResponseEncoder(c) - defer enc.end() - return enc.Atom("*").SP().Number(n).SP().Atom("RECENT").CRLF() -} - -func (c *Conn) writeObsoleteUnseen(n uint32) error { - enc := newResponseEncoder(c) - defer enc.end() +func writeObsoleteUnseen(enc *imapwire.Encoder, n uint32) error { enc.Atom("*").SP().Atom("OK").SP() enc.Special('[').Atom("UNSEEN").SP().Number(n).Special(']') enc.SP().Text("First unseen message") return enc.CRLF() } -func (c *Conn) writeUIDValidity(uidValidity uint32) error { - enc := newResponseEncoder(c) - defer enc.end() +func writeUIDValidity(enc *imapwire.Encoder, uidValidity uint32) error { enc.Atom("*").SP().Atom("OK").SP() enc.Special('[').Atom("UIDVALIDITY").SP().Number(uidValidity).Special(']') enc.SP().Text("UIDs valid") return enc.CRLF() } -func (c *Conn) writeUIDNext(uidNext imap.UID) error { - enc := newResponseEncoder(c) - defer enc.end() +func writeUIDNext(enc *imapwire.Encoder, uidNext imap.UID) error { enc.Atom("*").SP().Atom("OK").SP() enc.Special('[').Atom("UIDNEXT").SP().UID(uidNext).Special(']') enc.SP().Text("Predicted next UID") return enc.CRLF() } -func (c *Conn) writeFlags(flags []imap.Flag) error { +func (c *Conn) writeFlags(flags []imap.Flag) error { // also used by UpdateWriter enc := newResponseEncoder(c) defer enc.end() + return writeFlags(enc.Encoder, flags) +} + +func writeFlags(enc *imapwire.Encoder, flags []imap.Flag) error { enc.Atom("*").SP().Atom("FLAGS").SP().List(len(flags), func(i int) { enc.Flag(flags[i]) }) return enc.CRLF() } -func (c *Conn) writePermanentFlags(flags []imap.Flag) error { - enc := newResponseEncoder(c) - defer enc.end() +func writePermanentFlags(enc *imapwire.Encoder, flags []imap.Flag) error { enc.Atom("*").SP().Atom("OK").SP() enc.Special('[').Atom("PERMANENTFLAGS").SP().List(len(flags), func(i int) { enc.Flag(flags[i]) @@ -216,20 +249,33 @@ func (c *Conn) writePermanentFlags(flags []imap.Flag) error { return enc.CRLF() } -func (c *Conn) writeHighestModSeq(highestModSeq uint64) error { - enc := newResponseEncoder(c) - defer enc.end() +func writeHighestModSeq(enc *imapwire.Encoder, highestModSeq uint64) error { enc.Atom("*").SP().Atom("OK").SP() enc.Special('[').Atom("HIGHESTMODSEQ").SP().ModSeq(highestModSeq).Special(']') enc.SP().Text("Highest modification sequence") return enc.CRLF() } -func (c *Conn) writeNoModSeq() error { - enc := newResponseEncoder(c) - defer enc.end() +func writeNoModSeq(enc *imapwire.Encoder) error { enc.Atom("*").SP().Atom("OK").SP() enc.Special('[').Atom("NOMODSEQ").Special(']') enc.SP().Text("Mailbox does not support modification sequences") return enc.CRLF() } + +func writeVanished(enc *imapwire.Encoder, uids imap.UIDSet) error { + enc.Atom("*").SP().Atom("VANISHED").SP() + enc.NumSet(uids) + return enc.CRLF() +} + +func writeQResyncFetch(enc *imapwire.Encoder, mod imap.SelectModifiedData) error { + enc.Atom("*").SP().Number(mod.SeqNum).SP().Atom("FETCH").SP().Special('(') + enc.Atom("UID").SP().UID(mod.UID) + enc.SP().Atom("FLAGS").SP().List(len(mod.Flags), func(i int) { + enc.Flag(mod.Flags[i]) + }) + enc.SP().Atom("MODSEQ").SP().ModSeq(mod.ModSeq) + enc.Special(')') + return enc.CRLF() +} diff --git a/imapserver/vanished.go b/imapserver/vanished.go new file mode 100644 index 00000000..efe408d5 --- /dev/null +++ b/imapserver/vanished.go @@ -0,0 +1,32 @@ +package imapserver + +import ( + "github.com/emersion/go-imap/v2" +) + +// VanishedWriter writes VANISHED updates for QRESYNC-enabled connections. +type VanishedWriter struct { + conn *Conn +} + +// WriteVanished notifies the client that the messages with the provided UIDs +// have been expunged. If earlier is true, this is a VANISHED (EARLIER) response. +func (w *VanishedWriter) WriteVanished(uids imap.UIDSet, earlier bool) error { + if w.conn == nil { + return nil + } + return w.conn.writeVanished(uids, earlier) +} + +func (c *Conn) writeVanished(uids imap.UIDSet, earlier bool) error { + enc := newResponseEncoder(c) + defer enc.end() + enc.Atom("*").SP().Atom("VANISHED") + if earlier { + enc.SP().List(1, func(i int) { + enc.Atom("EARLIER") + }) + } + enc.SP().NumSet(uids) + return enc.CRLF() +} \ No newline at end of file diff --git a/search.go b/search.go index a94f52f3..b763bda1 100644 --- a/search.go +++ b/search.go @@ -58,7 +58,8 @@ type SearchCriteria struct { Not []SearchCriteria Or [][2]SearchCriteria - ModSeq *SearchCriteriaModSeq // requires CONDSTORE + ModSeq *SearchCriteriaModSeq // requires CONDSTORE + ChangedSince uint64 // requires CONDSTORE } // And intersects two search criteria. @@ -87,6 +88,10 @@ func (criteria *SearchCriteria) And(other *SearchCriteria) { criteria.Not = append(criteria.Not, other.Not...) criteria.Or = append(criteria.Or, other.Or...) + + if criteria.ChangedSince == 0 || other.ChangedSince > criteria.ChangedSince { + criteria.ChangedSince = other.ChangedSince + } } func intersectSince(t1, t2 time.Time) time.Time { diff --git a/select.go b/select.go index f307ff34..e19174f8 100644 --- a/select.go +++ b/select.go @@ -4,6 +4,30 @@ package imap type SelectOptions struct { ReadOnly bool CondStore bool // requires CONDSTORE + QResync *QResyncData +} + +// QResyncData contains data for the QRESYNC SELECT/EXAMINE parameter. +type QResyncData struct { + UIDValidity uint32 + ModSeq uint64 + KnownUIDs UIDSet + SeqMatch *QResyncSeqMatch +} + +// QResyncSeqMatch contains sequence match data for the QRESYNC parameter. +type QResyncSeqMatch struct { + SeqNums UIDSet + UIDs UIDSet +} + +// SelectModifiedData contains data about a message modified since a given +// mod-sequence, for the QRESYNC extension. +type SelectModifiedData struct { + SeqNum uint32 + UID UID + Flags []Flag + ModSeq uint64 } // SelectData is the data returned by a SELECT command. @@ -28,4 +52,8 @@ type SelectData struct { List *ListData // requires IMAP4rev2 HighestModSeq uint64 // requires CONDSTORE + + // QRESYNC extension + Vanished UIDSet + Modified []SelectModifiedData } diff --git a/vanished.go b/vanished.go new file mode 100644 index 00000000..3129dbe5 --- /dev/null +++ b/vanished.go @@ -0,0 +1,10 @@ +package imap + +// VanishedData represents a VANISHED response. +type VanishedData struct { + // Earlier indicates this is a VANISHED (EARLIER) response + // sent during SELECT with QRESYNC + Earlier bool + // UIDs is the set of UIDs that have been expunged + UIDs UIDSet +} From 239a2ae187a7b8ae37a2d4b4f70808234045fa7a Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 21 Sep 2025 19:21:58 +0200 Subject: [PATCH 19/29] update test to support QRESYNC --- imapclient/client_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/imapclient/client_test.go b/imapclient/client_test.go index 1d5158c3..0eb978b0 100644 --- a/imapclient/client_test.go +++ b/imapclient/client_test.go @@ -103,6 +103,7 @@ func newMemClientServerPair(t *testing.T) (net.Conn, io.Closer) { imap.CapIMAP4rev1: {}, imap.CapIMAP4rev2: {}, imap.CapCondStore: {}, + imap.CapQResync: {}, }, }) From 8539f83cc809b6ee139ea8cbfa2cf849e492afb1 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 21 Sep 2025 19:43:44 +0200 Subject: [PATCH 20/29] send CONDSTORE only if advertised --- imapclient/search.go | 14 +++++++------- imapclient/sort.go | 2 +- imapclient/thread.go | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/imapclient/search.go b/imapclient/search.go index 05cb69b4..a8a04a7f 100644 --- a/imapclient/search.go +++ b/imapclient/search.go @@ -67,7 +67,7 @@ func (c *Client) search(numKind imapwire.NumKind, criteria *imap.SearchCriteria, if charset != "" { enc.Atom("CHARSET").SP().Atom(charset).SP() } - writeSearchKey(enc.Encoder, criteria) + writeSearchKey(enc.Encoder, criteria, c.Caps().Has(imap.CapCondStore)) enc.end() return cmd } @@ -156,7 +156,7 @@ func (cmd *SearchCommand) Wait() (*imap.SearchData, error) { return &cmd.data, cmd.wait() } -func writeSearchKey(enc *imapwire.Encoder, criteria *imap.SearchCriteria) { +func writeSearchKey(enc *imapwire.Encoder, criteria *imap.SearchCriteria, condstore bool) { firstItem := true encodeItem := func() *imapwire.Encoder { if !firstItem { @@ -233,7 +233,7 @@ func writeSearchKey(enc *imapwire.Encoder, criteria *imap.SearchCriteria) { encodeItem().Atom("SMALLER").SP().Number64(criteria.Smaller) } - if modSeq := criteria.ModSeq; modSeq != nil { + if modSeq := criteria.ModSeq; modSeq != nil && condstore { encodeItem().Atom("MODSEQ") if modSeq.MetadataName != "" && modSeq.MetadataType != "" { enc.SP().Quoted(modSeq.MetadataName).SP().Atom(string(modSeq.MetadataType)) @@ -246,24 +246,24 @@ func writeSearchKey(enc *imapwire.Encoder, criteria *imap.SearchCriteria) { } } - if criteria.ChangedSince > 0 { + if criteria.ChangedSince > 0 && condstore { encodeItem().Atom("CHANGEDSINCE").SP().ModSeq(criteria.ChangedSince) } for _, not := range criteria.Not { encodeItem().Atom("NOT").SP() enc.Special('(') - writeSearchKey(enc, ¬) + writeSearchKey(enc, ¬, condstore) enc.Special(')') } for _, or := range criteria.Or { encodeItem().Atom("OR").SP() enc.Special('(') - writeSearchKey(enc, &or[0]) + writeSearchKey(enc, &or[0], condstore) enc.Special(')') enc.SP() enc.Special('(') - writeSearchKey(enc, &or[1]) + writeSearchKey(enc, &or[1], condstore) enc.Special(')') } diff --git a/imapclient/sort.go b/imapclient/sort.go index 260706d3..0ae847e6 100644 --- a/imapclient/sort.go +++ b/imapclient/sort.go @@ -39,7 +39,7 @@ func (c *Client) sort(numKind imapwire.NumKind, options *SortOptions) *SortComma enc.Atom(string(criterion.Key)) }) enc.SP().Atom("UTF-8").SP() - writeSearchKey(enc.Encoder, options.SearchCriteria) + writeSearchKey(enc.Encoder, options.SearchCriteria, c.Caps().Has(imap.CapCondStore)) enc.end() return cmd } diff --git a/imapclient/thread.go b/imapclient/thread.go index c341a18e..3bdd0b63 100644 --- a/imapclient/thread.go +++ b/imapclient/thread.go @@ -17,7 +17,7 @@ func (c *Client) thread(numKind imapwire.NumKind, options *ThreadOptions) *Threa cmd := &ThreadCommand{} enc := c.beginCommand(uidCmdName("THREAD", numKind), cmd) enc.SP().Atom(string(options.Algorithm)).SP().Atom("UTF-8").SP() - writeSearchKey(enc.Encoder, options.SearchCriteria) + writeSearchKey(enc.Encoder, options.SearchCriteria, c.Caps().Has(imap.CapCondStore)) enc.end() return cmd } From 30e5af061a92308e0e085528ed3dd675f3a10e5b Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 21 Sep 2025 20:22:55 +0200 Subject: [PATCH 21/29] Refactor setCaps() and Caps() for race condition --- imapclient/client.go | 52 +++++++++++--------------------------------- 1 file changed, 13 insertions(+), 39 deletions(-) diff --git a/imapclient/client.go b/imapclient/client.go index 342f7f9f..375d29f0 100644 --- a/imapclient/client.go +++ b/imapclient/client.go @@ -154,16 +154,15 @@ type Client struct { decCh chan struct{} decErr error - mutex sync.Mutex - state imap.ConnState - caps imap.CapSet - enabled imap.CapSet - pendingCapCh chan struct{} - mailbox *SelectedMailbox - cmdTag uint64 - pendingCmds []command - contReqs []continuationRequest - closed bool + mutex sync.Mutex + state imap.ConnState + caps imap.CapSet + enabled imap.CapSet + mailbox *SelectedMailbox + cmdTag uint64 + pendingCmds []command + contReqs []continuationRequest + closed bool } // New creates a new IMAP client. @@ -319,58 +318,33 @@ func (c *Client) Caps() imap.CapSet { c.mutex.Lock() caps := c.caps - capCh := c.pendingCapCh c.mutex.Unlock() if caps != nil { return caps } - if capCh == nil { - capCmd := c.Capability() - capCh := make(chan struct{}) - go func() { - capCmd.Wait() - close(capCh) - }() - c.mutex.Lock() - c.pendingCapCh = capCh - c.mutex.Unlock() - } - - timer := time.NewTimer(respReadTimeout) - defer timer.Stop() - select { - case <-timer.C: + capCmd := c.Capability() + caps, err := capCmd.Wait() + if err != nil { return nil - case <-capCh: - // ok } - - // TODO: this is racy if caps are reset before we get the reply - c.mutex.Lock() - defer c.mutex.Unlock() - return c.caps + return caps } func (c *Client) setCaps(caps imap.CapSet) { // If the capabilities are being reset, request the updated capabilities // from the server - var capCh chan struct{} if caps == nil { - capCh = make(chan struct{}) - // We need to send the CAPABILITY command in a separate goroutine: // setCaps might be called with Client.encMutex locked go func() { c.Capability().Wait() - close(capCh) }() } c.mutex.Lock() c.caps = caps - c.pendingCapCh = capCh c.mutex.Unlock() } From 943e8f6e873bd73ab20c505957cd0b730882a3b7 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 21 Sep 2025 20:35:26 +0200 Subject: [PATCH 22/29] feat(condstore): add CONDSTORE to ENABLE --- imapclient/enable.go | 2 +- imapserver/enable.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/imapclient/enable.go b/imapclient/enable.go index 25062d22..20aaa0c9 100644 --- a/imapclient/enable.go +++ b/imapclient/enable.go @@ -14,7 +14,7 @@ func (c *Client) Enable(caps ...imap.Cap) *EnableCommand { // extensions we support here for _, name := range caps { switch name { - case imap.CapIMAP4rev2, imap.CapUTF8Accept, imap.CapMetadata, imap.CapMetadataServer, imap.CapQResync: + case imap.CapIMAP4rev2, imap.CapUTF8Accept, imap.CapMetadata, imap.CapMetadataServer, imap.CapQResync, imap.CapCondStore: // ok default: done := make(chan error) diff --git a/imapserver/enable.go b/imapserver/enable.go index 0e0bb5b9..4d590acb 100644 --- a/imapserver/enable.go +++ b/imapserver/enable.go @@ -26,7 +26,7 @@ func (c *Conn) handleEnable(dec *imapwire.Decoder) error { var enabled []imap.Cap for _, req := range requested { switch req { - case imap.CapIMAP4rev2, imap.CapUTF8Accept, imap.CapQResync: + case imap.CapIMAP4rev2, imap.CapUTF8Accept, imap.CapQResync, imap.CapCondStore: enabled = append(enabled, req) } } From d1d721c14299775ebb72ca3c1636a4a4a7c05639 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 21 Sep 2025 20:41:53 +0200 Subject: [PATCH 23/29] enable condstore in client tests --- imapclient/client_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/imapclient/client_test.go b/imapclient/client_test.go index 0eb978b0..e27e0255 100644 --- a/imapclient/client_test.go +++ b/imapclient/client_test.go @@ -180,6 +180,15 @@ func newClientServerPair(t *testing.T, initialState imap.ConnState) (*imapclient } } + // Enable CONDSTORE for Dovecot tests (required for CONDSTORE features) + if useDovecot && initialState >= imap.ConnStateAuthenticated { + if client.Caps().Has(imap.CapCondStore) { + if _, err := client.Enable(imap.CapCondStore).Wait(); err != nil { + t.Logf("Failed to enable CONDSTORE: %v", err) + } + } + } + // Turn on debug logs after we're done initializing the test debugWriter.Swap(os.Stderr) From cf33794a4fd4408d6f7bcfc7ef598408b450d7bc Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 21 Sep 2025 20:53:40 +0200 Subject: [PATCH 24/29] CHANGEDSINCE is only in Fetch, not Search according to fc7162 --- imapclient/condstore_test.go | 126 ---------------------------- imapclient/search.go | 3 - imapserver/imapmemserver/message.go | 3 - imapserver/search.go | 6 -- search.go | 7 +- 5 files changed, 1 insertion(+), 144 deletions(-) diff --git a/imapclient/condstore_test.go b/imapclient/condstore_test.go index 54e2574a..e1815a1e 100644 --- a/imapclient/condstore_test.go +++ b/imapclient/condstore_test.go @@ -310,131 +310,5 @@ func TestCapability_CondStore(t *testing.T) { } } -func TestSearch_ChangedSince(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateSelected) - defer client.Close() - defer server.Close() - // First, get current ModSeq for our message - seqSet := imap.SeqSetNum(1) - firstFetch, err := client.Fetch(seqSet, &imap.FetchOptions{ - ModSeq: true, - }).Collect() - if err != nil { - t.Fatalf("Initial Fetch() = %v", err) - } - currentModSeq := firstFetch[0].ModSeq - t.Logf("Initial ModSeq: %d", currentModSeq) - - // Search with CHANGEDSINCE using a value lower than current - // This should find the message - searchCriteria := &imap.SearchCriteria{ - ChangedSince: currentModSeq - 1, - } - searchOptions := &imap.SearchOptions{ - ReturnCount: true, - } - results, err := client.Search(searchCriteria, searchOptions).Wait() - if err != nil { - t.Fatalf("Search with CHANGEDSINCE = %v", err) - } - - // There should be one message that matches - if results.Count != 1 { - t.Errorf("Search with CHANGEDSINCE < current returned %d messages, want 1", results.Count) - } - - // Search with CHANGEDSINCE using current value - // This should NOT find the message (since CHANGEDSINCE criterion is > not >=) - searchCriteria = &imap.SearchCriteria{ - ChangedSince: currentModSeq, - } - results, err = client.Search(searchCriteria, searchOptions).Wait() - if err != nil { - t.Fatalf("Search with CHANGEDSINCE = %v", err) - } - - // There should be no messages that match - if results.Count != 0 { - t.Errorf("Search with CHANGEDSINCE = current returned %d messages, want 0", results.Count) - } - - // Search with CHANGEDSINCE using a higher value - // This should NOT find the message - searchCriteria = &imap.SearchCriteria{ - ChangedSince: currentModSeq + 1, - } - results, err = client.Search(searchCriteria, searchOptions).Wait() - if err != nil { - t.Fatalf("Search with CHANGEDSINCE = %v", err) - } - // There should be no messages that match - if results.Count != 0 { - t.Errorf("Search with CHANGEDSINCE > current returned %d messages, want 0", results.Count) - } -} - -func TestUIDSearch_ChangedSince(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateSelected) - defer client.Close() - defer server.Close() - - // First, get current ModSeq for our message - seqSet := imap.SeqSetNum(1) - firstFetch, err := client.Fetch(seqSet, &imap.FetchOptions{ - ModSeq: true, - UID: true, - }).Collect() - if err != nil { - t.Fatalf("Initial Fetch() = %v", err) - } - currentModSeq := firstFetch[0].ModSeq - expectedUID := firstFetch[0].UID - t.Logf("Initial ModSeq: %d, UID: %d", currentModSeq, expectedUID) - - // UID Search with CHANGEDSINCE - searchCriteria := &imap.SearchCriteria{ - ChangedSince: currentModSeq - 1, - } - searchOptions := &imap.SearchOptions{ - ReturnAll: true, - } - results, err := client.UIDSearch(searchCriteria, searchOptions).Wait() - if err != nil { - t.Fatalf("UID Search with CHANGEDSINCE = %v", err) - } - - // Check that we got the expected UID - uids := results.AllUIDs() - if len(uids) != 1 { - t.Errorf("UID Search with CHANGEDSINCE returned %d UIDs, want 1", len(uids)) - } else if uids[0] != expectedUID { - t.Errorf("UID Search with CHANGEDSINCE returned UID %d, want %d", uids[0], expectedUID) - } -} - -func TestSearch_ChangedSince_Combined(t *testing.T) { - client, server := newClientServerPair(t, imap.ConnStateSelected) - defer client.Close() - defer server.Close() - - // Test CHANGEDSINCE combined with other search criteria - searchCriteria := &imap.SearchCriteria{ - ChangedSince: 1, // Use a low modseq to match existing messages - Flag: []imap.Flag{}, // Search for any flags (no specific flag required) - } - searchOptions := &imap.SearchOptions{ - ReturnCount: true, - } - results, err := client.Search(searchCriteria, searchOptions).Wait() - if err != nil { - t.Fatalf("Combined Search with CHANGEDSINCE = %v", err) - } - - t.Logf("Combined search returned %d messages", results.Count) - // We expect at least one message in the test mailbox - if results.Count == 0 { - t.Errorf("Combined search with CHANGEDSINCE should find at least one message") - } -} diff --git a/imapclient/search.go b/imapclient/search.go index a8a04a7f..9fe74cf1 100644 --- a/imapclient/search.go +++ b/imapclient/search.go @@ -246,9 +246,6 @@ func writeSearchKey(enc *imapwire.Encoder, criteria *imap.SearchCriteria, condst } } - if criteria.ChangedSince > 0 && condstore { - encodeItem().Atom("CHANGEDSINCE").SP().ModSeq(criteria.ChangedSince) - } for _, not := range criteria.Not { encodeItem().Atom("NOT").SP() diff --git a/imapserver/imapmemserver/message.go b/imapserver/imapmemserver/message.go index b55064f6..bbb26f68 100644 --- a/imapserver/imapmemserver/message.go +++ b/imapserver/imapmemserver/message.go @@ -173,9 +173,6 @@ func (msg *message) search(seqNum uint32, criteria *imap.SearchCriteria) bool { if criteria.ModSeq != nil && msg.modSeq < criteria.ModSeq.ModSeq { return false } - if criteria.ChangedSince > 0 && msg.modSeq <= criteria.ChangedSince { - return false - } for _, flag := range criteria.Flag { if _, ok := msg.flags[canonicalFlag(flag)]; !ok { return false diff --git a/imapserver/search.go b/imapserver/search.go index dde0d812..670db0c2 100644 --- a/imapserver/search.go +++ b/imapserver/search.go @@ -358,12 +358,6 @@ func readSearchKeyWithAtom(criteria *imap.SearchCriteria, dec *imapwire.Decoder, MetadataName: name, MetadataType: metadataType, } - case "CHANGEDSINCE": - var modSeq uint64 - if !dec.ExpectSP() || !dec.ExpectModSeq(&modSeq) { - return dec.Err() - } - criteria.ChangedSince = modSeq default: seqSet, err := imapwire.ParseSeqSet(key) if err != nil { diff --git a/search.go b/search.go index b763bda1..a94f52f3 100644 --- a/search.go +++ b/search.go @@ -58,8 +58,7 @@ type SearchCriteria struct { Not []SearchCriteria Or [][2]SearchCriteria - ModSeq *SearchCriteriaModSeq // requires CONDSTORE - ChangedSince uint64 // requires CONDSTORE + ModSeq *SearchCriteriaModSeq // requires CONDSTORE } // And intersects two search criteria. @@ -88,10 +87,6 @@ func (criteria *SearchCriteria) And(other *SearchCriteria) { criteria.Not = append(criteria.Not, other.Not...) criteria.Or = append(criteria.Or, other.Or...) - - if criteria.ChangedSince == 0 || other.ChangedSince > criteria.ChangedSince { - criteria.ChangedSince = other.ChangedSince - } } func intersectSince(t1, t2 time.Time) time.Time { From 55b3fb2458158da9025bc78ab20fa7d35c97bb48 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 21 Sep 2025 20:56:59 +0200 Subject: [PATCH 25/29] goftm --- imapclient/condstore_test.go | 3 --- imapclient/qresync_test.go | 22 +++++++++++----------- imapclient/search.go | 1 - imapclient/select.go | 4 ++-- imapserver/vanished.go | 2 +- 5 files changed, 14 insertions(+), 18 deletions(-) diff --git a/imapclient/condstore_test.go b/imapclient/condstore_test.go index e1815a1e..54b9aab4 100644 --- a/imapclient/condstore_test.go +++ b/imapclient/condstore_test.go @@ -309,6 +309,3 @@ func TestCapability_CondStore(t *testing.T) { t.Logf("CONDSTORE capability correctly announced after authentication") } } - - - diff --git a/imapclient/qresync_test.go b/imapclient/qresync_test.go index 057979cb..4d99e2bc 100644 --- a/imapclient/qresync_test.go +++ b/imapclient/qresync_test.go @@ -39,7 +39,7 @@ func TestSelect_QResync(t *testing.T) { if err != nil { t.Fatalf("First Select() = %v", err) } - t.Logf("Initial SELECT - UIDValidity: %d, HighestModSeq: %d", + t.Logf("Initial SELECT - UIDValidity: %d, HighestModSeq: %d", firstSelect.UIDValidity, firstSelect.HighestModSeq) // Unselect to test QRESYNC SELECT @@ -54,7 +54,7 @@ func TestSelect_QResync(t *testing.T) { ModSeq: firstSelect.HighestModSeq, }, } - + secondSelect, err := client.Select("INBOX", qresyncOptions).Wait() if err != nil { t.Fatalf("QRESYNC Select() = %v", err) @@ -93,7 +93,7 @@ func TestSelect_QResync_WithKnownUIDs(t *testing.T) { if err != nil { t.Fatalf("Fetch UIDs = %v", err) } - + var knownUIDs imap.UIDSet if len(messages) > 0 { knownUIDs = imap.UIDSetNum(messages[0].UID) @@ -112,12 +112,12 @@ func TestSelect_QResync_WithKnownUIDs(t *testing.T) { KnownUIDs: knownUIDs, }, } - + _, err = client.Select("INBOX", qresyncOptions).Wait() if err != nil { t.Fatalf("QRESYNC Select() with known UIDs = %v", err) } - + t.Logf("QRESYNC SELECT with known UIDs successful") } @@ -138,14 +138,14 @@ func TestUIDFetch_Vanished(t *testing.T) { ChangedSince: 1, // Use a low modseq to potentially get some results Vanished: true, } - + uidSet := imap.UIDSetNum(1) uidSet.AddRange(1, 0) // 1:* messages, err := client.Fetch(uidSet, fetchOptions).Collect() if err != nil { t.Fatalf("UID FETCH with VANISHED = %v", err) } - + t.Logf("UID FETCH with VANISHED returned %d messages", len(messages)) } @@ -164,10 +164,10 @@ func TestVanished_Response(t *testing.T) { // a VANISHED response. For now, we just verify QRESYNC is enabled. // The VANISHED responses would be handled by the UnilateralDataHandler // which can be set when creating the client. - + // This test just verifies that QRESYNC is properly enabled // and the client can handle the expected protocol - + t.Logf("VANISHED response handler test completed") } @@ -200,6 +200,6 @@ func TestCapability_QResync_Implications(t *testing.T) { t.Errorf("QRESYNC should imply CONDSTORE capability") } } - + t.Logf("QRESYNC capability implications verified") -} \ No newline at end of file +} diff --git a/imapclient/search.go b/imapclient/search.go index 9fe74cf1..476796a7 100644 --- a/imapclient/search.go +++ b/imapclient/search.go @@ -246,7 +246,6 @@ func writeSearchKey(enc *imapwire.Encoder, criteria *imap.SearchCriteria, condst } } - for _, not := range criteria.Not { encodeItem().Atom("NOT").SP() enc.Special('(') diff --git a/imapclient/select.go b/imapclient/select.go index c4b824a4..8a305452 100644 --- a/imapclient/select.go +++ b/imapclient/select.go @@ -17,7 +17,7 @@ func (c *Client) Select(mailbox string, options *imap.SelectOptions) *SelectComm cmd := &SelectCommand{mailbox: mailbox} enc := c.beginCommand(cmdName, cmd) enc.SP().Mailbox(mailbox) - + if options != nil { if options.CondStore { enc.SP().Special('(').Atom("CONDSTORE").Special(')') @@ -37,7 +37,7 @@ func (c *Client) Select(mailbox string, options *imap.SelectOptions) *SelectComm enc.Special(')').Special(')') } } - + enc.end() return cmd } diff --git a/imapserver/vanished.go b/imapserver/vanished.go index efe408d5..c7b5f92b 100644 --- a/imapserver/vanished.go +++ b/imapserver/vanished.go @@ -29,4 +29,4 @@ func (c *Conn) writeVanished(uids imap.UIDSet, earlier bool) error { } enc.SP().NumSet(uids) return enc.CRLF() -} \ No newline at end of file +} From a79d4e52df90e83de2f72327694f14ad1e206dbe Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 21 Sep 2025 21:06:15 +0200 Subject: [PATCH 26/29] permanentflags => flags --- imapclient/select.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/imapclient/select.go b/imapclient/select.go index 90cd1154..c325ff04 100644 --- a/imapclient/select.go +++ b/imapclient/select.go @@ -51,7 +51,7 @@ func (c *Client) handleFlags() error { c.mutex.Lock() if c.state == imap.ConnStateSelected { c.mailbox = c.mailbox.copy() - c.mailbox.PermanentFlags = flags + c.mailbox.Flags = flags } c.mutex.Unlock() From 8a3cc0ae8722b18c4167f6205dd2ea5547af5248 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 21 Sep 2025 21:47:38 +0200 Subject: [PATCH 27/29] Allow empty lines / commands without breaking connection --- imapserver/conn.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/imapserver/conn.go b/imapserver/conn.go index 291f37ec..3e795432 100644 --- a/imapserver/conn.go +++ b/imapserver/conn.go @@ -194,6 +194,17 @@ func (c *Conn) serve() { } func (c *Conn) readCommand(dec *imapwire.Decoder) error { + for { + if dec.EOF() { + return nil + } + + if dec.ExpectCRLF() { + continue + } + break + } + var tag, name string if !dec.ExpectAtom(&tag) || !dec.ExpectSP() || !dec.ExpectAtom(&name) { return fmt.Errorf("in command: %w", dec.Err()) From 4d6ceb6214ae4bf06a726ae1ee1e17936ad81445 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sun, 21 Sep 2025 22:10:00 +0200 Subject: [PATCH 28/29] Consistent and context aware utf handling --- imapclient/client.go | 2 ++ imapclient/enable.go | 3 +++ imapserver/conn.go | 15 ++++++++++++++- imapserver/list.go | 7 ++++++- internal/imapwire/decoder.go | 11 ++++++++++- internal/utf7/decoder.go | 31 +++++++++++++++++++++++++++++++ 6 files changed, 66 insertions(+), 3 deletions(-) diff --git a/imapclient/client.go b/imapclient/client.go index c8682a2d..b4c7af54 100644 --- a/imapclient/client.go +++ b/imapclient/client.go @@ -354,6 +354,8 @@ func (c *Client) setCaps(caps imap.CapSet) { c.mutex.Lock() c.caps = caps c.pendingCapCh = capCh + quotedUTF8 := c.caps.Has(imap.CapIMAP4rev2) || c.enabled.Has(imap.CapUTF8Accept) + c.dec.QuotedUTF8 = quotedUTF8 c.mutex.Unlock() } diff --git a/imapclient/enable.go b/imapclient/enable.go index 89576664..717d374e 100644 --- a/imapclient/enable.go +++ b/imapclient/enable.go @@ -43,6 +43,9 @@ func (c *Client) handleEnabled() error { for name := range caps { c.enabled[name] = struct{}{} } + + quotedUTF8 := c.caps.Has(imap.CapIMAP4rev2) || c.enabled.Has(imap.CapUTF8Accept) + c.dec.QuotedUTF8 = quotedUTF8 c.mutex.Unlock() if cmd := findPendingCmdByType[*EnableCommand](c); cmd != nil { diff --git a/imapserver/conn.go b/imapserver/conn.go index 291f37ec..5a982a9c 100644 --- a/imapserver/conn.go +++ b/imapserver/conn.go @@ -178,6 +178,15 @@ func (c *Conn) serve() { dec := imapwire.NewDecoder(c.br, imapwire.ConnSideServer) dec.MaxSize = maxCommandSize dec.CheckBufferedLiteralFunc = c.checkBufferedLiteral + + c.mutex.Lock() + // IMAP4rev2 is automatically enabled when advertised in capabilities + // UTF8=ACCEPT must be explicitly enabled + imap4rev2Available := c.server.options.caps().Has(imap.CapIMAP4rev2) + utf8AcceptEnabled := c.enabled.Has(imap.CapUTF8Accept) + quotedUTF8 := imap4rev2Available || utf8AcceptEnabled + c.mutex.Unlock() + dec.QuotedUTF8 = quotedUTF8 if c.state == imap.ConnStateLogout || dec.EOF() { break @@ -492,7 +501,11 @@ type responseEncoder struct { func newResponseEncoder(conn *Conn) *responseEncoder { conn.mutex.Lock() - quotedUTF8 := conn.enabled.Has(imap.CapIMAP4rev2) || conn.enabled.Has(imap.CapUTF8Accept) + // IMAP4rev2 is automatically enabled when advertised in capabilities + // UTF8=ACCEPT must be explicitly enabled + imap4rev2Available := conn.server.options.caps().Has(imap.CapIMAP4rev2) + utf8AcceptEnabled := conn.enabled.Has(imap.CapUTF8Accept) + quotedUTF8 := imap4rev2Available || utf8AcceptEnabled conn.mutex.Unlock() wireEnc := imapwire.NewEncoder(conn.bw, imapwire.ConnSideServer) diff --git a/imapserver/list.go b/imapserver/list.go index 1aab73d7..c25fc4e4 100644 --- a/imapserver/list.go +++ b/imapserver/list.go @@ -206,7 +206,12 @@ func readListMailbox(dec *imapwire.Decoder) (string, error) { return "", dec.Err() } } - return utf7.Decode(mailbox) + + if dec.QuotedUTF8 { + return utf7.Unescape(mailbox) + } else { + return utf7.Decode(mailbox) + } } func isListChar(ch byte) bool { diff --git a/internal/imapwire/decoder.go b/internal/imapwire/decoder.go index cfd2995c..23f5bae1 100644 --- a/internal/imapwire/decoder.go +++ b/internal/imapwire/decoder.go @@ -55,6 +55,9 @@ type Decoder struct { // MaxSize defines a maximum number of bytes to be read from the input. // Literals are ignored. MaxSize int64 + // QuotedUTF8 allows raw UTF-8 in quoted strings. This requires IMAP4rev2 + // to be available, or UTF8=ACCEPT to be enabled. + QuotedUTF8 bool r *bufio.Reader side ConnSide @@ -517,7 +520,13 @@ func (dec *Decoder) ExpectMailbox(ptr *string) bool { *ptr = "INBOX" return true } - name, err := utf7.Decode(name) + + var err error + if dec.QuotedUTF8 { + name, err = utf7.Unescape(name) + } else { + name, err = utf7.Decode(name) + } if err == nil { *ptr = name } diff --git a/internal/utf7/decoder.go b/internal/utf7/decoder.go index b8e906e4..205dd14e 100644 --- a/internal/utf7/decoder.go +++ b/internal/utf7/decoder.go @@ -116,3 +116,34 @@ func decode(b64 []byte) []byte { } return s[:j] } + +// Unescape passes through raw UTF-8 as-is and unescapes the special UTF-7 marker +// (the "&-" sequence back to "&"). +func Unescape(src string) (string, error) { + if !utf8.ValidString(src) { + return "", errors.New("invalid UTF-8") + } + + var sb strings.Builder + sb.Grow(len(src)) + + for i := 0; i < len(src); i++ { + ch := src[i] + + if ch != '&' { + sb.WriteByte(ch) + continue + } + + // Check if this is an escape sequence "&-" + if i+1 < len(src) && src[i+1] == '-' { + sb.WriteByte('&') + i++ // Skip the '-' + } else { + // This is not an escape sequence, keep the '&' as-is + sb.WriteByte(ch) + } + } + + return sb.String(), nil +} From f78e9738443623cb1396373fa15cb81b495d7e3b Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Mon, 22 Sep 2025 10:13:47 +0200 Subject: [PATCH 29/29] Go fmt --- imapserver/conn.go | 2 +- imapserver/list.go | 2 +- internal/imapwire/decoder.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/imapserver/conn.go b/imapserver/conn.go index 5a982a9c..d6c8e8f8 100644 --- a/imapserver/conn.go +++ b/imapserver/conn.go @@ -178,7 +178,7 @@ func (c *Conn) serve() { dec := imapwire.NewDecoder(c.br, imapwire.ConnSideServer) dec.MaxSize = maxCommandSize dec.CheckBufferedLiteralFunc = c.checkBufferedLiteral - + c.mutex.Lock() // IMAP4rev2 is automatically enabled when advertised in capabilities // UTF8=ACCEPT must be explicitly enabled diff --git a/imapserver/list.go b/imapserver/list.go index c25fc4e4..3fe73f93 100644 --- a/imapserver/list.go +++ b/imapserver/list.go @@ -206,7 +206,7 @@ func readListMailbox(dec *imapwire.Decoder) (string, error) { return "", dec.Err() } } - + if dec.QuotedUTF8 { return utf7.Unescape(mailbox) } else { diff --git a/internal/imapwire/decoder.go b/internal/imapwire/decoder.go index 23f5bae1..24afd56b 100644 --- a/internal/imapwire/decoder.go +++ b/internal/imapwire/decoder.go @@ -520,7 +520,7 @@ func (dec *Decoder) ExpectMailbox(ptr *string) bool { *ptr = "INBOX" return true } - + var err error if dec.QuotedUTF8 { name, err = utf7.Unescape(name)