Merge pull request #858 from KarthikeyaKollu/fix/zoho-modseq-search

fix: ask Zoho for MODSEQ searches as ESEARCH so an empty reply no longer drops the IMAP session
This commit is contained in:
Matthew Meszaros
2026-10-07 07:42:42 +00:00
committed by GitHub
2 changed files with 55 additions and 1 deletions
+8 -1
View File
@@ -602,7 +602,14 @@ func (c *Client) uidSearch(criteria *imap.SearchCriteria) ([]imap.UID, *errx.Mai
if err := c.resumeSyncLocked(); err != nil {
return nil, c.handleError(err)
}
data, err := c.client.UIDSearch(criteria, nil).Wait()
// Zoho answers a MODSEQ search that matches nothing with
// "* SEARCH (MODSEQ n)", which go-imap cannot parse, so the session drops
// on every quiet pass. Its ESEARCH answer to the same query is well formed.
var opts *imap.SearchOptions
if criteria.ModSeq != nil && c.client.Caps().Has(imap.CapESearch) {
opts = &imap.SearchOptions{ReturnAll: true}
}
data, err := c.client.UIDSearch(criteria, opts).Wait()
if err != nil {
return nil, c.handleError(err)
}
@@ -0,0 +1,47 @@
package imap
import (
"strings"
"testing"
"github.com/emersion/go-imap/v2"
)
// zohoModSeqSearch answers a CONDSTORE search the way imappro.zoho.in does
// when nothing changed: the plain form carries a double space go-imap cannot
// parse, the ESEARCH form is well formed.
func zohoModSeqSearch(line string) string {
fields := strings.Fields(line)
if len(fields) < 2 || !strings.Contains(strings.ToUpper(line), "SEARCH") || !strings.Contains(strings.ToUpper(line), "MODSEQ") {
return ""
}
tag := fields[0]
if strings.Contains(strings.ToUpper(line), "RETURN") {
return `* ESEARCH (TAG "` + tag + `") UID MODSEQ 1791314919556115300` + "\r\n" + tag + " OK Success"
}
return "* SEARCH (MODSEQ 1791314919556115300)\r\n" + tag + " OK Success"
}
// Zoho dropped every SMTP/IMAP mailbox with "server unreachable" on the first
// quiet sync pass: the empty MODSEQ search answer killed the session.
func TestSearchChangedSinceOnZohoEmptyResult(t *testing.T) {
caps := imap.CapSet{imap.CapIMAP4rev1: {}, imap.CapESearch: {}, imap.CapCondStore: {}}
c, wire := interceptingServer(t, caps, zohoModSeqSearch)
if err := c.Connect(); err != nil {
t.Fatalf("Connect: %v", err)
}
if _, err := c.SelectForSync("INBOX"); err != nil {
t.Fatalf("SelectForSync: %v", err)
}
uids, err := c.SearchChangedSince(1791314919556115300)
if err != nil {
t.Fatalf("SearchChangedSince: %v", err)
}
if len(uids) != 0 {
t.Fatalf("uids = %v, want none", uids)
}
searches := wire.commands("SEARCH")
if len(searches) != 1 || !strings.Contains(searches[0], "RETURN (ALL)") {
t.Fatalf("modseq search did not use ESEARCH: %v", searches)
}
}