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/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/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/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/client.go b/imapclient/client.go index 620bce36..48cad62a 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,35 @@ 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 + quotedUTF8 := c.caps.Has(imap.CapIMAP4rev2) || c.enabled.Has(imap.CapUTF8Accept) + c.dec.QuotedUTF8 = quotedUTF8 c.mutex.Unlock() } @@ -973,6 +949,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 +994,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 +1188,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/client_test.go b/imapclient/client_test.go index 9e5c206f..e27e0255 100644 --- a/imapclient/client_test.go +++ b/imapclient/client_test.go @@ -102,6 +102,8 @@ func newMemClientServerPair(t *testing.T) (net.Conn, io.Closer) { Caps: imap.CapSet{ imap.CapIMAP4rev1: {}, imap.CapIMAP4rev2: {}, + imap.CapCondStore: {}, + imap.CapQResync: {}, }, }) @@ -178,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) 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/imapclient/enable.go b/imapclient/enable.go index 89576664..36eeb9ed 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) @@ -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/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/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/imapclient/qresync_test.go b/imapclient/qresync_test.go new file mode 100644 index 00000000..4d99e2bc --- /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") +} diff --git a/imapclient/search.go b/imapclient/search.go index 17ac1161..476796a7 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)) @@ -249,17 +249,17 @@ func writeSearchKey(enc *imapwire.Encoder, criteria *imap.SearchCriteria) { 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(')') } @@ -340,12 +340,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 +348,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 c325ff04..3bdb1b80 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/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 } diff --git a/imapserver/capability.go b/imapserver/capability.go index 37da104b..c0283e42 100644 --- a/imapserver/capability.go +++ b/imapserver/capability.go @@ -93,6 +93,12 @@ func (c *Conn) availableCaps() []imap.Cap { imap.CapCreateSpecialUse, imap.CapLiteralPlus, imap.CapUnauthenticate, + imap.CapCondStore, + imap.CapQResync, + imap.CapSort, + imap.CapSortDisplay, + imap.CapESort, + imap.CapID, }) if appendLimitSession, ok := c.session.(SessionAppendLimit); ok { diff --git a/imapserver/conn.go b/imapserver/conn.go index 291f37ec..b80b4342 100644 --- a/imapserver/conn.go +++ b/imapserver/conn.go @@ -179,6 +179,15 @@ func (c *Conn) serve() { 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 } @@ -194,6 +203,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()) @@ -220,6 +240,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 @@ -276,6 +299,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 @@ -492,7 +517,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) @@ -574,6 +603,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..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: + case imap.CapIMAP4rev2, imap.CapUTF8Accept, imap.CapQResync, imap.CapCondStore: enabled = append(enabled, req) } } diff --git a/imapserver/fetch.go b/imapserver/fetch.go index e97e1f47..d2a99049 100644 --- a/imapserver/fetch.go +++ b/imapserver/fetch.go @@ -75,6 +75,40 @@ 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() + } + } + + // 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() } @@ -108,6 +142,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 @@ -460,6 +496,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/id.go b/imapserver/id.go new file mode 100644 index 00000000..5997b774 --- /dev/null +++ b/imapserver/id.go @@ -0,0 +1,173 @@ +package imapserver + +import ( + "fmt" + "strings" + + "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) + 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) + } + 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(')') + } + + err = enc.CRLF() + enc.end() + if 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{ + Raw: make(map[string]string), + } + 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 + } + + lowerKey := strings.ToLower(currKey) + data.Raw[lowerKey] = keyOrValue + + switch lowerKey { + 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: + // Unknown key, already stored in Raw + } + 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 +} diff --git a/imapserver/imapmemserver/mailbox.go b/imapserver/imapmemserver/mailbox.go index dee9a67b..812a2feb 100644 --- a/imapserver/imapmemserver/mailbox.go +++ b/imapserver/imapmemserver/mailbox.go @@ -18,21 +18,29 @@ 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 + expunged []expungedMessage +} + +type expungedMessage struct { + uid imap.UID + modSeq 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,6 +102,9 @@ func (mbox *Mailbox) statusDataLocked(options *imap.StatusOptions) *imap.StatusD size := mbox.sizeLocked() data.Size = &size } + if options.HighestModSeq { + data.HighestModSeq = mbox.highestModSeq + } if options.NumRecent { num := uint32(0) data.NumRecent = &num @@ -156,6 +167,9 @@ func (mbox *Mailbox) appendBytes(buf []byte, options *imap.AppendOptions) *imap. msg.uid = mbox.uidNext mbox.uidNext++ + mbox.highestModSeq++ + msg.modSeq = mbox.highestModSeq + mbox.l = append(mbox.l, msg) mbox.tracker.QueueNumMessages(uint32(len(mbox.l))) @@ -196,6 +210,7 @@ func (mbox *Mailbox) selectDataLocked() *imap.SelectData { FirstUnseenSeqNum: firstUnseenSeqNum, UIDNext: mbox.uidNext, UIDValidity: mbox.uidValidity, + HighestModSeq: mbox.highestModSeq, } } @@ -254,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 } @@ -309,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 { @@ -324,6 +369,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) @@ -383,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 } @@ -417,14 +467,50 @@ func (mbox *MailboxView) staticSearchCriteria(criteria *imap.SearchCriteria) { } } +type modifiedMessageData struct { + seqNum uint32 + uid imap.UID + flags []imap.Flag + modSeq uint64 +} + +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) { - msg.store(flags) - mbox.Mailbox.tracker.QueueMessageFlags(seqNum, msg.uid, msg.flagList(), mbox.tracker) + if options != nil && options.UnchangedSince > 0 && msg.modSeq > options.UnchangedSince { + return + } + + if changed := msg.store(mbox.Mailbox, flags); changed { + mbox.Mailbox.tracker.QueueMessageFlags(seqNum, msg.uid, msg.flagList(), mbox.tracker) + + if !flags.Silent { + modified = append(modified, modifiedMessageData{ + seqNum: seqNum, + uid: msg.uid, + flags: msg.flagList(), + modSeq: msg.modSeq, + }) + } + } }) + if !flags.Silent { - return mbox.Fetch(w, numSet, &imap.FetchOptions{Flags: true}) + for _, mod := range modified { + if err := writeStoreFetchResponse(w, mbox.tracker, mod); err != nil { + return err + } + } } + return nil } diff --git a/imapserver/imapmemserver/message.go b/imapserver/imapmemserver/message.go index d5580459..bbb26f68 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(mbox *Mailbox, 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,32 @@ func (msg *message) store(store *imap.StoreFlags) { default: panic(fmt.Errorf("unknown STORE flag operation: %v", store.Op)) } + + changed := false + if len(oldFlags) != len(msg.flags) { + changed = true + } else { + for k := range oldFlags { + if _, ok := msg.flags[k]; !ok { + changed = true + break + } + } + if !changed { + for k := range msg.flags { + if _, ok := oldFlags[k]; !ok { + changed = true + break + } + } + } + } + + if changed { + mbox.highestModSeq++ + msg.modSeq = mbox.highestModSeq + } + return changed } func (msg *message) reader() *gomessage.Entity { @@ -135,7 +170,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/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/imapmemserver/sort.go b/imapserver/imapmemserver/sort.go new file mode 100644 index 00000000..ab85af67 --- /dev/null +++ b/imapserver/imapmemserver/sort.go @@ -0,0 +1,224 @@ +package imapmemserver + +import ( + "bufio" + "bytes" + "net/mail" + "net/textproto" + "sort" + "strings" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapserver" +) + +// Sort performs a SORT command. +func (mbox *MailboxView) Sort(numKind imapserver.NumKind, criteria *imap.SearchCriteria, sortCriteria []imap.SortCriterion) ([]uint32, error) { + mbox.mutex.Lock() + defer mbox.mutex.Unlock() + + // Apply search criteria + mbox.staticSearchCriteria(criteria) + + // First find all messages that match the search criteria + var matchedMessages []*message + var matchedSeqNums []uint32 + var matchedIndices []int + for i, msg := range mbox.l { + seqNum := mbox.tracker.EncodeSeqNum(uint32(i) + 1) + + if !msg.search(seqNum, criteria) { + continue + } + + matchedMessages = append(matchedMessages, msg) + matchedSeqNums = append(matchedSeqNums, seqNum) + matchedIndices = append(matchedIndices, i) + } + + // Sort the matched messages based on the sort criteria + sortMatchedMessages(matchedMessages, matchedSeqNums, matchedIndices, sortCriteria) + + // Create sorted response + var data []uint32 + for i, msg := range matchedMessages { + var num uint32 + switch numKind { + case imapserver.NumKindSeq: + if matchedSeqNums[i] == 0 { + continue + } + num = matchedSeqNums[i] + case imapserver.NumKindUID: + num = uint32(msg.uid) + } + data = append(data, num) + } + + return data, nil +} + +// sortMatchedMessages sorts messages according to the specified sort criteria +func sortMatchedMessages(messages []*message, seqNums []uint32, indices []int, criteria []imap.SortCriterion) { + if len(messages) < 2 { + return // Nothing to sort + } + + // Create a slice of indices for sorting + indices2 := make([]int, len(messages)) + for i := range indices2 { + indices2[i] = i + } + + // Sort the indices based on the criteria + sort.SliceStable(indices2, func(i, j int) bool { + i2, j2 := indices2[i], indices2[j] + + // Apply each criterion in order until we find a difference + for _, criterion := range criteria { + result := compareByCriterion(messages[i2], messages[j2], criterion.Key) + + // Apply reverse if needed + if criterion.Reverse { + result = -result + } + + // If comparison yields a difference, return the result + if result < 0 { + return true + } else if result > 0 { + return false + } + // If equal, continue to the next criterion + } + + // If all criteria are equal, maintain original order + return i < j + }) + + // Reorder the original slices according to the sorted indices + newMessages := make([]*message, len(messages)) + newSeqNums := make([]uint32, len(seqNums)) + newIndices := make([]int, len(indices)) + + for i, idx := range indices2 { + newMessages[i] = messages[idx] + newSeqNums[i] = seqNums[idx] + newIndices[i] = indices[idx] + } + + // Copy sorted slices back to original slices + copy(messages, newMessages) + copy(seqNums, newSeqNums) + copy(indices, newIndices) +} + +// compareByCriterion compares two messages based on a single criterion +// returns -1 if a < b, 0 if a == b, 1 if a > b +func compareByCriterion(a, b *message, key imap.SortKey) int { + switch key { + case imap.SortKeyArrival: + // For ARRIVAL, we use the UID as the arrival order + if a.uid < b.uid { + return -1 + } else if a.uid > b.uid { + return 1 + } + return 0 + + case imap.SortKeyDate: + // Compare internal date + if a.t.Before(b.t) { + return -1 + } else if a.t.After(b.t) { + return 1 + } + return 0 + + case imap.SortKeySize: + // Compare message sizes + aSize := len(a.buf) + bSize := len(b.buf) + if aSize < bSize { + return -1 + } else if aSize > bSize { + return 1 + } + return 0 + + case imap.SortKeyFrom: + // NOTE: A fully compliant implementation as per RFC 5256 would parse + // the address and sort by mailbox, then host. This is a simplified + // case-insensitive comparison of the full header value. + fromA := getHeader(a.buf, "From") + fromB := getHeader(b.buf, "From") + return strings.Compare(strings.ToLower(fromA), strings.ToLower(fromB)) + + case imap.SortKeyTo: + // NOTE: Simplified comparison. See SortKeyFrom. + toA := getHeader(a.buf, "To") + toB := getHeader(b.buf, "To") + return strings.Compare(strings.ToLower(toA), strings.ToLower(toB)) + + case imap.SortKeyCc: + // NOTE: Simplified comparison. See SortKeyFrom. + ccA := getHeader(a.buf, "Cc") + ccB := getHeader(b.buf, "Cc") + return strings.Compare(strings.ToLower(ccA), strings.ToLower(ccB)) + + case imap.SortKeySubject: + // RFC 5256 specifies i;ascii-casemap collation, which is case-insensitive. + subjA := getHeader(a.buf, "Subject") + subjB := getHeader(b.buf, "Subject") + return strings.Compare(strings.ToLower(subjA), strings.ToLower(subjB)) + + case imap.SortKeyDisplay: + // RFC 5957: sort by display-name, fallback to mailbox. + fromA := getHeader(a.buf, "From") + fromB := getHeader(b.buf, "From") + + addrA, errA := mail.ParseAddress(fromA) + addrB, errB := mail.ParseAddress(fromB) + + var displayA, displayB string + + if errA == nil { + if addrA.Name != "" { + displayA = addrA.Name + } else { + displayA = addrA.Address + } + } else { + displayA = fromA // Fallback to raw header on parse error + } + + if errB == nil { + if addrB.Name != "" { + displayB = addrB.Name + } else { + displayB = addrB.Address + } + } else { + displayB = fromB // Fallback to raw header on parse error + } + + // A full implementation would use locale-aware sorting (e.g., golang.org/x/text/collate). + // A case-insensitive comparison is a reasonable and significant improvement. + return strings.Compare(strings.ToLower(displayA), strings.ToLower(displayB)) + + default: + // Default to no sorting for unknown criteria + return 0 + } +} + +// getHeader extracts a header value from a message's raw bytes. +// It performs a case-insensitive search for the key. +func getHeader(buf []byte, key string) string { + r := textproto.NewReader(bufio.NewReader(bytes.NewReader(buf))) + hdr, err := r.ReadMIMEHeader() + if err != nil { + return "" // Or log the error + } + return hdr.Get(key) +} diff --git a/imapserver/list.go b/imapserver/list.go index 1aab73d7..3fe73f93 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/imapserver/search.go b/imapserver/search.go index 91466818..65765876 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) @@ -117,6 +121,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() } @@ -136,24 +143,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 +189,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 +207,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 +260,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 +327,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,17 +336,44 @@ 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 "$": 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 { @@ -339,5 +385,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))) } diff --git a/imapserver/select.go b/imapserver/select.go index 3535fe73..ae1d7b3c 100644 --- a/imapserver/select.go +++ b/imapserver/select.go @@ -3,13 +3,94 @@ 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": + // 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 + } + 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)) + } + return nil + }) + if err != nil { + return err + } + } + + if !dec.ExpectCRLF() { return dec.Err() } @@ -32,45 +113,56 @@ 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 } - 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 } } + if c.server.options.caps().Has(imap.CapCondStore) { + if data.HighestModSeq > 0 { + writeHighestModSeq(enc.Encoder, data.HighestModSeq) + } else { + writeNoModSeq(enc.Encoder) + } + } + c.state = imap.ConnStateSelected - // TODO: forbid write commands in read-only mode var ( cmdName string @@ -83,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), @@ -100,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 } @@ -114,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]) @@ -172,3 +248,34 @@ func (c *Conn) writePermanentFlags(flags []imap.Flag) error { enc.SP().Text("Permanent flags") return enc.CRLF() } + +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 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/sort.go b/imapserver/sort.go new file mode 100644 index 00000000..7fd48e01 --- /dev/null +++ b/imapserver/sort.go @@ -0,0 +1,234 @@ +package imapserver + +import ( + "fmt" + "strings" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/internal/imapwire" +) + +type SortData struct { + Nums []uint32 + Min uint32 + Max uint32 + Count uint32 +} + +type SessionSort interface { + Session + + Sort(numKind NumKind, criteria *imap.SearchCriteria, sortCriteria []imap.SortCriterion) ([]uint32, error) +} + +type esortReturnOptions struct { + Min bool + Max bool + Count bool + All bool +} + +func (c *Conn) handleSort(tag string, dec *imapwire.Decoder, numKind NumKind) error { + if !dec.ExpectSP() { + return dec.Err() + } + + var esortReturnOpts esortReturnOptions + esortReturnOpts.All = true // Default if no RETURN or RETURN (ALL) + + var atom string + // dec.Func returns true if an atom is read; 'atom' will contain it. + // If the next token is not an atom (e.g., '('), it returns false and dec.Err() is nil. + if dec.Func(&atom, imapwire.IsAtomChar) && strings.EqualFold(atom, "RETURN") { + // Atom "RETURN" was successfully read and consumed. + if !dec.ExpectSP() { + return dec.Err() + } + + esortReturnOpts.All = false // Explicit RETURN given, so default ALL is off unless specified in list + + parseReturnErr := dec.ExpectList(func() error { + var opt string + if !dec.ExpectAtom(&opt) { + return dec.Err() + } + opt = strings.ToUpper(opt) + switch opt { + case "MIN": + esortReturnOpts.Min = true + case "MAX": + esortReturnOpts.Max = true + case "COUNT": + esortReturnOpts.Count = true + case "ALL": + esortReturnOpts.All = true + default: + // RFC 5267: Servers MUST ignore any unknown sort-return-opt. + } + return nil + }) + if parseReturnErr != nil { + return parseReturnErr + } + + if esortReturnOpts.All && (esortReturnOpts.Min || esortReturnOpts.Max || esortReturnOpts.Count) { + return &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: "ESORT RETURN ALL cannot be combined with MIN, MAX, or COUNT", + } + } + + // If RETURN was specified but resulted in no recognized options, default to ALL. + // This means if RETURN () or RETURN (UNKNOWN_OPT) is sent, it behaves as if RETURN (ALL) or no RETURN was sent. + if !esortReturnOpts.Min && !esortReturnOpts.Max && !esortReturnOpts.Count && !esortReturnOpts.All { + esortReturnOpts.All = true + } + + if !dec.ExpectSP() { // Expect SP after RETURN (...) + return dec.Err() + } + } else if dec.Err() != nil { + // dec.Func failed for a reason other than the first char not matching (e.g. EOF or malformed atom) + return dec.Err() + } + + var sortCriteria []imap.SortCriterion + listErr := dec.ExpectList(func() error { + for { // Loop to correctly parse multiple sort criteria items + var criterion imap.SortCriterion + var atom string + if !dec.ExpectAtom(&atom) { + return dec.Err() + } + + if strings.EqualFold(atom, "REVERSE") { + criterion.Reverse = true + if !dec.ExpectSP() || !dec.ExpectAtom(&atom) { + return dec.Err() + } + } + + criterion.Key = imap.SortKey(strings.ToUpper(atom)) + sortCriteria = append(sortCriteria, criterion) + + if !dec.SP() { // If no more SP, then no more sort criteria in this list + break + } + } + return nil + }) + if listErr != nil { + return listErr + } + + // Parse charset - must be UTF-8 for SORT + if !dec.ExpectSP() { + return dec.Err() + } + var charset string + if !dec.ExpectAtom(&charset) || !dec.ExpectSP() { + return dec.Err() + } + if !strings.EqualFold(charset, "UTF-8") { + return &imap.Error{ + Type: imap.StatusResponseTypeNo, + Code: imap.ResponseCodeBadCharset, + Text: "Only UTF-8 is supported for SORT", + } + } + + // Parse search criteria + var criteria imap.SearchCriteria + for { + if err := readSearchKey(&criteria, dec); err != nil { + return fmt.Errorf("in search-key: %w", err) + } + if !dec.SP() { // If no more SP, then no more search keys + break + } + } + + if !dec.ExpectCRLF() { + return dec.Err() + } + + if err := c.checkState(imap.ConnStateSelected); err != nil { + return err + } + + var sortedNums []uint32 + if sortSession, ok := c.session.(SessionSort); ok { + var sortErr error + sortedNums, sortErr = sortSession.Sort(numKind, &criteria, sortCriteria) + if sortErr != nil { + return sortErr + } + } else { + return &imap.Error{ + Type: imap.StatusResponseTypeNo, + Code: imap.ResponseCodeCannot, + Text: "SORT command is not supported by this session", + } + } + + data := &SortData{Nums: sortedNums} + if len(sortedNums) > 0 { + data.Count = uint32(len(sortedNums)) + min, max := sortedNums[0], sortedNums[0] + for _, num := range sortedNums { + if num < min { + min = num + } + if num > max { + max = num + } + } + data.Min = min + data.Max = max + } + + return c.writeSortResponse(tag, numKind, data, &esortReturnOpts) +} + +func (c *Conn) writeSortResponse(tag string, numKind NumKind, data *SortData, returnOpts *esortReturnOptions) error { + // For ESORT, if RETURN options other than ALL are specified, send an ESEARCH response. + // See RFC 5267 section 4.2. + if c.server.options.caps().Has(imap.CapESort) && !returnOpts.All { + enc := newResponseEncoder(c) + enc.Atom("*").SP().Atom("ESEARCH") + if tag != "" { + enc.SP().Special('(').Atom("TAG").SP().String(tag).Special(')') + } + if numKind == NumKindUID { + enc.SP().Atom("UID") + } + if returnOpts.Min { + if data.Count > 0 { + enc.SP().Atom("MIN").SP().Number(data.Min) + } + } + if returnOpts.Max { + if data.Count > 0 { + enc.SP().Atom("MAX").SP().Number(data.Max) + } + } + if returnOpts.Count { + enc.SP().Atom("COUNT").SP().Number(data.Count) + } + if err := enc.CRLF(); err != nil { + enc.end() + return err + } + enc.end() + } + + // A SORT response is always sent, either for a regular SORT, or following + // an ESEARCH response for an ESORT. + enc := newResponseEncoder(c) + defer enc.end() + enc.Atom("*").SP().Atom("SORT") + for _, num := range data.Nums { + enc.SP().Number(num) + } + return enc.CRLF() +} diff --git a/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, diff --git a/imapserver/vanished.go b/imapserver/vanished.go new file mode 100644 index 00000000..c7b5f92b --- /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() +} diff --git a/internal/imapwire/decoder.go b/internal/imapwire/decoder.go index cfd2995c..24afd56b 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 +} 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/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 +} 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 +}