diff --git a/acl.go b/acl.go index 4d9431e9..4bbdc7e6 100644 --- a/acl.go +++ b/acl.go @@ -5,26 +5,35 @@ import ( "strings" ) -// IMAP4 ACL extension (RFC 2086) +// IMAP4 ACL extension (RFC 4314, obsoletes RFC 2086) // Right describes a set of operations controlled by the IMAP ACL extension. type Right byte const ( - // Standard rights - RightLookup = Right('l') // mailbox is visible to LIST/LSUB commands - RightRead = Right('r') // SELECT the mailbox, perform CHECK, FETCH, PARTIAL, SEARCH, COPY from mailbox - RightSeen = Right('s') // keep seen/unseen information across sessions (STORE SEEN flag) - RightWrite = Right('w') // STORE flags other than SEEN and DELETED - RightInsert = Right('i') // perform APPEND, COPY into mailbox - RightPost = Right('p') // send mail to submission address for mailbox, not enforced by IMAP4 itself - RightCreate = Right('c') // CREATE new sub-mailboxes in any implementation-defined hierarchy - RightDelete = Right('d') // STORE DELETED flag, perform EXPUNGE - RightAdminister = Right('a') // perform SETACL + // Standard rights (RFC 4314 Section 2) + RightLookup = Right('l') // mailbox is visible to LIST/LSUB commands + RightRead = Right('r') // SELECT the mailbox, perform CHECK, FETCH, PARTIAL, SEARCH, COPY from mailbox + RightSeen = Right('s') // keep seen/unseen information across sessions (STORE SEEN flag) + RightWrite = Right('w') // STORE flags other than SEEN and DELETED + RightInsert = Right('i') // perform APPEND, COPY into mailbox + RightPost = Right('p') // send mail to submission address for mailbox, not enforced by IMAP4 itself + RightCreateChild = Right('k') // CREATE new sub-mailboxes (new in RFC 4314, replaces 'c') + RightDeleteMbox = Right('x') // DELETE mailbox (new in RFC 4314, replaces 'd' for mailbox deletion) + RightDeleteMsg = Right('t') // STORE DELETED flag (new in RFC 4314) + RightExpunge = Right('e') // perform EXPUNGE (new in RFC 4314, split from 'd') + RightAdminister = Right('a') // perform SETACL, DELETEACL, GETACL, LISTRIGHTS + + // Obsolete rights from RFC 2086 (still supported for backwards compatibility) + RightCreate = Right('c') // obsolete, use RightCreateChild instead + RightDelete = Right('d') // obsolete, use RightDeleteMsg + RightExpunge instead ) -// RightSetAll contains all standard rights. -var RightSetAll = RightSet("lrswipcda") +// RightSetAll contains all standard rights (RFC 4314). +var RightSetAll = RightSet("lrswipkxtea") + +// RightSetAllCompat contains all rights including obsolete RFC 2086 rights. +var RightSetAllCompat = RightSet("lrswipkxteacd") // RightsIdentifier is an ACL identifier. type RightsIdentifier string @@ -102,3 +111,29 @@ func (rs1 RightSet) Equal(rs2 RightSet) bool { return true } + +// ACLEntry represents a single ACL entry for a mailbox. +type ACLEntry struct { + Identifier RightsIdentifier // User identifier (email address, group, "anyone", etc.) + Rights RightSet // Rights granted to this identifier +} + +// GetACLData represents the response to a GETACL command. +type GetACLData struct { + Mailbox string // Mailbox name + ACL []ACLEntry // List of ACL entries +} + +// ListRightsData represents the response to a LISTRIGHTS command. +type ListRightsData struct { + Mailbox string // Mailbox name + Identifier RightsIdentifier // User identifier + RequiredRights RightSet // Rights that are always granted (usually empty) + OptionalRights []RightSet // Groups of optional rights that may be granted +} + +// MyRightsData represents the response to a MYRIGHTS command. +type MyRightsData struct { + Mailbox string // Mailbox name + Rights RightSet // Rights the user has on this mailbox +} diff --git a/imapclient/acl.go b/imapclient/acl.go index b20be3b7..580042c5 100644 --- a/imapclient/acl.go +++ b/imapclient/acl.go @@ -40,6 +40,47 @@ func (cmd *SetACLCommand) Wait() error { return cmd.wait() } +// DeleteACL sends a DELETEACL command. +// +// This command requires support for the ACL extension. +func (c *Client) DeleteACL(mailbox string, ri imap.RightsIdentifier) *DeleteACLCommand { + cmd := &DeleteACLCommand{} + enc := c.beginCommand("DELETEACL", cmd) + enc.SP().Mailbox(mailbox).SP().String(string(ri)) + enc.end() + return cmd +} + +// DeleteACLCommand is a DELETEACL command. +type DeleteACLCommand struct { + commandBase +} + +func (cmd *DeleteACLCommand) Wait() error { + return cmd.wait() +} + +// ListRights sends a LISTRIGHTS command. +// +// This command requires support for the ACL extension. +func (c *Client) ListRights(mailbox string, ri imap.RightsIdentifier) *ListRightsCommand { + cmd := &ListRightsCommand{} + enc := c.beginCommand("LISTRIGHTS", cmd) + enc.SP().Mailbox(mailbox).SP().String(string(ri)) + enc.end() + return cmd +} + +// ListRightsCommand is a LISTRIGHTS command. +type ListRightsCommand struct { + commandBase + data ListRightsData +} + +func (cmd *ListRightsCommand) Wait() (*ListRightsData, error) { + return &cmd.data, cmd.wait() +} + // GetACL sends a GETACL command. // // This command requires support for the ACL extension. @@ -83,6 +124,17 @@ func (c *Client) handleGetACL() error { return nil } +func (c *Client) handleListRights() error { + data, err := readListRights(c.dec) + if err != nil { + return fmt.Errorf("in listrights-response: %v", err) + } + if cmd := findPendingCmdByType[*ListRightsCommand](c); cmd != nil { + cmd.data = *data + } + return nil +} + // MyRightsCommand is a MYRIGHTS command. type MyRightsCommand struct { commandBase @@ -136,3 +188,39 @@ func readGetACL(dec *imapwire.Decoder) (*GetACLData, error) { return data, nil } + +// ListRightsData is the data returned by the LISTRIGHTS command. +type ListRightsData struct { + Mailbox string + Identifier imap.RightsIdentifier + RequiredRights imap.RightSet + OptionalRights []imap.RightSet +} + +func readListRights(dec *imapwire.Decoder) (*ListRightsData, error) { + var ( + data ListRightsData + identifierStr string + requiredStr string + ) + + if !dec.ExpectMailbox(&data.Mailbox) || !dec.ExpectSP() || + !dec.ExpectAString(&identifierStr) || !dec.ExpectSP() || + !dec.ExpectAString(&requiredStr) { + return nil, dec.Err() + } + + data.Identifier = imap.RightsIdentifier(identifierStr) + data.RequiredRights = imap.RightSet(requiredStr) + + // Read optional rights groups + for dec.SP() { + var optionalStr string + if !dec.ExpectAString(&optionalStr) { + return nil, dec.Err() + } + data.OptionalRights = append(data.OptionalRights, imap.RightSet(optionalStr)) + } + + return &data, nil +} diff --git a/imapclient/acl_test.go b/imapclient/acl_test.go index 34a62f41..a4f125a6 100644 --- a/imapclient/acl_test.go +++ b/imapclient/acl_test.go @@ -1,6 +1,7 @@ package imapclient_test import ( + "strings" "testing" "github.com/emersion/go-imap/v2" @@ -113,3 +114,297 @@ func TestACL(t *testing.T) { } }) } + +// TestDeleteACL tests the DELETEACL command +func TestDeleteACL(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapACL) { + t.Skipf("server doesn't support ACL") + } + + mailbox := "INBOX" + identifier := imap.RightsIdentifier("testuser2") + + // First, set some rights + err := client.SetACL(mailbox, identifier, imap.RightModificationReplace, imap.RightSet("lr")).Wait() + if err != nil { + t.Fatalf("SetACL().Wait() error: %v", err) + } + + // Verify rights were set + getACLData, err := client.GetACL(mailbox).Wait() + if err != nil { + t.Fatalf("GetACL().Wait() error: %v", err) + } + + if _, ok := getACLData.Rights[identifier]; !ok { + t.Fatalf("Rights not set for identifier %s", identifier) + } + + // Delete the ACL entry + err = client.DeleteACL(mailbox, identifier).Wait() + if err != nil { + t.Fatalf("DeleteACL().Wait() error: %v", err) + } + + // Verify rights were deleted + getACLData, err = client.GetACL(mailbox).Wait() + if err != nil { + t.Fatalf("GetACL().Wait() error: %v", err) + } + + if rights, ok := getACLData.Rights[identifier]; ok && len(rights) > 0 { + t.Errorf("Rights still exist for identifier %s after DeleteACL: %s", identifier, rights) + } + + // Test deleting non-existent ACL (should not error) + err = client.DeleteACL(mailbox, imap.RightsIdentifier("nonexistent")).Wait() + if err != nil { + t.Errorf("DeleteACL() for non-existent identifier returned error: %v", err) + } + + // Test with non-existent mailbox + err = client.DeleteACL("NonExistentMailbox", identifier).Wait() + if err == nil { + t.Errorf("DeleteACL() for non-existent mailbox should return error") + } +} + +// TestListRights tests the LISTRIGHTS command +func TestListRights(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapACL) { + t.Skipf("server doesn't support ACL") + } + + mailbox := "INBOX" + identifier := imap.RightsIdentifier(testUsername) + + // Execute LISTRIGHTS command + listRightsData, err := client.ListRights(mailbox, identifier).Wait() + if err != nil { + t.Fatalf("ListRights().Wait() error: %v", err) + } + + // Verify we got data back + if listRightsData.Mailbox != mailbox { + t.Errorf("ListRights returned wrong mailbox: expected %s, got %s", mailbox, listRightsData.Mailbox) + } + + if listRightsData.Identifier != identifier { + t.Errorf("ListRights returned wrong identifier: expected %s, got %s", identifier, listRightsData.Identifier) + } + + // RequiredRights is usually empty, but OptionalRights should have some rights + if len(listRightsData.OptionalRights) == 0 { + t.Errorf("ListRights returned no optional rights") + } + + // Test with non-existent mailbox - some servers like Dovecot may not return an error + _, err = client.ListRights("NonExistentMailbox", imap.RightsIdentifier("nonexistent")).Wait() + if err != nil { + t.Logf("ListRights() for non-existent mailbox returned error (as expected): %v", err) + } +} + +// TestACLMultipleIdentifiers tests ACL with multiple identifiers +func TestACLMultipleIdentifiers(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapACL) { + t.Skipf("server doesn't support ACL") + } + + if err := client.Create("SharedFolder", nil).Wait(); err != nil { + t.Fatalf("create SharedFolder error: %v", err) + } + + mailbox := "SharedFolder" + identifiers := []imap.RightsIdentifier{ + imap.RightsIdentifier(testUsername), + imap.RightsIdentifier("user2@example.com"), + imap.RightsIdentifier("user3@example.com"), + } + + // Set rights for multiple identifiers + for i, identifier := range identifiers { + rights := imap.RightSet("lr") + if i == 0 { + rights = imap.RightSet("lrswipkxtea") // Full rights for owner + } + + err := client.SetACL(mailbox, identifier, imap.RightModificationReplace, rights).Wait() + if err != nil { + t.Fatalf("SetACL() for %s error: %v", identifier, err) + } + } + + // Test 'anyone' identifier separately as some servers (like Dovecot) may disallow it + err := client.SetACL(mailbox, imap.RightsIdentifierAnyone, imap.RightModificationReplace, imap.RightSet("lr")).Wait() + if err != nil { + t.Logf("SetACL() for 'anyone' returned error (some servers disallow it): %v", err) + } else { + // If it succeeded, add it to our identifiers list for verification + identifiers = append(identifiers, imap.RightsIdentifierAnyone) + } + + // Get and verify all ACLs + getACLData, err := client.GetACL(mailbox).Wait() + if err != nil { + t.Fatalf("GetACL().Wait() error: %v", err) + } + + if len(getACLData.Rights) != len(identifiers) { + t.Errorf("Expected %d ACL entries, got %d", len(identifiers), len(getACLData.Rights)) + } + + for _, identifier := range identifiers { + if _, ok := getACLData.Rights[identifier]; !ok { + t.Errorf("Missing ACL entry for identifier %s", identifier) + } + } + + // Test modifying specific identifier + err = client.SetACL(mailbox, identifiers[1], imap.RightModificationAdd, imap.RightSet("w")).Wait() + if err != nil { + t.Fatalf("SetACL() add rights error: %v", err) + } + + getACLData, err = client.GetACL(mailbox).Wait() + if err != nil { + t.Fatalf("GetACL().Wait() error: %v", err) + } + + expectedRights := imap.RightSet("lrw") + if !expectedRights.Equal(getACLData.Rights[identifiers[1]]) { + t.Errorf("Rights after add: expected %s, got %s", expectedRights, getACLData.Rights[identifiers[1]]) + } +} + +// TestACLRFC4314Rights tests RFC 4314 specific rights +func TestACLRFC4314Rights(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapACL) { + t.Skipf("server doesn't support ACL") + } + + mailbox := "INBOX" + identifier := imap.RightsIdentifier(testUsername) + + // Test new RFC 4314 rights: k, x, t, e (include 'a' to maintain admin rights) + newRights := imap.RightSet("kxtea") + err := client.SetACL(mailbox, identifier, imap.RightModificationReplace, newRights).Wait() + if err != nil { + t.Fatalf("SetACL() with RFC 4314 rights error: %v", err) + } + + getACLData, err := client.GetACL(mailbox).Wait() + if err != nil { + t.Fatalf("GetACL().Wait() error: %v", err) + } + + // Some servers (like Dovecot) automatically map obsolete rights c/d when setting new rights + // So we check that at minimum the requested rights are present + gotRights := getACLData.Rights[identifier] + for _, r := range string(newRights) { + if !strings.ContainsRune(string(gotRights), rune(r)) { + t.Errorf("RFC 4314 rights: expected to have right %c in %s", r, gotRights) + } + } + + // Test that obsolete rights (c, d) still work + obsoleteRights := imap.RightSet("cd") + err = client.SetACL(mailbox, identifier, imap.RightModificationAdd, obsoleteRights).Wait() + if err != nil { + t.Fatalf("SetACL() with obsolete rights error: %v", err) + } + + myRightsData, err := client.MyRights(mailbox).Wait() + if err != nil { + t.Fatalf("MyRights().Wait() error: %v", err) + } + + // Verify obsolete rights were added AND translated to modern equivalents + // When 'c' is added, 'k' should also be present (may already be there) + // When 'd' is added, 't' and 'e' should also be present + if !strings.Contains(string(myRightsData.Rights), "c") { + t.Errorf("Obsolete right 'c' not found in rights: %s", myRightsData.Rights) + } + if !strings.Contains(string(myRightsData.Rights), "d") { + t.Errorf("Obsolete right 'd' not found in rights: %s", myRightsData.Rights) + } + if !strings.Contains(string(myRightsData.Rights), "k") { + t.Errorf("Modern equivalent 'k' for obsolete 'c' not found in rights: %s", myRightsData.Rights) + } + if !strings.Contains(string(myRightsData.Rights), "t") { + t.Errorf("Modern equivalent 't' for obsolete 'd' not found in rights: %s", myRightsData.Rights) + } + if !strings.Contains(string(myRightsData.Rights), "e") { + t.Errorf("Modern equivalent 'e' for obsolete 'd' not found in rights: %s", myRightsData.Rights) + } +} + +// TestACLObsoleteRightsTranslation specifically tests that obsolete RFC 2086 rights +// are translated to their RFC 4314 equivalents +func TestACLObsoleteRightsTranslation(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + if !client.Caps().Has(imap.CapACL) { + t.Skipf("server doesn't support ACL") + } + + mailbox := "INBOX" + identifier := imap.RightsIdentifier(testUsername) + + // Set only obsolete right 'c' - should also grant 'k' + err := client.SetACL(mailbox, identifier, imap.RightModificationReplace, imap.RightSet("ca")).Wait() + if err != nil { + t.Fatalf("SetACL() with obsolete 'c' error: %v", err) + } + + myRightsData, err := client.MyRights(mailbox).Wait() + if err != nil { + t.Fatalf("MyRights().Wait() error: %v", err) + } + + if !strings.Contains(string(myRightsData.Rights), "c") { + t.Errorf("Obsolete right 'c' not stored: %s", myRightsData.Rights) + } + if !strings.Contains(string(myRightsData.Rights), "k") { + t.Errorf("Setting obsolete 'c' should also grant modern 'k': %s", myRightsData.Rights) + } + + // Set only obsolete right 'd' - should also grant 't' and 'e' + err = client.SetACL(mailbox, identifier, imap.RightModificationReplace, imap.RightSet("da")).Wait() + if err != nil { + t.Fatalf("SetACL() with obsolete 'd' error: %v", err) + } + + myRightsData, err = client.MyRights(mailbox).Wait() + if err != nil { + t.Fatalf("MyRights().Wait() error: %v", err) + } + + if !strings.Contains(string(myRightsData.Rights), "d") { + t.Errorf("Obsolete right 'd' not stored: %s", myRightsData.Rights) + } + if !strings.Contains(string(myRightsData.Rights), "t") { + t.Errorf("Setting obsolete 'd' should also grant modern 't': %s", myRightsData.Rights) + } + if !strings.Contains(string(myRightsData.Rights), "e") { + t.Errorf("Setting obsolete 'd' should also grant modern 'e': %s", myRightsData.Rights) + } +} diff --git a/imapclient/client.go b/imapclient/client.go index 4933d2fa..adec3452 100644 --- a/imapclient/client.go +++ b/imapclient/client.go @@ -1015,6 +1015,11 @@ func (c *Client) readResponseData(typ string) error { return c.dec.Err() } return c.handleGetACL() + case "LISTRIGHTS": + if !c.dec.ExpectSP() { + return c.dec.Err() + } + return c.handleListRights() default: return fmt.Errorf("unsupported response type %q", typ) } diff --git a/imapserver/acl.go b/imapserver/acl.go new file mode 100644 index 00000000..1d6e35f4 --- /dev/null +++ b/imapserver/acl.go @@ -0,0 +1,48 @@ +package imapserver + +import "github.com/emersion/go-imap/v2" + +// SessionACL is an IMAP session which supports the ACL extension (RFC 4314). +// +// This extension allows clients to manage access control lists for mailboxes, +// enabling shared mailbox functionality with fine-grained permissions. +type SessionACL interface { + Session + + // GetACL retrieves the access control list for a mailbox. + // Returns the mailbox name and list of ACL entries. + // + // The user must have either the 'l' (lookup) or 'a' (admin) right on the mailbox. + GetACL(mailbox string) (*imap.GetACLData, error) + + // SetACL sets or modifies the access control list for a mailbox. + // The modification parameter determines how the rights are applied: + // - RightModificationReplace: Replace all rights for the identifier + // - RightModificationAdd: Add the specified rights to existing rights + // - RightModificationRemove: Remove the specified rights from existing rights + // + // To remove all rights for an identifier, use RightModificationReplace with an empty rights set. + // + // The user must have the 'a' (admin) right on the mailbox. + // + // identifier: User email, group name, or special identifier ("anyone", "authenticated") + // modification: How to apply the rights (replace, add, or remove) + // rights: Rights to grant/add/remove + SetACL(mailbox string, identifier imap.RightsIdentifier, modification imap.RightModification, rights imap.RightSet) error + + // DeleteACL removes the access control list entry for an identifier. + // This is equivalent to SetACL with RightModificationReplace and empty rights. + // + // The user must have the 'a' (admin) right on the mailbox. + DeleteACL(mailbox string, identifier imap.RightsIdentifier) error + + // ListRights lists the rights that can be granted to an identifier on a mailbox. + // Returns required rights (always present) and groups of optional rights (may be granted). + // + // The user must have the 'a' (admin) right on the mailbox. + ListRights(mailbox string, identifier imap.RightsIdentifier) (*imap.ListRightsData, error) + + // MyRights returns the rights the current user has on a mailbox. + // This command does not require any special permissions - any user can check their own rights. + MyRights(mailbox string) (*imap.MyRightsData, error) +} diff --git a/imapserver/capability.go b/imapserver/capability.go index 37da104b..af3d8865 100644 --- a/imapserver/capability.go +++ b/imapserver/capability.go @@ -95,6 +95,11 @@ func (c *Conn) availableCaps() []imap.Cap { imap.CapUnauthenticate, }) + // Add ACL capability if the session supports it + if _, ok := c.session.(SessionACL); ok { + caps = append(caps, imap.Cap("ACL")) + } + if appendLimitSession, ok := c.session.(SessionAppendLimit); ok { limit := appendLimitSession.AppendLimit() caps = append(caps, imap.Cap(fmt.Sprintf("APPENDLIMIT=%d", limit))) diff --git a/imapserver/cmd_acl.go b/imapserver/cmd_acl.go new file mode 100644 index 00000000..d63831e7 --- /dev/null +++ b/imapserver/cmd_acl.go @@ -0,0 +1,181 @@ +package imapserver + +import ( + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/internal" + "github.com/emersion/go-imap/v2/internal/imapwire" +) + +func (c *Conn) handleGetACL(dec *imapwire.Decoder) error { + var mailbox string + if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || !dec.ExpectCRLF() { + return dec.Err() + } + + if err := c.checkState(imap.ConnStateAuthenticated); err != nil { + return err + } + + session, ok := c.session.(SessionACL) + if !ok { + return newClientBugError("ACL extension is not supported") + } + + data, err := session.GetACL(mailbox) + if err != nil { + return err + } + + return c.writeGetACL(data) +} + +func (c *Conn) handleSetACL(dec *imapwire.Decoder) error { + var mailbox, identifierStr, rightsStr string + if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || + !dec.ExpectSP() || !dec.ExpectAString(&identifierStr) || + !dec.ExpectSP() || !dec.ExpectAString(&rightsStr) || + !dec.ExpectCRLF() { + return dec.Err() + } + + if err := c.checkState(imap.ConnStateAuthenticated); err != nil { + return err + } + + session, ok := c.session.(SessionACL) + if !ok { + return newClientBugError("ACL extension is not supported") + } + + // Parse rights modification (+ or - prefix, or replace) + modification := imap.RightModificationReplace + rights := imap.RightSet(rightsStr) + if len(rightsStr) > 0 { + switch rightsStr[0] { + case '+': + modification = imap.RightModificationAdd + rights = imap.RightSet(rightsStr[1:]) + case '-': + modification = imap.RightModificationRemove + rights = imap.RightSet(rightsStr[1:]) + } + } + + identifier := imap.RightsIdentifier(identifierStr) + return session.SetACL(mailbox, identifier, modification, rights) +} + +func (c *Conn) handleDeleteACL(dec *imapwire.Decoder) error { + var mailbox, identifierStr string + if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || + !dec.ExpectSP() || !dec.ExpectAString(&identifierStr) || + !dec.ExpectCRLF() { + return dec.Err() + } + + if err := c.checkState(imap.ConnStateAuthenticated); err != nil { + return err + } + + session, ok := c.session.(SessionACL) + if !ok { + return newClientBugError("ACL extension is not supported") + } + + identifier := imap.RightsIdentifier(identifierStr) + return session.DeleteACL(mailbox, identifier) +} + +func (c *Conn) handleListRights(dec *imapwire.Decoder) error { + var mailbox, identifierStr string + if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || + !dec.ExpectSP() || !dec.ExpectAString(&identifierStr) || + !dec.ExpectCRLF() { + return dec.Err() + } + + if err := c.checkState(imap.ConnStateAuthenticated); err != nil { + return err + } + + session, ok := c.session.(SessionACL) + if !ok { + return newClientBugError("ACL extension is not supported") + } + + identifier := imap.RightsIdentifier(identifierStr) + data, err := session.ListRights(mailbox, identifier) + if err != nil { + return err + } + + return c.writeListRights(data) +} + +func (c *Conn) handleMyRights(dec *imapwire.Decoder) error { + var mailbox string + if !dec.ExpectSP() || !dec.ExpectMailbox(&mailbox) || !dec.ExpectCRLF() { + return dec.Err() + } + + if err := c.checkState(imap.ConnStateAuthenticated); err != nil { + return err + } + + session, ok := c.session.(SessionACL) + if !ok { + return newClientBugError("ACL extension is not supported") + } + + data, err := session.MyRights(mailbox) + if err != nil { + return err + } + + return c.writeMyRights(data) +} + +func (c *Conn) writeGetACL(data *imap.GetACLData) error { + enc := newResponseEncoder(c) + defer enc.end() + + enc.Atom("*").SP().Atom("ACL").SP().Mailbox(data.Mailbox) + for i := range data.ACL { + entry := &data.ACL[i] + enc.SP().String(string(entry.Identifier)).SP().String(string(entry.Rights)) + } + return enc.CRLF() +} + +func (c *Conn) writeListRights(data *imap.ListRightsData) error { + enc := newResponseEncoder(c) + defer enc.end() + + enc.Atom("*").SP().Atom("LISTRIGHTS").SP(). + Mailbox(data.Mailbox).SP(). + String(string(data.Identifier)).SP(). + String(string(data.RequiredRights)) + + // Write optional rights groups + for i := range data.OptionalRights { + enc.SP().String(string(data.OptionalRights[i])) + } + + return enc.CRLF() +} + +func (c *Conn) writeMyRights(data *imap.MyRightsData) error { + enc := newResponseEncoder(c) + defer enc.end() + + enc.Atom("*").SP().Atom("MYRIGHTS").SP(). + Mailbox(data.Mailbox).SP(). + String(string(data.Rights)) + + return enc.CRLF() +} + +// For backwards compatibility, keep the old SETACL format helper +func formatRights(rm imap.RightModification, rs imap.RightSet) string { + return internal.FormatRights(rm, rs) +} diff --git a/imapserver/conn.go b/imapserver/conn.go index 291f37ec..7f99a7f7 100644 --- a/imapserver/conn.go +++ b/imapserver/conn.go @@ -251,6 +251,16 @@ func (c *Conn) readCommand(dec *imapwire.Decoder) error { err = c.handleLSub(dec) case "NAMESPACE": err = c.handleNamespace(dec) + case "GETACL": + err = c.handleGetACL(dec) + case "SETACL": + err = c.handleSetACL(dec) + case "DELETEACL": + err = c.handleDeleteACL(dec) + case "LISTRIGHTS": + err = c.handleListRights(dec) + case "MYRIGHTS": + err = c.handleMyRights(dec) case "IDLE": err = c.handleIdle(dec) case "SELECT", "EXAMINE": diff --git a/imapserver/imapmemserver/mailbox.go b/imapserver/imapmemserver/mailbox.go index dee9a67b..364108c0 100644 --- a/imapserver/imapmemserver/mailbox.go +++ b/imapserver/imapmemserver/mailbox.go @@ -24,6 +24,7 @@ type Mailbox struct { specialUse []imap.MailboxAttr l []*message uidNext imap.UID + acl map[imap.RightsIdentifier]imap.RightSet } // NewMailbox creates a new mailbox. @@ -33,6 +34,7 @@ func NewMailbox(name string, uidValidity uint32) *Mailbox { uidValidity: uidValidity, name: name, uidNext: 1, + acl: make(map[imap.RightsIdentifier]imap.RightSet), } } diff --git a/imapserver/imapmemserver/session.go b/imapserver/imapmemserver/session.go index 70e9d2f8..5ec5653c 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" ) @@ -19,7 +21,10 @@ type UserSession struct { *mailbox // may be nil } -var _ imapserver.SessionIMAP4rev2 = (*UserSession)(nil) +var ( + _ imapserver.SessionIMAP4rev2 = (*UserSession)(nil) + _ imapserver.SessionACL = (*UserSession)(nil) +) // NewUserSession creates a new user session. func NewUserSession(user *User) *UserSession { @@ -138,3 +143,126 @@ func (sess *UserSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) } return sess.mailbox.Idle(w, stop) } + +// GetACL retrieves the access control list for a mailbox +func (sess *UserSession) GetACL(name string) (*imap.GetACLData, error) { + mbox, err := sess.user.mailbox(name) + if err != nil { + return nil, err + } + + mbox.mutex.Lock() + defer mbox.mutex.Unlock() + + // Return ACL entries (for test purposes, we grant full rights to the current user) + entries := []imap.ACLEntry{ + { + Identifier: imap.RightsIdentifier(sess.user.username), + Rights: mbox.acl[imap.RightsIdentifier(sess.user.username)], + }, + } + + // Add other ACL entries + for identifier, rights := range mbox.acl { + if identifier != imap.RightsIdentifier(sess.user.username) { + entries = append(entries, imap.ACLEntry{ + Identifier: identifier, + Rights: rights, + }) + } + } + + return &imap.GetACLData{ + Mailbox: name, + ACL: entries, + }, nil +} + +// SetACL sets or modifies the access control list for a mailbox +func (sess *UserSession) SetACL(name string, identifier imap.RightsIdentifier, modification imap.RightModification, rights imap.RightSet) error { + mbox, err := sess.user.mailbox(name) + if err != nil { + return err + } + + mbox.mutex.Lock() + defer mbox.mutex.Unlock() + + // Check if user has admin rights + userRights := mbox.acl[imap.RightsIdentifier(sess.user.username)] + hasAdmin := false + for _, r := range userRights { + if r == imap.RightAdminister { + hasAdmin = true + break + } + } + if !hasAdmin { + return &imap.Error{ + Type: imap.StatusResponseTypeNo, + Text: "Permission denied: admin right required", + } + } + + // Apply modification + currentRights := mbox.acl[identifier] + + // Handle obsolete rights for backwards compatibility + if strings.Contains(string(rights), "c") { + rights = rights.Add(imap.RightSet("k")) + } + + if strings.Contains(string(rights), "d") { + rights = rights.Add(imap.RightSet("te")) + } + + switch modification { + case imap.RightModificationReplace: + mbox.acl[identifier] = rights + case imap.RightModificationAdd: + mbox.acl[identifier] = currentRights.Add(rights) + case imap.RightModificationRemove: + mbox.acl[identifier] = currentRights.Remove(rights) + } + + return nil +} + +// DeleteACL removes the access control list entry for an identifier +func (sess *UserSession) DeleteACL(name string, identifier imap.RightsIdentifier) error { + return sess.SetACL(name, identifier, imap.RightModificationReplace, nil) +} + +// ListRights lists the rights that can be granted to an identifier on a mailbox +func (sess *UserSession) ListRights(name string, identifier imap.RightsIdentifier) (*imap.ListRightsData, error) { + _, err := sess.user.mailbox(name) + if err != nil { + return nil, err + } + + // For test purposes, return all rights as optional + return &imap.ListRightsData{ + Mailbox: name, + Identifier: identifier, + RequiredRights: imap.RightSet(""), + OptionalRights: []imap.RightSet{imap.RightSetAll}, + }, nil +} + +// MyRights returns the rights the current user has on a mailbox +func (sess *UserSession) MyRights(name string) (*imap.MyRightsData, error) { + mbox, err := sess.user.mailbox(name) + if err != nil { + return nil, err + } + + mbox.mutex.Lock() + defer mbox.mutex.Unlock() + + rights := mbox.acl[imap.RightsIdentifier(sess.user.username)] + + return &imap.MyRightsData{ + Mailbox: name, + Rights: rights, + }, nil +} diff --git a/imapserver/imapmemserver/user.go b/imapserver/imapmemserver/user.go index 9af1d7bc..38322b4a 100644 --- a/imapserver/imapmemserver/user.go +++ b/imapserver/imapmemserver/user.go @@ -138,7 +138,12 @@ func (u *User) Create(name string, options *imap.CreateOptions) error { // UIDVALIDITY must change if a mailbox is deleted and re-created with the // same name. u.prevUidValidity++ - u.mailboxes[name] = NewMailbox(name, u.prevUidValidity) + mbox := NewMailbox(name, u.prevUidValidity) + + // Initialize ACL with full rights for the owner + mbox.acl[imap.RightsIdentifier(u.username)] = imap.RightSetAll + + u.mailboxes[name] = mbox return nil }