Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
7e8c5a1
add independent capabilities
mercata Jun 6, 2025
b6e4b9c
imapserver: add SORT and SORT=DISPLAY capabilities
mercata Jun 7, 2025
08495b9
imapserver: add ESORT capability
mercata Jun 7, 2025
384cff2
use a list instead of a struct
mercata Jun 7, 2025
6c983fa
capability ID required separate parsing, adding it now
mercata Jun 7, 2025
025cfbc
fix: ensure response encoder is properly finalized in handleID
mercata Jun 8, 2025
207d3ae
Merge branch 'v2' into capabilities
dejanstrbac Jun 9, 2025
cd0603c
Merge branch 'v2' into sort
dejanstrbac Jun 9, 2025
94bb68a
moving CapChildren to own PR
mercata Jun 9, 2025
200aa88
Merge branch 'v2' into capabilities
dejanstrbac Jun 11, 2025
bb05d23
mkae CapID applicable to both rev1 and rev2
mercata Jun 11, 2025
100d053
Merge branch 'v2' into capabilities
dejanstrbac Jun 11, 2025
fd118b6
Merge branch 'v2' into sort
dejanstrbac Jun 11, 2025
44d415f
moved logic for sortdata to library from memserver
mercata Jun 11, 2025
d5a432d
Merge branch 'v2' into sort
dejanstrbac Jun 11, 2025
6ed205a
Add CONDSTORE extension support
mercata Jun 6, 2025
8a69868
Add CONDSTORE extension support
mercata Jun 6, 2025
aa89543
resolve merge error and fmt
mercata Jun 11, 2025
43bd92a
removing merge conflicts leftovers
mercata Jun 11, 2025
1a045eb
Merge branch 'v2' into sort
dejanstrbac Jun 11, 2025
884d373
Merge branch 'v2' into capabilities
dejanstrbac Jun 11, 2025
dd9e5b0
Merge branch 'v2' into capabilities
dejanstrbac Sep 21, 2025
bff9940
Merge branch 'v2' into sort
dejanstrbac Sep 21, 2025
0e418b8
Extend ID command on both client and server for ID forwarding in Dovecot
mercata Sep 21, 2025
f2fdf37
corrected ESORT handling as per RFC5267
mercata Sep 21, 2025
b625cbe
Merge branch 'v2' into sort
dejanstrbac Sep 21, 2025
0446b74
Merge branch 'v2' into capabilities
dejanstrbac Sep 21, 2025
6793216
According to RFC 4731, a server must ignore any unrecognized RETURN o…
mercata Sep 21, 2025
649289c
Merge branch 'v2' into v2search
dejanstrbac Sep 21, 2025
e38020d
Merge branch 'v2' into condstore
dejanstrbac Sep 21, 2025
1f423a7
updates of requested changes
mercata Sep 21, 2025
a1954be
Missing CONDSTORE, Search fixes and QRESYNC support
mercata Sep 21, 2025
239a2ae
update test to support QRESYNC
mercata Sep 21, 2025
8539f83
send CONDSTORE only if advertised
mercata Sep 21, 2025
30e5af0
Refactor setCaps() and Caps() for race condition
mercata Sep 21, 2025
943e8f6
feat(condstore): add CONDSTORE to ENABLE
mercata Sep 21, 2025
d1d721c
enable condstore in client tests
mercata Sep 21, 2025
cf33794
CHANGEDSINCE is only in Fetch, not Search according to fc7162
mercata Sep 21, 2025
55b3fb2
goftm
mercata Sep 21, 2025
a79d4e5
permanentflags => flags
mercata Sep 21, 2025
01a2948
Merge branch 'v2' into imapclientflags
dejanstrbac Sep 21, 2025
8a3cc0a
Allow empty lines / commands without breaking connection
mercata Sep 21, 2025
409fe31
Merge branch 'v2' into emptycmd
dejanstrbac Sep 21, 2025
4d6ceb6
Consistent and context aware utf handling
mercata Sep 21, 2025
68a2840
Merge branch 'v2' into utf
dejanstrbac Sep 21, 2025
f78e973
Go fmt
mercata Sep 22, 2025
50b99b2
Merge pull request #7 from migadu/capabilities
dejanstrbac Sep 23, 2025
a0ebec9
Merge pull request #2 from dejanstrbac/utf
dejanstrbac Sep 23, 2025
59c6420
Merge pull request #3 from dejanstrbac/emptycmd
dejanstrbac Sep 23, 2025
a746919
Merge pull request #4 from dejanstrbac/imapclientflags
dejanstrbac Sep 23, 2025
e03666f
Merge pull request #5 from dejanstrbac/v2search
dejanstrbac Sep 23, 2025
f8689b4
Merge branch 'sora' into sort
dejanstrbac Sep 23, 2025
1506e71
Merge pull request #6 from migadu/sort
dejanstrbac Sep 23, 2025
b75e766
Merge branch 'sora' into condstore
dejanstrbac Sep 23, 2025
ab7be97
Merge branch 'v2' into condstore
dejanstrbac Sep 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions imapclient/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ func newMemClientServerPair(t *testing.T) (net.Conn, io.Closer) {
Caps: imap.CapSet{
imap.CapIMAP4rev1: {},
imap.CapIMAP4rev2: {},
imap.CapCondStore: {},
},
})

Expand Down
311 changes: 311 additions & 0 deletions imapclient/condstore_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions imapserver/capability.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ func (c *Conn) availableCaps() []imap.Cap {
imap.CapCreateSpecialUse,
imap.CapLiteralPlus,
imap.CapUnauthenticate,
imap.CapCondStore,
})
}
return caps
Expand Down
28 changes: 28 additions & 0 deletions imapserver/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,26 @@ func (c *Conn) handleFetch(dec *imapwire.Decoder, numKind NumKind) error {
}
}

if dec.SP() && dec.Special('(') {
var param string
if !dec.ExpectAtom(&param) {
return dec.Err()
}

if strings.ToUpper(param) == "CHANGEDSINCE" {
if !dec.ExpectSP() || !dec.ExpectModSeq(&options.ChangedSince) {
return dec.Err()
}
options.ModSeq = true
} else {
return fmt.Errorf("unknown FETCH modifier: %v", param)
}

if !dec.ExpectSpecial(')') {
return dec.Err()
}
}

if !dec.ExpectCRLF() {
return dec.Err()
}
Expand Down Expand Up @@ -108,6 +128,8 @@ func handleFetchAtt(dec *imapwire.Decoder, attName string, options *imap.FetchOp
options.RFC822Size = true
case "UID":
options.UID = true
case "MODSEQ":
options.ModSeq = true
case "RFC822": // equivalent to BODY[]
bs := &imap.FetchItemBodySection{}
writerOptions.obsolete[bs] = attName
Expand Down Expand Up @@ -456,6 +478,12 @@ func (w *FetchResponseWriter) WriteEnvelope(envelope *imap.Envelope) {
writeEnvelope(enc, envelope)
}

// WriteModSeq writes the message's MODSEQ.
func (w *FetchResponseWriter) WriteModSeq(modSeq uint64) {
w.writeItemSep()
w.enc.Atom("MODSEQ").SP().Special('(').ModSeq(modSeq).Special(')')
}

// WriteBodyStructure writes the message's body structure (either BODYSTRUCTURE
// or BODY).
func (w *FetchResponseWriter) WriteBodyStructure(bs imap.BodyStructure) {
Expand Down
Loading