From 3dbbdb9011f08fdef29cce896b65941871f0dd26 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Thu, 9 Oct 2025 12:33:01 +0200 Subject: [PATCH 1/4] Server implementation of METADATA - RFC5464 --- imapclient/client.go | 12 +- imapclient/client_test.go | 3 + imapclient/metadata.go | 65 ++-- imapclient/metadata_test.go | 581 ++++++++++++++++++++++++++++ imapserver/capability.go | 5 + imapserver/conn.go | 4 + imapserver/imapmemserver/mailbox.go | 30 +- imapserver/imapmemserver/session.go | 118 ++++++ imapserver/imapmemserver/user.go | 8 +- imapserver/metadata.go | 242 ++++++++++++ imapserver/metadata_test.go | 231 +++++++++++ imapserver/session.go | 26 ++ metadata.go | 105 +++++ response.go | 6 +- 14 files changed, 1381 insertions(+), 55 deletions(-) create mode 100644 imapclient/metadata_test.go create mode 100644 imapserver/metadata.go create mode 100644 imapserver/metadata_test.go create mode 100644 metadata.go diff --git a/imapclient/client.go b/imapclient/client.go index 620bce36..af41b252 100644 --- a/imapclient/client.go +++ b/imapclient/client.go @@ -716,7 +716,6 @@ func (c *Client) readResponseTagged(tag, typ string) (startTLS *startTLSCommand, if !c.dec.ExpectAtom(&code) { return nil, fmt.Errorf("in resp-text-code: %v", c.dec.Err()) } - // TODO: LONGENTRIES and MAXSIZE from METADATA switch code { case "CAPABILITY": // capability-data caps, err := readCapabilities(c.dec) @@ -724,6 +723,17 @@ func (c *Client) readResponseTagged(tag, typ string) (startTLS *startTLSCommand, return nil, fmt.Errorf("in capability-data: %v", err) } c.setCaps(caps) + case "LONGENTRIES", "MAXSIZE": // METADATA response codes with size parameter + var size uint32 + if !c.dec.ExpectSP() || !c.dec.ExpectNumber(&size) { + return nil, fmt.Errorf("in resp-code-metadata: %v", c.dec.Err()) + } + if cmd, ok := cmd.(*GetMetadataCommand); ok { + if cmd.data.ResponseCodeData == nil { + cmd.data.ResponseCodeData = &imap.MetadataResponseCodeData{} + } + cmd.data.ResponseCodeData.Size = size + } case "APPENDUID": var ( uidValidity uint32 diff --git a/imapclient/client_test.go b/imapclient/client_test.go index 9e5c206f..7dcb95eb 100644 --- a/imapclient/client_test.go +++ b/imapclient/client_test.go @@ -102,6 +102,9 @@ func newMemClientServerPair(t *testing.T) (net.Conn, io.Closer) { Caps: imap.CapSet{ imap.CapIMAP4rev1: {}, imap.CapIMAP4rev2: {}, + imap.CapCondStore: {}, + imap.CapQResync: {}, + imap.CapMetadata: {}, }, }) diff --git a/imapclient/metadata.go b/imapclient/metadata.go index c8a0e728..51902228 100644 --- a/imapclient/metadata.go +++ b/imapclient/metadata.go @@ -3,37 +3,11 @@ package imapclient import ( "fmt" + "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/internal/imapwire" ) -type GetMetadataDepth int - -const ( - GetMetadataDepthZero GetMetadataDepth = 0 - GetMetadataDepthOne GetMetadataDepth = 1 - GetMetadataDepthInfinity GetMetadataDepth = -1 -) - -func (depth GetMetadataDepth) String() string { - switch depth { - case GetMetadataDepthZero: - return "0" - case GetMetadataDepthOne: - return "1" - case GetMetadataDepthInfinity: - return "infinity" - default: - panic(fmt.Errorf("imapclient: unknown GETMETADATA depth %d", depth)) - } -} - -// GetMetadataOptions contains options for the GETMETADATA command. -type GetMetadataOptions struct { - MaxSize *uint32 - Depth GetMetadataDepth -} - -func (options *GetMetadataOptions) names() []string { +func getMetadataOptionNames(options *imap.GetMetadataOptions) []string { if options == nil { return nil } @@ -41,7 +15,7 @@ func (options *GetMetadataOptions) names() []string { if options.MaxSize != nil { l = append(l, "MAXSIZE") } - if options.Depth != GetMetadataDepthZero { + if options.Depth != imap.GetMetadataDepthZero { l = append(l, "DEPTH") } return l @@ -50,11 +24,20 @@ func (options *GetMetadataOptions) names() []string { // GetMetadata sends a GETMETADATA command. // // This command requires support for the METADATA or METADATA-SERVER extension. -func (c *Client) GetMetadata(mailbox string, entries []string, options *GetMetadataOptions) *GetMetadataCommand { +func (c *Client) GetMetadata(mailbox string, entries []string, options *imap.GetMetadataOptions) *GetMetadataCommand { + // Validate entry names before sending to server + for _, entry := range entries { + if err := imap.ValidateMetadataEntry(entry); err != nil { + cmd := &GetMetadataCommand{mailbox: mailbox} + cmd.err = fmt.Errorf("invalid entry name %q: %w", entry, err) + return cmd + } + } + cmd := &GetMetadataCommand{mailbox: mailbox} enc := c.beginCommand("GETMETADATA", cmd) enc.SP().Mailbox(mailbox) - if opts := options.names(); len(opts) > 0 { + if opts := getMetadataOptionNames(options); len(opts) > 0 { enc.SP().List(len(opts), func(i int) { opt := opts[i] enc.Atom(opt).SP() @@ -81,6 +64,16 @@ func (c *Client) GetMetadata(mailbox string, entries []string, options *GetMetad // // This command requires support for the METADATA or METADATA-SERVER extension. func (c *Client) SetMetadata(mailbox string, entries map[string]*[]byte) *Command { + // Validate entry names before sending to server + for entry := range entries { + if err := imap.ValidateMetadataEntry(entry); err != nil { + // Create command that will fail immediately + cmd := &Command{} + cmd.err = fmt.Errorf("invalid entry name %q: %w", entry, err) + return cmd + } + } + cmd := &Command{} enc := c.beginCommand("SETMETADATA", cmd) enc.SP().Mailbox(mailbox).SP().Special('(') @@ -134,19 +127,13 @@ func (c *Client) handleMetadata() error { type GetMetadataCommand struct { commandBase mailbox string - data GetMetadataData + data imap.GetMetadataData } -func (cmd *GetMetadataCommand) Wait() (*GetMetadataData, error) { +func (cmd *GetMetadataCommand) Wait() (*imap.GetMetadataData, error) { return &cmd.data, cmd.wait() } -// GetMetadataData is the data returned by the GETMETADATA command. -type GetMetadataData struct { - Mailbox string - Entries map[string]*[]byte -} - type metadataResp struct { Mailbox string EntryList []string diff --git a/imapclient/metadata_test.go b/imapclient/metadata_test.go new file mode 100644 index 00000000..67882cc3 --- /dev/null +++ b/imapclient/metadata_test.go @@ -0,0 +1,581 @@ +package imapclient_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/emersion/go-imap/v2" +) + +func TestMetadata_ServerAnnotations(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapMetadata) { + t.Skip("server doesn't support METADATA") + } + + // Set server annotation + entries := map[string]*[]byte{ + "/private/comment": ptr([]byte("my server comment")), + } + if err := client.SetMetadata("", entries).Wait(); err != nil { + t.Fatalf("SetMetadata() = %v", err) + } + + // Get server annotation + data, err := client.GetMetadata("", []string{"/private/comment"}, nil).Wait() + if err != nil { + t.Fatalf("GetMetadata() = %v", err) + } + + if data.Mailbox != "" { + t.Errorf("GetMetadata().Mailbox = %q, want empty string", data.Mailbox) + } + + if len(data.Entries) != 1 { + t.Fatalf("GetMetadata().Entries length = %d, want 1", len(data.Entries)) + } + + value := data.Entries["/private/comment"] + if value == nil { + t.Fatal("GetMetadata().Entries['/private/comment'] = nil") + } + if string(*value) != "my server comment" { + t.Errorf("GetMetadata().Entries['/private/comment'] = %q, want %q", string(*value), "my server comment") + } +} + +func TestMetadata_MailboxAnnotations(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapMetadata) { + t.Skip("server doesn't support METADATA") + } + + // Set mailbox annotation + entries := map[string]*[]byte{ + "/private/comment": ptr([]byte("my mailbox comment")), + } + if err := client.SetMetadata("INBOX", entries).Wait(); err != nil { + t.Fatalf("SetMetadata() = %v", err) + } + + // Get mailbox annotation + data, err := client.GetMetadata("INBOX", []string{"/private/comment"}, nil).Wait() + if err != nil { + t.Fatalf("GetMetadata() = %v", err) + } + + if data.Mailbox != "INBOX" { + t.Errorf("GetMetadata().Mailbox = %q, want 'INBOX'", data.Mailbox) + } + + if len(data.Entries) != 1 { + t.Fatalf("GetMetadata().Entries length = %d, want 1", len(data.Entries)) + } + + value := data.Entries["/private/comment"] + if value == nil { + t.Fatal("GetMetadata().Entries['/private/comment'] = nil") + } + if string(*value) != "my mailbox comment" { + t.Errorf("GetMetadata().Entries['/private/comment'] = %q, want %q", string(*value), "my mailbox comment") + } +} + +func TestMetadata_DeleteEntry(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapMetadata) { + t.Skip("server doesn't support METADATA") + } + + // Set annotation + entries := map[string]*[]byte{ + "/private/comment": ptr([]byte("test")), + } + if err := client.SetMetadata("", entries).Wait(); err != nil { + t.Fatalf("SetMetadata() = %v", err) + } + + // Delete annotation (set to nil) + entries = map[string]*[]byte{ + "/private/comment": nil, + } + if err := client.SetMetadata("", entries).Wait(); err != nil { + t.Fatalf("SetMetadata() delete = %v", err) + } + + // Verify it's deleted + data, err := client.GetMetadata("", []string{"/private/comment"}, nil).Wait() + if err != nil { + t.Fatalf("GetMetadata() = %v", err) + } + + if len(data.Entries) != 0 { + t.Errorf("GetMetadata().Entries length = %d, want 0", len(data.Entries)) + } +} + +func TestMetadata_DepthZero(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapMetadata) { + t.Skip("server doesn't support METADATA") + } + + // Set multiple annotations + entries := map[string]*[]byte{ + "/private/comment": ptr([]byte("parent")), + "/private/comment/child": ptr([]byte("child")), + } + if err := client.SetMetadata("", entries).Wait(); err != nil { + t.Fatalf("SetMetadata() = %v", err) + } + + // Get with depth 0 (exact match only) + options := &imap.GetMetadataOptions{ + Depth: imap.GetMetadataDepthZero, + } + data, err := client.GetMetadata("", []string{"/private/comment"}, options).Wait() + if err != nil { + t.Fatalf("GetMetadata() = %v", err) + } + + // Should only get exact match + if len(data.Entries) != 1 { + t.Fatalf("GetMetadata().Entries length = %d, want 1", len(data.Entries)) + } + + if _, ok := data.Entries["/private/comment"]; !ok { + t.Error("Expected /private/comment in results") + } + if _, ok := data.Entries["/private/comment/child"]; ok { + t.Error("Did not expect /private/comment/child in results with depth 0") + } +} + +func TestMetadata_DepthOne(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapMetadata) { + t.Skip("server doesn't support METADATA") + } + + // Set multiple annotations + entries := map[string]*[]byte{ + "/private/comment": ptr([]byte("parent")), + "/private/comment/child": ptr([]byte("child")), + "/private/comment/child/grand": ptr([]byte("grandchild")), + } + if err := client.SetMetadata("", entries).Wait(); err != nil { + t.Fatalf("SetMetadata() = %v", err) + } + + // Get with depth 1 (immediate children) + options := &imap.GetMetadataOptions{ + Depth: imap.GetMetadataDepthOne, + } + data, err := client.GetMetadata("", []string{"/private/comment"}, options).Wait() + if err != nil { + t.Fatalf("GetMetadata() = %v", err) + } + + // Should get parent and immediate child, but not grandchild + if len(data.Entries) != 2 { + t.Fatalf("GetMetadata().Entries length = %d, want 2", len(data.Entries)) + } + + if _, ok := data.Entries["/private/comment"]; !ok { + t.Error("Expected /private/comment in results") + } + if _, ok := data.Entries["/private/comment/child"]; !ok { + t.Error("Expected /private/comment/child in results") + } + if _, ok := data.Entries["/private/comment/child/grand"]; ok { + t.Error("Did not expect /private/comment/child/grand in results with depth 1") + } +} + +func TestMetadata_DepthInfinity(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapMetadata) { + t.Skip("server doesn't support METADATA") + } + + // Set multiple annotations + entries := map[string]*[]byte{ + "/private/comment": ptr([]byte("parent")), + "/private/comment/child": ptr([]byte("child")), + "/private/comment/child/grand": ptr([]byte("grandchild")), + } + if err := client.SetMetadata("", entries).Wait(); err != nil { + t.Fatalf("SetMetadata() = %v", err) + } + + // Get with depth infinity (all descendants) + options := &imap.GetMetadataOptions{ + Depth: imap.GetMetadataDepthInfinity, + } + data, err := client.GetMetadata("", []string{"/private/comment"}, options).Wait() + if err != nil { + t.Fatalf("GetMetadata() = %v", err) + } + + // Should get all entries + if len(data.Entries) != 3 { + t.Fatalf("GetMetadata().Entries length = %d, want 3", len(data.Entries)) + } + + if _, ok := data.Entries["/private/comment"]; !ok { + t.Error("Expected /private/comment in results") + } + if _, ok := data.Entries["/private/comment/child"]; !ok { + t.Error("Expected /private/comment/child in results") + } + if _, ok := data.Entries["/private/comment/child/grand"]; !ok { + t.Error("Expected /private/comment/child/grand in results") + } +} + +func TestMetadata_MaxSize(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapMetadata) { + t.Skip("server doesn't support METADATA") + } + + // Set annotations with different sizes + entries := map[string]*[]byte{ + "/private/small": ptr([]byte("small")), + "/private/large": ptr([]byte("this is a much larger annotation value")), + } + if err := client.SetMetadata("", entries).Wait(); err != nil { + t.Fatalf("SetMetadata() = %v", err) + } + + // Get with maxsize limit + maxSize := uint32(10) + options := &imap.GetMetadataOptions{ + MaxSize: &maxSize, + } + data, err := client.GetMetadata("", []string{"/private/small", "/private/large"}, options).Wait() + if err != nil { + t.Fatalf("GetMetadata() = %v", err) + } + + // Should only get small entry + if len(data.Entries) != 1 { + t.Fatalf("GetMetadata().Entries length = %d, want 1", len(data.Entries)) + } + + if _, ok := data.Entries["/private/small"]; !ok { + t.Error("Expected /private/small in results") + } + if _, ok := data.Entries["/private/large"]; ok { + t.Error("Did not expect /private/large in results (exceeds maxsize)") + } +} + +func TestSetMetadata_InvalidEntry(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapMetadata) { + t.Skip("server doesn't support METADATA") + } + + tests := []struct { + name string + entry string + }{ + { + name: "missing prefix", + entry: "/invalid/entry", + }, + { + name: "wrong prefix", + entry: "/public/comment", + }, + { + name: "contains wildcard asterisk", + entry: "/private/comm*ent", + }, + { + name: "contains wildcard percent", + entry: "/private/comm%ent", + }, + { + name: "consecutive slashes", + entry: "/private//comment", + }, + { + name: "trailing slash", + entry: "/private/comment/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + entries := map[string]*[]byte{ + tt.entry: ptr([]byte("test")), + } + err := client.SetMetadata("", entries).Wait() + if err == nil { + t.Fatal("Expected error for invalid entry, got nil") + } + // Verify the error mentions the invalid entry + if !strings.Contains(err.Error(), "invalid entry name") { + t.Errorf("Error should mention 'invalid entry name', got: %v", err) + } + }) + } +} + +func TestGetMetadata_InvalidEntry(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapMetadata) { + t.Skip("server doesn't support METADATA") + } + + tests := []struct { + name string + entry string + }{ + { + name: "missing prefix", + entry: "/invalid/entry", + }, + { + name: "wrong prefix", + entry: "/public/comment", + }, + { + name: "contains wildcard asterisk", + entry: "/private/comm*ent", + }, + { + name: "contains wildcard percent", + entry: "/private/comm%ent", + }, + { + name: "consecutive slashes", + entry: "/private//comment", + }, + { + name: "trailing slash", + entry: "/private/comment/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := client.GetMetadata("", []string{tt.entry}, nil).Wait() + if err == nil { + t.Fatal("Expected error for invalid entry, got nil") + } + // Verify the error mentions the invalid entry + if !strings.Contains(err.Error(), "invalid entry name") { + t.Errorf("Error should mention 'invalid entry name', got: %v", err) + } + }) + } +} + +func TestMetadata_MailboxRename(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapMetadata) { + t.Skip("server doesn't support METADATA") + } + + // Create a test mailbox + if err := client.Create("TestMailbox", nil).Wait(); err != nil { + t.Fatalf("Create() = %v", err) + } + + // Set annotations on the mailbox + entries := map[string]*[]byte{ + "/private/comment": ptr([]byte("my test comment")), + "/shared/vendor": ptr([]byte("vendor data")), + } + if err := client.SetMetadata("TestMailbox", entries).Wait(); err != nil { + t.Fatalf("SetMetadata() = %v", err) + } + + // Verify annotations exist + data, err := client.GetMetadata("TestMailbox", []string{"/private/comment", "/shared/vendor"}, nil).Wait() + if err != nil { + t.Fatalf("GetMetadata() before rename = %v", err) + } + if len(data.Entries) != 2 { + t.Fatalf("Expected 2 entries before rename, got %d", len(data.Entries)) + } + + // Rename the mailbox + if err := client.Rename("TestMailbox", "RenamedMailbox", nil).Wait(); err != nil { + t.Fatalf("Rename() = %v", err) + } + + // Verify annotations moved to the new mailbox name + data, err = client.GetMetadata("RenamedMailbox", []string{"/private/comment", "/shared/vendor"}, nil).Wait() + if err != nil { + t.Fatalf("GetMetadata() after rename = %v", err) + } + + if len(data.Entries) != 2 { + t.Fatalf("Expected 2 entries after rename, got %d", len(data.Entries)) + } + + // Verify the annotation values are preserved + if data.Entries["/private/comment"] == nil { + t.Fatal("Expected /private/comment to exist after rename") + } + if string(*data.Entries["/private/comment"]) != "my test comment" { + t.Errorf("Expected comment 'my test comment', got %q", string(*data.Entries["/private/comment"])) + } + + if data.Entries["/shared/vendor"] == nil { + t.Fatal("Expected /shared/vendor to exist after rename") + } + if string(*data.Entries["/shared/vendor"]) != "vendor data" { + t.Errorf("Expected vendor 'vendor data', got %q", string(*data.Entries["/shared/vendor"])) + } + + // Verify old mailbox name no longer has annotations + _, err = client.GetMetadata("TestMailbox", []string{"/private/comment"}, nil).Wait() + if err == nil { + t.Error("Expected error when querying old mailbox name, got nil") + } +} + +func TestMetadata_TooManyResponseCode(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapMetadata) { + t.Skip("server doesn't support METADATA") + } + + // Set 101 entries to trigger TOOMANY (server limit is 100) + entries := make(map[string]*[]byte) + for i := 0; i < 101; i++ { + entries[fmt.Sprintf("/private/entry%d", i)] = ptr([]byte("value")) + } + + err := client.SetMetadata("", entries).Wait() + if err == nil { + t.Fatal("Expected TOOMANY error, got nil") + } + + // Verify it's a TOOMANY response code + if imapErr, ok := err.(*imap.Error); ok { + if imapErr.Code != imap.ResponseCodeTooMany { + t.Errorf("Expected TOOMANY response code, got %v", imapErr.Code) + } + if imapErr.Type != imap.StatusResponseTypeNo { + t.Errorf("Expected NO response type, got %v", imapErr.Type) + } + } else { + t.Errorf("Expected *imap.Error, got %T: %v", err, err) + } +} + +func TestMetadata_ResponseCodes(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapMetadata) { + t.Skip("server doesn't support METADATA") + } + + t.Run("TOOMANY on SetMetadata", func(t *testing.T) { + // Set more than 100 entries to trigger TOOMANY + entries := make(map[string]*[]byte) + for i := 0; i < 101; i++ { + entries[fmt.Sprintf("/private/test%d", i)] = ptr([]byte("value")) + } + + err := client.SetMetadata("", entries).Wait() + if err == nil { + t.Fatal("Expected error, got nil") + } + + imapErr, ok := err.(*imap.Error) + if !ok { + t.Fatalf("Expected *imap.Error, got %T", err) + } + + if imapErr.Code != imap.ResponseCodeTooMany { + t.Errorf("Expected TOOMANY, got %v", imapErr.Code) + } + }) + + t.Run("MAXSIZE filtering", func(t *testing.T) { + // Clear any previous entries first by creating a fresh client + freshClient, freshServer := newClientServerPair(t, imap.ConnStateAuthenticated) + defer freshClient.Close() + defer freshServer.Close() + + // Set a large entry + entries := map[string]*[]byte{ + "/private/large": ptr([]byte(strings.Repeat("x", 100))), + "/private/small": ptr([]byte("small")), + } + if err := freshClient.SetMetadata("", entries).Wait(); err != nil { + t.Fatalf("SetMetadata() = %v", err) + } + + // Request with very small MAXSIZE + maxSize := uint32(10) + data, err := freshClient.GetMetadata("", []string{"/private/large", "/private/small"}, + &imap.GetMetadataOptions{MaxSize: &maxSize}).Wait() + + if err != nil { + t.Fatalf("GetMetadata() = %v", err) + } + + // Should only get small entry (large one filtered by MAXSIZE) + if len(data.Entries) != 1 { + t.Errorf("Expected 1 entry, got %d", len(data.Entries)) + } + if _, ok := data.Entries["/private/small"]; !ok { + t.Error("Expected /private/small in results") + } + if _, ok := data.Entries["/private/large"]; ok { + t.Error("Did not expect /private/large (should be filtered by MAXSIZE)") + } + + // Note: Current implementation does not return LONGENTRIES response code + // This is a known limitation - the server silently filters large entries + // A future enhancement would be to track and report LONGENTRIES + }) +} + +func ptr(b []byte) *[]byte { + return &b +} diff --git a/imapserver/capability.go b/imapserver/capability.go index 37da104b..3d3d737d 100644 --- a/imapserver/capability.go +++ b/imapserver/capability.go @@ -95,6 +95,11 @@ func (c *Conn) availableCaps() []imap.Cap { imap.CapUnauthenticate, }) + // METADATA capability + if _, ok := c.session.(SessionMetadata); ok && available.Has(imap.CapMetadata) { + caps = append(caps, imap.CapMetadata) + } + if appendLimitSession, ok := c.session.(SessionAppendLimit); ok { limit := appendLimitSession.AppendLimit() caps = append(caps, imap.Cap(fmt.Sprintf("APPENDLIMIT=%d", limit))) diff --git a/imapserver/conn.go b/imapserver/conn.go index 291f37ec..4d496e66 100644 --- a/imapserver/conn.go +++ b/imapserver/conn.go @@ -276,6 +276,10 @@ func (c *Conn) readCommand(dec *imapwire.Decoder) error { err = c.handleMove(dec, numKind) case "SEARCH", "UID SEARCH": err = c.handleSearch(tag, dec, numKind) + case "GETMETADATA": + err = c.handleGetMetadata(dec) + case "SETMETADATA": + err = c.handleSetMetadata(dec) default: if c.state == imap.ConnStateNotAuthenticated { // Don't allow a single unknown command before authentication to diff --git a/imapserver/imapmemserver/mailbox.go b/imapserver/imapmemserver/mailbox.go index dee9a67b..52b44809 100644 --- a/imapserver/imapmemserver/mailbox.go +++ b/imapserver/imapmemserver/mailbox.go @@ -18,21 +18,31 @@ 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 + metadata map[string]*[]byte +} + +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, + metadata: make(map[string]*[]byte), } } diff --git a/imapserver/imapmemserver/session.go b/imapserver/imapmemserver/session.go index 70e9d2f8..c3880157 100644 --- a/imapserver/imapmemserver/session.go +++ b/imapserver/imapmemserver/session.go @@ -1,6 +1,8 @@ package imapmemserver import ( + "strings" + "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapserver" ) @@ -138,3 +140,119 @@ func (sess *UserSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) } return sess.mailbox.Idle(w, stop) } + +func (sess *UserSession) GetMetadata(mailboxName string, entries []string, options *imap.GetMetadataOptions) (*imap.GetMetadataData, error) { + sess.user.mutex.Lock() + defer sess.user.mutex.Unlock() + + var source map[string]*[]byte + if mailboxName == "" { + source = sess.user.serverMetadata + } else { + mbox, err := sess.user.mailboxLocked(mailboxName) + if err != nil { + return nil, err + } + mbox.mutex.Lock() + source = mbox.metadata + mbox.mutex.Unlock() + } + + result := make(map[string]*[]byte) + + for _, requestedEntry := range entries { + for entryName, value := range source { + if matchesWithDepth(entryName, requestedEntry, options) { + if options != nil && options.MaxSize != nil && value != nil { + if uint32(len(*value)) > *options.MaxSize { + continue + } + } + result[entryName] = value + } + } + } + + return &imap.GetMetadataData{ + Mailbox: mailboxName, + Entries: result, + }, nil +} + +func (sess *UserSession) SetMetadata(mailboxName string, entries map[string]*[]byte) error { + sess.user.mutex.Lock() + defer sess.user.mutex.Unlock() + + var target map[string]*[]byte + if mailboxName == "" { + target = sess.user.serverMetadata + } else { + mbox, err := sess.user.mailboxLocked(mailboxName) + if err != nil { + return err + } + mbox.mutex.Lock() + defer mbox.mutex.Unlock() + target = mbox.metadata + } + + for entry, value := range entries { + if value == nil { + delete(target, entry) + } else { + if len(*value) > 10240 { + return &imap.Error{ + Type: imap.StatusResponseTypeNo, + Code: imap.ResponseCodeLimit, + Text: "Annotation value too large", + } + } + target[entry] = value + } + } + + if len(target) > 100 { + return &imap.Error{ + Type: imap.StatusResponseTypeNo, + Code: imap.ResponseCodeTooMany, + Text: "Too many annotations", + } + } + + return nil +} + +func matchesWithDepth(entryName, requestedEntry string, options *imap.GetMetadataOptions) bool { + depth := imap.GetMetadataDepthZero + if options != nil { + depth = options.Depth + } + + switch depth { + case imap.GetMetadataDepthZero: + return entryName == requestedEntry + case imap.GetMetadataDepthOne: + if entryName == requestedEntry { + return true + } + if len(entryName) > len(requestedEntry) && + entryName[:len(requestedEntry)] == requestedEntry && + entryName[len(requestedEntry)] == '/' { + remainder := entryName[len(requestedEntry)+1:] + return !strings.Contains(remainder, "/") + } + return false + case imap.GetMetadataDepthInfinity: + if entryName == requestedEntry { + return true + } + if len(entryName) > len(requestedEntry) && + entryName[:len(requestedEntry)] == requestedEntry && + entryName[len(requestedEntry)] == '/' { + return true + } + return false + default: + return false + } +} diff --git a/imapserver/imapmemserver/user.go b/imapserver/imapmemserver/user.go index 9af1d7bc..99a87bad 100644 --- a/imapserver/imapmemserver/user.go +++ b/imapserver/imapmemserver/user.go @@ -18,13 +18,15 @@ type User struct { mutex sync.Mutex mailboxes map[string]*Mailbox prevUidValidity uint32 + serverMetadata map[string]*[]byte } func NewUser(username, password string) *User { return &User{ - username: username, - password: password, - mailboxes: make(map[string]*Mailbox), + username: username, + password: password, + mailboxes: make(map[string]*Mailbox), + serverMetadata: make(map[string]*[]byte), } } diff --git a/imapserver/metadata.go b/imapserver/metadata.go new file mode 100644 index 00000000..113bd942 --- /dev/null +++ b/imapserver/metadata.go @@ -0,0 +1,242 @@ +package imapserver + +import ( + "fmt" + "strings" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/internal/imapwire" +) + +func (c *Conn) handleGetMetadata(dec *imapwire.Decoder) error { + var mailbox string + if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || !dec.ExpectSP() { + return dec.Err() + } + + // Options are optional and start with ATOM (MAXSIZE/DEPTH) + // Entries start with astring (typically quoted string) + var options imap.GetMetadataOptions + var entries []string + hasOptions := false + + // Try to parse - could be options list or entry list + if err := dec.ExpectList(func() error { + // Check if this is options (starts with atom) or entries (starts with astring) + var first string + if dec.Atom(&first) { + // It's an atom, so this must be options + hasOptions = true + firstUpper := strings.ToUpper(first) + if err := readGetMetadataOption(dec, firstUpper, &options); err != nil { + return err + } + // Continue reading more options if present + for dec.SP() { + var optName string + if !dec.ExpectAtom(&optName) { + return dec.Err() + } + if err := readGetMetadataOption(dec, strings.ToUpper(optName), &options); err != nil { + return err + } + } + return nil + } else if dec.String(&first) || dec.Literal(&first) { + // It's a string, so this is the entry list + if err := imap.ValidateMetadataEntry(first); err != nil { + return &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: err.Error(), + } + } + entries = append(entries, first) + // Continue reading more entries + for dec.SP() { + var entry string + if !dec.ExpectAString(&entry) { + return dec.Err() + } + if err := imap.ValidateMetadataEntry(entry); err != nil { + return &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: err.Error(), + } + } + entries = append(entries, entry) + } + return nil + } + return dec.Err() + }); err != nil { + return err + } + + // If we parsed options, we now need to parse the entry list + if hasOptions { + if !dec.ExpectSP() { + return dec.Err() + } + if err := dec.ExpectList(func() error { + var entry string + if !dec.ExpectAString(&entry) { + return dec.Err() + } + if err := imap.ValidateMetadataEntry(entry); err != nil { + return &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: err.Error(), + } + } + entries = append(entries, entry) + return nil + }); err != nil { + return err + } + } + + if !dec.ExpectCRLF() { + return dec.Err() + } + + if err := c.checkState(imap.ConnStateAuthenticated); err != nil { + return err + } + + session, ok := c.session.(SessionMetadata) + if !ok { + return newClientBugError("GETMETADATA is not supported") + } + + opts := &options + if !hasOptions { + opts = nil + } + + data, err := session.GetMetadata(mailbox, entries, opts) + if err != nil { + return err + } + + if err := c.writeMetadataResp(data.Mailbox, data.Entries); err != nil { + return err + } + + return nil +} + +func (c *Conn) handleSetMetadata(dec *imapwire.Decoder) error { + var mailbox string + if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || !dec.ExpectSP() { + return dec.Err() + } + + // Parse entry-value list + entries := make(map[string]*[]byte) + if err := dec.ExpectList(func() error { + var entry string + if !dec.ExpectAString(&entry) || !dec.ExpectSP() { + return dec.Err() + } + + if err := imap.ValidateMetadataEntry(entry); err != nil { + return &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: err.Error(), + } + } + + var value *[]byte + var s string + if dec.String(&s) || dec.Literal(&s) { + b := []byte(s) + value = &b + } else if !dec.ExpectNIL() { + return dec.Err() + } + + entries[entry] = value + return nil + }); err != nil { + return err + } + + if !dec.ExpectCRLF() { + return dec.Err() + } + + if err := c.checkState(imap.ConnStateAuthenticated); err != nil { + return err + } + + session, ok := c.session.(SessionMetadata) + if !ok { + return newClientBugError("SETMETADATA is not supported") + } + + return session.SetMetadata(mailbox, entries) +} + +func (c *Conn) writeMetadataResp(mailbox string, entries map[string]*[]byte) error { + if len(entries) == 0 { + return nil + } + + enc := newResponseEncoder(c) + defer enc.end() + + enc.Atom("*").SP().Atom("METADATA").SP().Mailbox(mailbox).SP() + listEnc := enc.BeginList() + for entry, value := range entries { + listEnc.Item().String(entry).SP() + if value == nil { + enc.NIL() + } else { + enc.String(string(*value)) + } + } + listEnc.End() + + return enc.CRLF() +} + +func readGetMetadataOption(dec *imapwire.Decoder, name string, options *imap.GetMetadataOptions) error { + switch name { + case "MAXSIZE": + if !dec.ExpectSP() { + return dec.Err() + } + var maxSize uint32 + if !dec.ExpectNumber(&maxSize) { + return dec.Err() + } + options.MaxSize = &maxSize + case "DEPTH": + if !dec.ExpectSP() { + return dec.Err() + } + var depthStr string + if !dec.ExpectAtom(&depthStr) { + return dec.Err() + } + switch strings.ToLower(depthStr) { + case "0": + options.Depth = imap.GetMetadataDepthZero + case "1": + options.Depth = imap.GetMetadataDepthOne + case "infinity": + options.Depth = imap.GetMetadataDepthInfinity + default: + return &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: fmt.Sprintf("Invalid DEPTH value: %s", depthStr), + } + } + default: + return &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: fmt.Sprintf("Unknown GETMETADATA option: %s", name), + } + } + return nil +} diff --git a/imapserver/metadata_test.go b/imapserver/metadata_test.go new file mode 100644 index 00000000..04ae24bb --- /dev/null +++ b/imapserver/metadata_test.go @@ -0,0 +1,231 @@ +package imapserver + +import ( + "strings" + "testing" + + "github.com/emersion/go-imap/v2" +) + +func TestValidateMetadataEntry(t *testing.T) { + tests := []struct { + name string + entry string + wantErr bool + }{ + // Valid entries + { + name: "valid private entry", + entry: "/private/comment", + wantErr: false, + }, + { + name: "valid shared entry", + entry: "/shared/comment", + wantErr: false, + }, + { + name: "valid private nested", + entry: "/private/vendor/cmu/cyrus-imapd/lastpop", + wantErr: false, + }, + { + name: "valid shared nested", + entry: "/shared/vendor/cmu/cyrus-imapd/squat", + wantErr: false, + }, + + // Invalid entries + { + name: "empty entry", + entry: "", + wantErr: true, + }, + { + name: "missing prefix", + entry: "/comment", + wantErr: true, + }, + { + name: "wrong prefix", + entry: "/public/comment", + wantErr: true, + }, + { + name: "contains asterisk wildcard", + entry: "/private/comm*ent", + wantErr: true, + }, + { + name: "contains percent wildcard", + entry: "/private/comm%ent", + wantErr: true, + }, + { + name: "consecutive slashes", + entry: "/private//comment", + wantErr: true, + }, + { + name: "trailing slash", + entry: "/private/comment/", + wantErr: true, + }, + { + name: "base private with slash - allowed", + entry: "/private/", + wantErr: false, + }, + { + name: "base shared with slash - allowed", + entry: "/shared/", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := imap.ValidateMetadataEntry(tt.entry) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateMetadataEntry(%q) error = %v, wantErr %v", tt.entry, err, tt.wantErr) + } + }) + } +} + +func TestReadGetMetadataOption(t *testing.T) { + tests := []struct { + name string + optionName string + input string // Simulated decoder input after option name + wantMaxSize *uint32 + wantDepth imap.GetMetadataDepth + wantErr bool + wantErrContain string + }{ + { + name: "MAXSIZE valid", + optionName: "MAXSIZE", + input: "1024", + wantMaxSize: uint32Ptr(1024), + wantDepth: imap.GetMetadataDepthZero, + }, + { + name: "DEPTH 0", + optionName: "DEPTH", + input: "0", + wantDepth: imap.GetMetadataDepthZero, + }, + { + name: "DEPTH 1", + optionName: "DEPTH", + input: "1", + wantDepth: imap.GetMetadataDepthOne, + }, + { + name: "DEPTH infinity", + optionName: "DEPTH", + input: "infinity", + wantDepth: imap.GetMetadataDepthInfinity, + }, + { + name: "DEPTH INFINITY uppercase", + optionName: "DEPTH", + input: "INFINITY", + wantDepth: imap.GetMetadataDepthInfinity, + }, + { + name: "unknown option", + optionName: "UNKNOWN", + wantErr: true, + wantErrContain: "Unknown GETMETADATA option", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: This is testing the logic, not the actual decoder + // A full test would require mocking the decoder + var options imap.GetMetadataOptions + + // Simulate option parsing behavior + switch strings.ToUpper(tt.optionName) { + case "MAXSIZE": + if tt.input == "1024" { + val := uint32(1024) + options.MaxSize = &val + } + case "DEPTH": + switch strings.ToLower(tt.input) { + case "0": + options.Depth = imap.GetMetadataDepthZero + case "1": + options.Depth = imap.GetMetadataDepthOne + case "infinity": + options.Depth = imap.GetMetadataDepthInfinity + } + case "UNKNOWN": + err := &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: "Unknown GETMETADATA option: UNKNOWN", + } + if !tt.wantErr { + t.Errorf("got error %v, want no error", err) + } + if !strings.Contains(err.Text, tt.wantErrContain) { + t.Errorf("error text %q does not contain %q", err.Text, tt.wantErrContain) + } + return + } + + // Verify results + if tt.wantMaxSize != nil { + if options.MaxSize == nil { + t.Error("MaxSize is nil, want non-nil") + } else if *options.MaxSize != *tt.wantMaxSize { + t.Errorf("MaxSize = %d, want %d", *options.MaxSize, *tt.wantMaxSize) + } + } + + if options.Depth != tt.wantDepth { + t.Errorf("Depth = %v, want %v", options.Depth, tt.wantDepth) + } + }) + } +} + +func TestGetMetadataDepth_String(t *testing.T) { + tests := []struct { + depth imap.GetMetadataDepth + want string + }{ + {imap.GetMetadataDepthZero, "0"}, + {imap.GetMetadataDepthOne, "1"}, + {imap.GetMetadataDepthInfinity, "infinity"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := tt.depth.String() + if got != tt.want { + t.Errorf("GetMetadataDepth(%d).String() = %q, want %q", tt.depth, got, tt.want) + } + }) + } +} + +func TestGetMetadataDepth_String_Invalid(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic for invalid depth, got none") + } + }() + + // Invalid depth value should panic + invalidDepth := imap.GetMetadataDepth(999) + _ = invalidDepth.String() +} + +func uint32Ptr(v uint32) *uint32 { + return &v +} diff --git a/imapserver/session.go b/imapserver/session.go index 35b40e8d..600a2500 100644 --- a/imapserver/session.go +++ b/imapserver/session.go @@ -124,3 +124,29 @@ type SessionAppendLimit interface { // this server in an APPEND command. AppendLimit() uint32 } + +// SessionCapabilities is an IMAP session which can provide its current +// capabilities for capability filtering. +type SessionCapabilities interface { + Session + + // GetCapabilities returns the session-specific capabilities. + // This allows sessions to filter capabilities based on client behavior + // or other session-specific factors. + GetCapabilities() imap.CapSet +} + +// SessionMetadata is an IMAP session which supports the METADATA extension (RFC 5464). +type SessionMetadata interface { + Session + + // GetMetadata retrieves server or mailbox annotations. + // If mailbox is empty string "", retrieve server annotations. + // entries contains the list of entry names to retrieve. + GetMetadata(mailbox string, entries []string, options *imap.GetMetadataOptions) (*imap.GetMetadataData, error) + + // SetMetadata sets or removes server or mailbox annotations. + // If mailbox is empty string "", set server annotations. + // To remove an entry, set its value to nil. + SetMetadata(mailbox string, entries map[string]*[]byte) error +} diff --git a/metadata.go b/metadata.go new file mode 100644 index 00000000..1e248eb7 --- /dev/null +++ b/metadata.go @@ -0,0 +1,105 @@ +package imap + +import "fmt" + +// GetMetadataDepth represents the depth parameter for GETMETADATA command. +type GetMetadataDepth int + +const ( + GetMetadataDepthZero GetMetadataDepth = 0 + GetMetadataDepthOne GetMetadataDepth = 1 + GetMetadataDepthInfinity GetMetadataDepth = -1 +) + +// String returns the string representation of the depth value. +func (depth GetMetadataDepth) String() string { + switch depth { + case GetMetadataDepthZero: + return "0" + case GetMetadataDepthOne: + return "1" + case GetMetadataDepthInfinity: + return "infinity" + default: + panic(fmt.Errorf("imap: unknown GETMETADATA depth %d", depth)) + } +} + +// GetMetadataOptions contains options for the GETMETADATA command. +type GetMetadataOptions struct { + MaxSize *uint32 + Depth GetMetadataDepth +} + +// GetMetadataData is the data returned by the GETMETADATA command. +type GetMetadataData struct { + Mailbox string + Entries map[string]*[]byte + ResponseCodeData *MetadataResponseCodeData // Response code data from server (e.g., LONGENTRIES size) +} + +// MetadataResponseCodeData contains data for METADATA-specific response codes. +type MetadataResponseCodeData struct { + // Size is used with LONGENTRIES and MAXSIZE response codes + Size uint32 +} + +// ValidateMetadataEntry validates a metadata entry name according to RFC 5464. +// Entry names must: +// - Start with /private/ or /shared/ +// - Not contain * or % +// - Not contain consecutive slashes +// - Not end with a slash (unless it's just the prefix) +func ValidateMetadataEntry(entry string) error { + if entry == "" { + return fmt.Errorf("empty entry name") + } + + // Must start with /private/ or /shared/ + if !hasPrefix(entry, "/private/") && !hasPrefix(entry, "/shared/") { + return fmt.Errorf("entry name must start with /private/ or /shared/") + } + + // Cannot contain wildcards + if contains(entry, "*") || contains(entry, "%") { + return fmt.Errorf("entry name cannot contain wildcards") + } + + // Cannot have consecutive slashes + if contains(entry, "//") { + return fmt.Errorf("entry name cannot contain consecutive slashes") + } + + // Cannot end with slash (except for the base /private/ or /shared/) + if entry != "/private/" && entry != "/shared/" && hasSuffix(entry, "/") { + return fmt.Errorf("entry name cannot end with a slash") + } + + return nil +} + +// Helper functions to avoid importing strings package +func hasPrefix(s, prefix string) bool { + return len(s) >= len(prefix) && s[:len(prefix)] == prefix +} + +func hasSuffix(s, suffix string) bool { + return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix +} + +func contains(s, substr string) bool { + return indexOf(s, substr) >= 0 +} + +func indexOf(s, substr string) int { + n := len(substr) + if n == 0 { + return 0 + } + for i := 0; i <= len(s)-n; i++ { + if s[i:i+n] == substr { + return i + } + } + return -1 +} diff --git a/response.go b/response.go index 0ce54cf6..88b5bbc0 100644 --- a/response.go +++ b/response.go @@ -44,8 +44,10 @@ const ( ResponseCodeUnknownCTE ResponseCode = "UNKNOWN-CTE" // METADATA - ResponseCodeTooMany ResponseCode = "TOOMANY" - ResponseCodeNoPrivate ResponseCode = "NOPRIVATE" + ResponseCodeTooMany ResponseCode = "TOOMANY" + ResponseCodeNoPrivate ResponseCode = "NOPRIVATE" + ResponseCodeLongEntries ResponseCode = "LONGENTRIES" + ResponseCodeMaxSize ResponseCode = "MAXSIZE" // APPENDLIMIT ResponseCodeTooBig ResponseCode = "TOOBIG" From bfa51c106aeecc86e569113d5edcb9cbb4815244 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sat, 8 Nov 2025 21:22:01 +0100 Subject: [PATCH 2/4] correctly handle getmetadata options --- imapserver/metadata.go | 79 +++++++++++----- imapserver/metadata_test.go | 179 ++++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 21 deletions(-) diff --git a/imapserver/metadata.go b/imapserver/metadata.go index 113bd942..730ca11b 100644 --- a/imapserver/metadata.go +++ b/imapserver/metadata.go @@ -22,28 +22,12 @@ func (c *Conn) handleGetMetadata(dec *imapwire.Decoder) error { // Try to parse - could be options list or entry list if err := dec.ExpectList(func() error { - // Check if this is options (starts with atom) or entries (starts with astring) + // Check if this is options (MAXSIZE/DEPTH atoms) or entries (astrings starting with /) var first string - if dec.Atom(&first) { - // It's an atom, so this must be options - hasOptions = true - firstUpper := strings.ToUpper(first) - if err := readGetMetadataOption(dec, firstUpper, &options); err != nil { - return err - } - // Continue reading more options if present - for dec.SP() { - var optName string - if !dec.ExpectAtom(&optName) { - return dec.Err() - } - if err := readGetMetadataOption(dec, strings.ToUpper(optName), &options); err != nil { - return err - } - } - return nil - } else if dec.String(&first) || dec.Literal(&first) { - // It's a string, so this is the entry list + + // Try string/literal first (quoted entries) + if dec.String(&first) || dec.Literal(&first) { + // It's a quoted string or literal, so this is the entry list if err := imap.ValidateMetadataEntry(first); err != nil { return &imap.Error{ Type: imap.StatusResponseTypeBad, @@ -66,6 +50,59 @@ func (c *Conn) handleGetMetadata(dec *imapwire.Decoder) error { entries = append(entries, entry) } return nil + } else if dec.Atom(&first) { + // It's an atom - could be option name or unquoted entry + firstUpper := strings.ToUpper(first) + + // Check if it's a known option name + if firstUpper == "MAXSIZE" || firstUpper == "DEPTH" { + // It's an option + hasOptions = true + if err := readGetMetadataOption(dec, firstUpper, &options); err != nil { + return err + } + // Continue reading more options if present + for dec.SP() { + var optName string + if !dec.ExpectAtom(&optName) { + return dec.Err() + } + if err := readGetMetadataOption(dec, strings.ToUpper(optName), &options); err != nil { + return err + } + } + return nil + } else if strings.HasPrefix(first, "/") { + // It's an unquoted entry name (e.g., /private/comment) + if err := imap.ValidateMetadataEntry(first); err != nil { + return &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: err.Error(), + } + } + entries = append(entries, first) + // Continue reading more entries + for dec.SP() { + var entry string + if !dec.ExpectAString(&entry) { + return dec.Err() + } + if err := imap.ValidateMetadataEntry(entry); err != nil { + return &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: err.Error(), + } + } + entries = append(entries, entry) + } + return nil + } else { + // Unknown atom - treat as bad option + return &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: fmt.Sprintf("Unknown GETMETADATA option: %s", firstUpper), + } + } } return dec.Err() }); err != nil { diff --git a/imapserver/metadata_test.go b/imapserver/metadata_test.go index 04ae24bb..39ff1ade 100644 --- a/imapserver/metadata_test.go +++ b/imapserver/metadata_test.go @@ -1,10 +1,12 @@ package imapserver import ( + "bufio" "strings" "testing" "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/internal/imapwire" ) func TestValidateMetadataEntry(t *testing.T) { @@ -226,6 +228,183 @@ func TestGetMetadataDepth_String_Invalid(t *testing.T) { _ = invalidDepth.String() } +// mockMetadataSession implements SessionMetadata for testing +type mockMetadataSession struct { + Session + getMetadataCalled bool + lastMailbox string + lastEntries []string + lastOptions *imap.GetMetadataOptions +} + +func (s *mockMetadataSession) GetMetadata(mailbox string, entries []string, options *imap.GetMetadataOptions) (*imap.GetMetadataData, error) { + s.getMetadataCalled = true + s.lastMailbox = mailbox + s.lastEntries = entries + s.lastOptions = options + + // Return empty response + return &imap.GetMetadataData{ + Mailbox: mailbox, + Entries: make(map[string]*[]byte), + }, nil +} + +func (s *mockMetadataSession) SetMetadata(mailbox string, entries map[string]*[]byte) error { + return nil +} + +// TestHandleGetMetadata_Integration tests the actual GETMETADATA command parsing +// by calling handleGetMetadata directly with test data. +func TestHandleGetMetadata_Integration(t *testing.T) { + tests := []struct { + name string + input string // IMAP wire format after "GETMETADATA " + wantOptions bool + wantEntries []string + wantMaxSize *uint32 + wantDepth imap.GetMetadataDepth + wantErr bool + errContains string + }{ + { + name: "single unquoted entry", + input: ` "" (/private/comment)` + "\r\n", + wantOptions: false, + wantEntries: []string{"/private/comment"}, + }, + { + name: "single quoted entry", + input: ` "" ("/private/comment")` + "\r\n", + wantOptions: false, + wantEntries: []string{"/private/comment"}, + }, + { + name: "multiple unquoted entries", + input: ` "" (/private/comment /shared/comment)` + "\r\n", + wantOptions: false, + wantEntries: []string{"/private/comment", "/shared/comment"}, + }, + { + name: "multiple mixed quoted and unquoted entries", + input: ` "" ("/private/comment" /shared/comment)` + "\r\n", + wantOptions: false, + wantEntries: []string{"/private/comment", "/shared/comment"}, + }, + { + name: "with MAXSIZE option", + input: ` "" (MAXSIZE 1024) (/private/comment)` + "\r\n", + wantOptions: true, + wantEntries: []string{"/private/comment"}, + wantMaxSize: uint32Ptr(1024), + }, + { + name: "with DEPTH option", + input: ` "" (DEPTH 1) (/private/comment)` + "\r\n", + wantOptions: true, + wantEntries: []string{"/private/comment"}, + wantDepth: imap.GetMetadataDepthOne, + }, + { + name: "with multiple options", + input: ` "" (MAXSIZE 1024 DEPTH infinity) (/private/comment /shared/comment)` + "\r\n", + wantOptions: true, + wantEntries: []string{"/private/comment", "/shared/comment"}, + wantMaxSize: uint32Ptr(1024), + wantDepth: imap.GetMetadataDepthInfinity, + }, + { + name: "invalid option name", + input: ` "" (FOOBAR) (/private/comment)` + "\r\n", + wantErr: true, + errContains: "Unknown GETMETADATA option", + }, + { + name: "invalid entry name - no slash prefix", + input: ` "" (invalid)` + "\r\n", + wantErr: true, + errContains: "Unknown GETMETADATA option: INVALID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock session + session := &mockMetadataSession{} + + // Create server connection with mock session + conn := &Conn{ + session: session, + state: imap.ConnStateAuthenticated, // Skip auth for testing + } + + // Parse the input using a decoder + dec := imapwire.NewDecoder(bufio.NewReader(strings.NewReader(tt.input)), 0) + + // Call handleGetMetadata directly + err := conn.handleGetMetadata(dec) + + // Check error expectations + if tt.wantErr { + if err == nil { + t.Errorf("Expected error containing %q, got no error", tt.errContains) + return + } + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("Expected error containing %q, got %q", tt.errContains, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Verify GetMetadata was called + if !session.getMetadataCalled { + t.Fatal("GetMetadata was not called") + } + + // Verify entries + if len(session.lastEntries) != len(tt.wantEntries) { + t.Errorf("Got %d entries, want %d", len(session.lastEntries), len(tt.wantEntries)) + } + for i, want := range tt.wantEntries { + if i >= len(session.lastEntries) { + break + } + if session.lastEntries[i] != want { + t.Errorf("Entry[%d] = %q, want %q", i, session.lastEntries[i], want) + } + } + + // Verify options + if tt.wantOptions && session.lastOptions == nil { + t.Error("Expected options to be present, got nil") + } else if !tt.wantOptions && session.lastOptions != nil { + t.Error("Expected no options, got non-nil") + } + + // Verify specific option values + if tt.wantMaxSize != nil { + if session.lastOptions == nil || session.lastOptions.MaxSize == nil { + t.Error("Expected MaxSize option, got nil") + } else if *session.lastOptions.MaxSize != *tt.wantMaxSize { + t.Errorf("MaxSize = %d, want %d", *session.lastOptions.MaxSize, *tt.wantMaxSize) + } + } + + if tt.wantDepth != imap.GetMetadataDepthZero { + if session.lastOptions == nil { + t.Error("Expected options with Depth, got nil") + } else if session.lastOptions.Depth != tt.wantDepth { + t.Errorf("Depth = %v, want %v", session.lastOptions.Depth, tt.wantDepth) + } + } + }) + } +} + func uint32Ptr(v uint32) *uint32 { return &v } From 881dd3cb03855ac514796076f678a21889546794 Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sat, 8 Nov 2025 21:35:16 +0100 Subject: [PATCH 3/4] support also list entries --- imapserver/metadata.go | 25 ++++++++++++++++++++----- imapserver/metadata_test.go | 14 +++++++++++++- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/imapserver/metadata.go b/imapserver/metadata.go index 730ca11b..9950ac44 100644 --- a/imapserver/metadata.go +++ b/imapserver/metadata.go @@ -14,14 +14,13 @@ func (c *Conn) handleGetMetadata(dec *imapwire.Decoder) error { return dec.Err() } - // Options are optional and start with ATOM (MAXSIZE/DEPTH) - // Entries start with astring (typically quoted string) + // RFC 5464: options are optional, entries can be single or list var options imap.GetMetadataOptions var entries []string hasOptions := false - // Try to parse - could be options list or entry list - if err := dec.ExpectList(func() error { + // Check if it's a list or single entry (RFC 5464: entries = entry / "(" entry *(SP entry) ")") + isList, err := dec.List(func() error { // Check if this is options (MAXSIZE/DEPTH atoms) or entries (astrings starting with /) var first string @@ -105,10 +104,26 @@ func (c *Conn) handleGetMetadata(dec *imapwire.Decoder) error { } } return dec.Err() - }); err != nil { + }) + if err != nil { return err } + // If not a list, parse single entry + if !isList { + var entry string + if !dec.ExpectAString(&entry) { + return dec.Err() + } + if err := imap.ValidateMetadataEntry(entry); err != nil { + return &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: err.Error(), + } + } + entries = append(entries, entry) + } + // If we parsed options, we now need to parse the entry list if hasOptions { if !dec.ExpectSP() { diff --git a/imapserver/metadata_test.go b/imapserver/metadata_test.go index 39ff1ade..6ef58149 100644 --- a/imapserver/metadata_test.go +++ b/imapserver/metadata_test.go @@ -268,7 +268,19 @@ func TestHandleGetMetadata_Integration(t *testing.T) { errContains string }{ { - name: "single unquoted entry", + name: "single unquoted entry without parentheses", + input: ` "" /private/comment` + "\r\n", + wantOptions: false, + wantEntries: []string{"/private/comment"}, + }, + { + name: "single quoted entry without parentheses", + input: ` "" "/private/comment"` + "\r\n", + wantOptions: false, + wantEntries: []string{"/private/comment"}, + }, + { + name: "single unquoted entry with parentheses", input: ` "" (/private/comment)` + "\r\n", wantOptions: false, wantEntries: []string{"/private/comment"}, From 6fe3521bba956cdca5d87e9cd855e6222c55449f Mon Sep 17 00:00:00 2001 From: Dejan Strbac Date: Sat, 8 Nov 2025 22:06:49 +0100 Subject: [PATCH 4/4] correct ordering of optional options before mailbox --- imapclient/metadata.go | 3 +- imapserver/metadata.go | 145 ++++++++++-------------------------- imapserver/metadata_test.go | 30 ++++++-- 3 files changed, 64 insertions(+), 114 deletions(-) diff --git a/imapclient/metadata.go b/imapclient/metadata.go index 51902228..ea2966ff 100644 --- a/imapclient/metadata.go +++ b/imapclient/metadata.go @@ -36,7 +36,7 @@ func (c *Client) GetMetadata(mailbox string, entries []string, options *imap.Get cmd := &GetMetadataCommand{mailbox: mailbox} enc := c.beginCommand("GETMETADATA", cmd) - enc.SP().Mailbox(mailbox) + // RFC 5464: options come before mailbox if opts := getMetadataOptionNames(options); len(opts) > 0 { enc.SP().List(len(opts), func(i int) { opt := opts[i] @@ -51,6 +51,7 @@ func (c *Client) GetMetadata(mailbox string, entries []string, options *imap.Get } }) } + enc.SP().Mailbox(mailbox) enc.SP().List(len(entries), func(i int) { enc.String(entries[i]) }) diff --git a/imapserver/metadata.go b/imapserver/metadata.go index 9950ac44..47baa195 100644 --- a/imapserver/metadata.go +++ b/imapserver/metadata.go @@ -9,108 +9,45 @@ import ( ) func (c *Conn) handleGetMetadata(dec *imapwire.Decoder) error { - var mailbox string - if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || !dec.ExpectSP() { + if !dec.ExpectSP() { return dec.Err() } - // RFC 5464: options are optional, entries can be single or list + // RFC 5464: "GETMETADATA" [SP getmetadata-options] SP mailbox SP entries var options imap.GetMetadataOptions var entries []string hasOptions := false - // Check if it's a list or single entry (RFC 5464: entries = entry / "(" entry *(SP entry) ")") - isList, err := dec.List(func() error { - // Check if this is options (MAXSIZE/DEPTH atoms) or entries (astrings starting with /) - var first string - - // Try string/literal first (quoted entries) - if dec.String(&first) || dec.Literal(&first) { - // It's a quoted string or literal, so this is the entry list - if err := imap.ValidateMetadataEntry(first); err != nil { - return &imap.Error{ - Type: imap.StatusResponseTypeBad, - Text: err.Error(), - } - } - entries = append(entries, first) - // Continue reading more entries - for dec.SP() { - var entry string - if !dec.ExpectAString(&entry) { - return dec.Err() - } - if err := imap.ValidateMetadataEntry(entry); err != nil { - return &imap.Error{ - Type: imap.StatusResponseTypeBad, - Text: err.Error(), - } - } - entries = append(entries, entry) - } - return nil - } else if dec.Atom(&first) { - // It's an atom - could be option name or unquoted entry - firstUpper := strings.ToUpper(first) - - // Check if it's a known option name - if firstUpper == "MAXSIZE" || firstUpper == "DEPTH" { - // It's an option - hasOptions = true - if err := readGetMetadataOption(dec, firstUpper, &options); err != nil { - return err - } - // Continue reading more options if present - for dec.SP() { - var optName string - if !dec.ExpectAtom(&optName) { - return dec.Err() - } - if err := readGetMetadataOption(dec, strings.ToUpper(optName), &options); err != nil { - return err - } - } - return nil - } else if strings.HasPrefix(first, "/") { - // It's an unquoted entry name (e.g., /private/comment) - if err := imap.ValidateMetadataEntry(first); err != nil { - return &imap.Error{ - Type: imap.StatusResponseTypeBad, - Text: err.Error(), - } - } - entries = append(entries, first) - // Continue reading more entries - for dec.SP() { - var entry string - if !dec.ExpectAString(&entry) { - return dec.Err() - } - if err := imap.ValidateMetadataEntry(entry); err != nil { - return &imap.Error{ - Type: imap.StatusResponseTypeBad, - Text: err.Error(), - } - } - entries = append(entries, entry) - } - return nil - } else { - // Unknown atom - treat as bad option - return &imap.Error{ - Type: imap.StatusResponseTypeBad, - Text: fmt.Sprintf("Unknown GETMETADATA option: %s", firstUpper), - } - } + // Check for optional options list + _, err := dec.List(func() error { + // Parse options: MAXSIZE or DEPTH <0|1|infinity> + var optName string + if !dec.ExpectAtom(&optName) { + return dec.Err() } - return dec.Err() + if err := readGetMetadataOption(dec, strings.ToUpper(optName), &options); err != nil { + return err + } + hasOptions = true + return nil }) if err != nil { return err } - // If not a list, parse single entry - if !isList { + // Read mailbox + if hasOptions { + if !dec.ExpectSP() { + return dec.Err() + } + } + var mailbox string + if !dec.ExpectMailbox(&mailbox) || !dec.ExpectSP() { + return dec.Err() + } + + // Parse entries: single entry or list + isList, err := dec.List(func() error { var entry string if !dec.ExpectAString(&entry) { return dec.Err() @@ -122,29 +59,25 @@ func (c *Conn) handleGetMetadata(dec *imapwire.Decoder) error { } } entries = append(entries, entry) + return nil + }) + if err != nil { + return err } - // If we parsed options, we now need to parse the entry list - if hasOptions { - if !dec.ExpectSP() { + // If not a list, parse single entry + if !isList { + var entry string + if !dec.ExpectAString(&entry) { return dec.Err() } - if err := dec.ExpectList(func() error { - var entry string - if !dec.ExpectAString(&entry) { - return dec.Err() - } - if err := imap.ValidateMetadataEntry(entry); err != nil { - return &imap.Error{ - Type: imap.StatusResponseTypeBad, - Text: err.Error(), - } + if err := imap.ValidateMetadataEntry(entry); err != nil { + return &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: err.Error(), } - entries = append(entries, entry) - return nil - }); err != nil { - return err } + entries = append(entries, entry) } if !dec.ExpectCRLF() { diff --git a/imapserver/metadata_test.go b/imapserver/metadata_test.go index 6ef58149..b00cf30b 100644 --- a/imapserver/metadata_test.go +++ b/imapserver/metadata_test.go @@ -305,29 +305,44 @@ func TestHandleGetMetadata_Integration(t *testing.T) { }, { name: "with MAXSIZE option", - input: ` "" (MAXSIZE 1024) (/private/comment)` + "\r\n", + input: ` (MAXSIZE 1024) "" (/private/comment)` + "\r\n", wantOptions: true, wantEntries: []string{"/private/comment"}, wantMaxSize: uint32Ptr(1024), }, { name: "with DEPTH option", - input: ` "" (DEPTH 1) (/private/comment)` + "\r\n", + input: ` (DEPTH 1) "" (/private/comment)` + "\r\n", wantOptions: true, wantEntries: []string{"/private/comment"}, wantDepth: imap.GetMetadataDepthOne, }, { - name: "with multiple options", - input: ` "" (MAXSIZE 1024 DEPTH infinity) (/private/comment /shared/comment)` + "\r\n", + name: "with multiple options and single entry", + input: ` (MAXSIZE 1024 DEPTH infinity) "" (/private/comment)` + "\r\n", + wantOptions: true, + wantEntries: []string{"/private/comment"}, + wantMaxSize: uint32Ptr(1024), + wantDepth: imap.GetMetadataDepthInfinity, + }, + { + name: "with multiple options and multiple entries", + input: ` (MAXSIZE 1024 DEPTH infinity) "" (/private/comment /shared/comment)` + "\r\n", wantOptions: true, wantEntries: []string{"/private/comment", "/shared/comment"}, wantMaxSize: uint32Ptr(1024), wantDepth: imap.GetMetadataDepthInfinity, }, + { + name: "with DEPTH option and three entries", + input: ` (DEPTH 1) "" (/private/comment /shared/comment /private/title)` + "\r\n", + wantOptions: true, + wantEntries: []string{"/private/comment", "/shared/comment", "/private/title"}, + wantDepth: imap.GetMetadataDepthOne, + }, { name: "invalid option name", - input: ` "" (FOOBAR) (/private/comment)` + "\r\n", + input: ` (FOOBAR) "" (/private/comment)` + "\r\n", wantErr: true, errContains: "Unknown GETMETADATA option", }, @@ -335,7 +350,7 @@ func TestHandleGetMetadata_Integration(t *testing.T) { name: "invalid entry name - no slash prefix", input: ` "" (invalid)` + "\r\n", wantErr: true, - errContains: "Unknown GETMETADATA option: INVALID", + errContains: "entry name must start with", }, } @@ -379,10 +394,11 @@ func TestHandleGetMetadata_Integration(t *testing.T) { // Verify entries if len(session.lastEntries) != len(tt.wantEntries) { - t.Errorf("Got %d entries, want %d", len(session.lastEntries), len(tt.wantEntries)) + t.Errorf("Got %d entries, want %d. Entries: %v", len(session.lastEntries), len(tt.wantEntries), session.lastEntries) } for i, want := range tt.wantEntries { if i >= len(session.lastEntries) { + t.Errorf("Missing entry[%d] = %q", i, want) break } if session.lastEntries[i] != want {