From 6464ce86f86fdd8ae0f9a3fc80885eb31b1cd835 Mon Sep 17 00:00:00 2001 From: KarthikeyaKollu Date: Wed, 7 Oct 2026 01:28:51 +0530 Subject: [PATCH] fix: ask Zoho for MODSEQ searches as ESEARCH so an empty reply no longer drops the IMAP session (#857) Zoho answers an empty UID SEARCH MODSEQ with "* SEARCH (MODSEQ n)", which go-imap cannot parse; the session dropped on every quiet pass and the mailbox showed SERVER_UNREACHABLE. Its ESEARCH answer to the same query is well formed. Co-Authored-By: Claude Opus 5.5 --- internal/client/smtpimap/imap/client.go | 9 +++- .../smtpimap/imap/search_modseq_test.go | 47 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 internal/client/smtpimap/imap/search_modseq_test.go diff --git a/internal/client/smtpimap/imap/client.go b/internal/client/smtpimap/imap/client.go index 078777d73..f40fdd755 100644 --- a/internal/client/smtpimap/imap/client.go +++ b/internal/client/smtpimap/imap/client.go @@ -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) } diff --git a/internal/client/smtpimap/imap/search_modseq_test.go b/internal/client/smtpimap/imap/search_modseq_test.go new file mode 100644 index 000000000..9292ed60a --- /dev/null +++ b/internal/client/smtpimap/imap/search_modseq_test.go @@ -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) + } +}