From 0a7f90ba5aadec68b10651a99f8588bd6f5a342d Mon Sep 17 00:00:00 2001 From: Hugo Osvaldo Barrera Date: Sun, 12 Oct 2025 03:37:09 +0200 Subject: [PATCH] imapclient: initial VANISHED support --- fetch.go | 1 + imapclient/client.go | 12 ++++++++++++ imapclient/enable.go | 2 +- imapclient/fetch.go | 6 +++++- imapclient/select.go | 21 +++++++++++++++++++-- imapclient/vanished.go | 34 ++++++++++++++++++++++++++++++++++ select.go | 21 +++++++++++++++++++++ 7 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 imapclient/vanished.go diff --git a/fetch.go b/fetch.go index f146c897..405f6236 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 for UID FETCH with ChangedSince } // FetchItemBodyStructure contains FETCH options for the body structure. diff --git a/imapclient/client.go b/imapclient/client.go index 620bce36..5eba94f8 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": @@ -1187,6 +1192,13 @@ type UnilateralDataHandler struct { // requires ENABLE METADATA or ENABLE SERVER-METADATA Metadata func(mailbox string, entries []string) + + // Called when the server sends an untagged VANISHED response. + // + // Requires QRESYNC extension (RFC 4551/7162). The parameter earlier + // indicates whether this response covers earlier expunges (true for + // SELECT QRESYNC responses, false for UID FETCH VANISHED responses). + Vanished func(uids imap.UIDSet, earlier bool) } // command is an interface for IMAP commands. diff --git a/imapclient/enable.go b/imapclient/enable.go index 89576664..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: + case imap.CapIMAP4rev2, imap.CapUTF8Accept, imap.CapMetadata, imap.CapMetadataServer, imap.CapQResync, imap.CapCondStore: // ok default: done := make(chan error) diff --git a/imapclient/fetch.go b/imapclient/fetch.go index f60256fc..82a67d0f 100644 --- a/imapclient/fetch.go +++ b/imapclient/fetch.go @@ -34,7 +34,11 @@ func (c *Client) Fetch(numSet imap.NumSet, options *imap.FetchOptions) *FetchCom enc.SP().NumSet(numSet).SP() writeFetchItems(enc.Encoder, numKind, options) if options.ChangedSince != 0 { - enc.SP().Special('(').Atom("CHANGEDSINCE").SP().ModSeq(options.ChangedSince).Special(')') + enc.SP().Special('(').Atom("CHANGEDSINCE").SP().ModSeq(options.ChangedSince) + if options.Vanished { + enc.SP().Atom("VANISHED") + } + enc.Special(')') } enc.end() return cmd diff --git a/imapclient/select.go b/imapclient/select.go index c325ff04..1b18b9de 100644 --- a/imapclient/select.go +++ b/imapclient/select.go @@ -17,8 +17,25 @@ 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.QResync != nil { + // QRESYNC implies CONDSTORE + enc.SP().Special('(').Atom("QRESYNC").SP() + enc.Special('(') + enc.Number(options.QResync.UIDValidity).SP().ModSeq(options.QResync.ModSeq) + if options.QResync.KnownUIDs != nil { + enc.SP().NumSet(*options.QResync.KnownUIDs) + if options.QResync.SeqMatchData != nil { + enc.SP().Special('(') + enc.NumSet(options.QResync.SeqMatchData.KnownSeqSet).SP() + enc.NumSet(options.QResync.SeqMatchData.KnownUIDSet) + enc.Special(')') + } + } + enc.Special(')').Special(')') + } else if options.CondStore { + enc.SP().Special('(').Atom("CONDSTORE").Special(')') + } } enc.end() return cmd diff --git a/imapclient/vanished.go b/imapclient/vanished.go new file mode 100644 index 00000000..e5e61817 --- /dev/null +++ b/imapclient/vanished.go @@ -0,0 +1,34 @@ +package imapclient + +import ( + "github.com/emersion/go-imap/v2" +) + +func (c *Client) handleVanished() error { + var earlier bool + if c.dec.Special('(') { + var atom string + if !c.dec.ExpectAtom(&atom) || atom != "EARLIER" || !c.dec.ExpectSpecial(')') { + return c.dec.Err() + } + earlier = true + if !c.dec.ExpectSP() { + return c.dec.Err() + } + } + + var uids imap.UIDSet + if !c.dec.ExpectUIDSet(&uids) { + return c.dec.Err() + } + + // Check if this is part of a SELECT command response + cmd := findPendingCmdByType[*SelectCommand](c) + if cmd != nil { + cmd.data.VanishedUIDs = uids + } else if handler := c.options.unilateralDataHandler().Vanished; handler != nil { + handler(uids, earlier) + } + + return nil +} diff --git a/select.go b/select.go index f307ff34..c0b9871c 100644 --- a/select.go +++ b/select.go @@ -4,6 +4,23 @@ package imap type SelectOptions struct { ReadOnly bool CondStore bool // requires CONDSTORE + + // QRESYNC parameters (requires QRESYNC extension, RFC 5162) + QResync *SelectQResyncOptions +} + +// SelectQResyncOptions contains QRESYNC parameters for SELECT. +type SelectQResyncOptions struct { + UIDValidity uint32 + ModSeq uint64 + KnownUIDs *UIDSet // optional + SeqMatchData *SelectSeqMatchData // optional +} + +// SelectSeqMatchData contains sequence match data for QRESYNC. +type SelectSeqMatchData struct { + KnownSeqSet SeqSet + KnownUIDSet UIDSet } // SelectData is the data returned by a SELECT command. @@ -28,4 +45,8 @@ type SelectData struct { List *ListData // requires IMAP4rev2 HighestModSeq uint64 // requires CONDSTORE + + // UIDs of messages that were expunged. + // Requires QRESYNC extension (RFC 4551/7162). + VanishedUIDs UIDSet // requires QRESYNC }