├── .gitignore ├── quota.go ├── README.md ├── LICENSE ├── client.go ├── commands.go ├── server.go └── responses.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | -------------------------------------------------------------------------------- /quota.go: -------------------------------------------------------------------------------- 1 | // Implements the IMAP QUOTA extension, as defined in RFC 2087. 2 | package quota 3 | 4 | // The QUOTA capability name. 5 | const Capability = "QUOTA" 6 | 7 | // Resources defined in RFC 2087 section 3. 8 | const ( 9 | // Sum of messages' RFC822.SIZE, in units of 1024 octets 10 | ResourceStorage = "STORAGE" 11 | // Number of messages 12 | ResourceMessage = "MESSAGE" 13 | ) 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-imap-quota 2 | 3 | [![GoDoc](https://godoc.org/github.com/emersion/go-imap-quota?status.svg)](https://godoc.org/github.com/emersion/go-imap-quota) 4 | 5 | [QUOTA extension](https://tools.ietf.org/html/rfc2087) for [go-imap](https://github.com/emersion/go-imap) 6 | 7 | ## Usage 8 | 9 | ```go 10 | package main 11 | 12 | import ( 13 | "log" 14 | 15 | "github.com/emersion/go-imap-quota" 16 | ) 17 | 18 | func main() { 19 | // Connect to IMAP server 20 | 21 | // Create a quota client 22 | qc := quota.NewClient(c) 23 | 24 | // Check for server support 25 | if !qc.SupportsQuota() { 26 | log.Fatal("Client doesn't support QUOTA extension") 27 | } 28 | 29 | // Retrieve quotas for INBOX 30 | quotas, err := qc.GetQuotaRoot("INBOX") 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | 35 | // Print quotas 36 | log.Println("Quotas for INBOX:") 37 | for _, quota := range quotas { 38 | log.Printf("* %q, resources:\n", quota.Name) 39 | for name, usage := range quota.Resources { 40 | log.Printf(" * %v: %v/%v used\n", name, usage[0], usage[1]) 41 | } 42 | } 43 | } 44 | ``` 45 | 46 | ## License 47 | 48 | MIT 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 emersion 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package quota 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/emersion/go-imap" 7 | "github.com/emersion/go-imap/client" 8 | ) 9 | 10 | // Client is a QUOTA client. 11 | type Client struct { 12 | c *client.Client 13 | } 14 | 15 | // NewClient creates a new client. 16 | func NewClient(c *client.Client) *Client { 17 | return &Client{c: c} 18 | } 19 | 20 | // SupportQuota checks if the server supports the QUOTA extension. 21 | func (c *Client) SupportQuota() (bool, error) { 22 | return c.c.Support(Capability) 23 | } 24 | 25 | // SetQuota changes the resource limits for the specified quota root. Any 26 | // previous resource limits for the named quota root are discarded. 27 | func (c *Client) SetQuota(root string, resources map[string]uint32) error { 28 | if c.c.State()&imap.AuthenticatedState == 0 { 29 | return client.ErrNotLoggedIn 30 | } 31 | 32 | cmd := &SetCommand{ 33 | Root: root, 34 | Resources: resources, 35 | } 36 | 37 | status, err := c.c.Execute(cmd, nil) 38 | if err != nil { 39 | return err 40 | } 41 | return status.Err() 42 | } 43 | 44 | // GetQuota returns a quota root's resource usage and limits. 45 | func (c *Client) GetQuota(root string) (*Status, error) { 46 | if c.c.State()&imap.AuthenticatedState == 0 { 47 | return nil, client.ErrNotLoggedIn 48 | } 49 | 50 | cmd := &GetCommand{ 51 | Root: root, 52 | } 53 | 54 | res := &Response{} 55 | 56 | status, err := c.c.Execute(cmd, res) 57 | if err != nil { 58 | return nil, err 59 | } 60 | if err := status.Err(); err != nil { 61 | return nil, err 62 | } 63 | if len(res.Quotas) != 1 { 64 | return nil, fmt.Errorf("Expected exactly one QUOTA response, got %v", len(res.Quotas)) 65 | } 66 | 67 | return res.Quotas[0], nil 68 | } 69 | 70 | // GetQuotaRoot returns the list of quota roots for a mailbox. 71 | func (c *Client) GetQuotaRoot(mailbox string) ([]*Status, error) { 72 | if c.c.State()&imap.AuthenticatedState == 0 { 73 | return nil, client.ErrNotLoggedIn 74 | } 75 | 76 | cmd := &GetRootCommand{ 77 | Mailbox: mailbox, 78 | } 79 | 80 | res := &Response{} 81 | 82 | status, err := c.c.Execute(cmd, res) 83 | if err != nil { 84 | return nil, err 85 | } 86 | if err := status.Err(); err != nil { 87 | return nil, err 88 | } 89 | 90 | return res.Quotas, nil 91 | } 92 | -------------------------------------------------------------------------------- /commands.go: -------------------------------------------------------------------------------- 1 | package quota 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/emersion/go-imap" 7 | "github.com/emersion/go-imap/utf7" 8 | ) 9 | 10 | const ( 11 | setCommandName = "SETQUOTA" 12 | getCommandName = "GETQUOTA" 13 | getRootCommandName = "GETQUOTAROOT" 14 | ) 15 | 16 | // The SETQUOTA command. See RFC 2087 section 4.1. 17 | type SetCommand struct { 18 | Root string 19 | Resources map[string]uint32 20 | } 21 | 22 | func (cmd *SetCommand) Command() *imap.Command { 23 | args := []interface{}{cmd.Root} 24 | 25 | for k, v := range cmd.Resources { 26 | args = append(args, k, v) 27 | } 28 | 29 | return &imap.Command{ 30 | Name: setCommandName, 31 | Arguments: args, 32 | } 33 | } 34 | 35 | func (cmd *SetCommand) Parse(fields []interface{}) error { 36 | if len(fields) < 2 { 37 | return errors.New("No enough arguments") 38 | } 39 | 40 | var ok bool 41 | if cmd.Root, ok = fields[0].(string); !ok { 42 | return errors.New("Quota root must be a string") 43 | } 44 | 45 | resources, ok := fields[1].([]interface{}) 46 | if !ok { 47 | return errors.New("Resources must be a list") 48 | } 49 | 50 | var name string 51 | for i, v := range resources { 52 | if i%2 == 0 { 53 | name, ok = v.(string) 54 | if !ok { 55 | return errors.New("Resource name must be a string") 56 | } 57 | } else { 58 | var err error 59 | cmd.Resources[name], err = imap.ParseNumber(v) 60 | if err != nil { 61 | return err 62 | } 63 | } 64 | } 65 | 66 | return nil 67 | } 68 | 69 | // The GETQUOTA command. See RFC 2087 section 4.2. 70 | type GetCommand struct { 71 | Root string 72 | } 73 | 74 | func (cmd *GetCommand) Command() *imap.Command { 75 | return &imap.Command{ 76 | Name: getCommandName, 77 | Arguments: []interface{}{cmd.Root}, 78 | } 79 | } 80 | 81 | func (cmd *GetCommand) Parse(fields []interface{}) error { 82 | if len(fields) < 1 { 83 | return errors.New("No enough arguments") 84 | } 85 | 86 | var ok bool 87 | if cmd.Root, ok = fields[0].(string); !ok { 88 | return errors.New("Quota root must be a string") 89 | } 90 | 91 | return nil 92 | } 93 | 94 | // The GETQUOTAROOT command. See RFC 2087 section 4.3. 95 | type GetRootCommand struct { 96 | Mailbox string 97 | } 98 | 99 | func (cmd *GetRootCommand) Command() *imap.Command { 100 | mailbox, _ := utf7.Encoding.NewEncoder().String(cmd.Mailbox) 101 | 102 | return &imap.Command{ 103 | Name: getRootCommandName, 104 | Arguments: []interface{}{mailbox}, 105 | } 106 | } 107 | 108 | func (cmd *GetRootCommand) Parse(fields []interface{}) error { 109 | if len(fields) < 1 { 110 | return errors.New("No enough arguments") 111 | } 112 | 113 | var ok bool 114 | mailbox, ok := fields[0].(string) 115 | if !ok { 116 | return errors.New("Quota root must be a string") 117 | } 118 | var err error 119 | if cmd.Mailbox, err = utf7.Encoding.NewDecoder().String(mailbox); err != nil { 120 | return err 121 | } 122 | 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package quota 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/emersion/go-imap" 7 | "github.com/emersion/go-imap/server" 8 | ) 9 | 10 | var ( 11 | ErrUnsupportedBackend = errors.New("quota: backend not supported") 12 | ) 13 | 14 | type User interface { 15 | // GetQuota returns the quota with the specified name. 16 | GetQuota(name string) (*Status, error) 17 | 18 | // SetQuota registers or updates a quota for this user with a set of resources 19 | // and their limit. The resource limits for the named quota root are changed 20 | // to be the specified limits. Any previous resource limits for the named 21 | // quota root are discarded. 22 | SetQuota(name string, resources map[string]uint32) error 23 | } 24 | 25 | type Mailbox interface { 26 | // ListQuotas returns the currently active quotas for this mailbox. 27 | ListQuotas() ([]string, error) 28 | } 29 | 30 | type SetHandler struct { 31 | SetCommand 32 | } 33 | 34 | func (h *SetHandler) Handle(conn server.Conn) error { 35 | if conn.Context().User == nil { 36 | return server.ErrNotAuthenticated 37 | } 38 | 39 | u, ok := conn.Context().User.(User) 40 | if !ok { 41 | return ErrUnsupportedBackend 42 | } 43 | 44 | if err := u.SetQuota(h.Root, h.Resources); err != nil { 45 | return err 46 | } 47 | 48 | inner := &GetHandler{} 49 | inner.Root = h.Root 50 | return inner.Handle(conn) 51 | } 52 | 53 | type GetHandler struct { 54 | GetCommand 55 | } 56 | 57 | func (h *GetHandler) Handle(conn server.Conn) error { 58 | if conn.Context().User == nil { 59 | return server.ErrNotAuthenticated 60 | } 61 | 62 | u, ok := conn.Context().User.(User) 63 | if !ok { 64 | return ErrUnsupportedBackend 65 | } 66 | 67 | status, err := u.GetQuota(h.Root) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | res := &Response{Quotas: []*Status{status}} 73 | return conn.WriteResp(res) 74 | } 75 | 76 | type GetRootHandler struct { 77 | GetRootCommand 78 | } 79 | 80 | func (h *GetRootHandler) Handle(conn server.Conn) error { 81 | if conn.Context().User == nil { 82 | return server.ErrNotAuthenticated 83 | } 84 | 85 | u, ok := conn.Context().User.(User) 86 | if !ok { 87 | return ErrUnsupportedBackend 88 | } 89 | 90 | mbox, err := conn.Context().User.GetMailbox(h.Mailbox) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | qmbox, ok := mbox.(Mailbox) 96 | if !ok { 97 | return ErrUnsupportedBackend 98 | } 99 | 100 | roots, err := qmbox.ListQuotas() 101 | if err != nil { 102 | return err 103 | } 104 | 105 | rootRes := &RootResponse{ 106 | Mailbox: &MailboxRoots{ 107 | Name: h.Mailbox, 108 | Roots: roots, 109 | }, 110 | } 111 | if err := conn.WriteResp(rootRes); err != nil { 112 | return err 113 | } 114 | 115 | res := &Response{} 116 | for _, name := range roots { 117 | status, err := u.GetQuota(name) 118 | if err != nil { 119 | return err 120 | } 121 | res.Quotas = append(res.Quotas, status) 122 | } 123 | 124 | return conn.WriteResp(res) 125 | } 126 | 127 | type extension struct{} 128 | 129 | func NewExtension() server.Extension { 130 | return &extension{} 131 | } 132 | 133 | func (ext *extension) Capabilities(c server.Conn) []string { 134 | if c.Context().State&imap.AuthenticatedState != 0 { 135 | return []string{Capability} 136 | } 137 | return nil 138 | } 139 | 140 | func (ext *extension) Command(name string) server.HandlerFactory { 141 | switch name { 142 | case setCommandName: 143 | return func() server.Handler { 144 | return &SetHandler{} 145 | } 146 | case getCommandName: 147 | return func() server.Handler { 148 | return &GetHandler{} 149 | } 150 | case getRootCommandName: 151 | return func() server.Handler { 152 | return &GetRootHandler{} 153 | } 154 | } 155 | 156 | return nil 157 | } 158 | -------------------------------------------------------------------------------- /responses.go: -------------------------------------------------------------------------------- 1 | package quota 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/emersion/go-imap" 7 | "github.com/emersion/go-imap/responses" 8 | "github.com/emersion/go-imap/utf7" 9 | ) 10 | 11 | const ( 12 | responseName = "QUOTA" 13 | rootResponseName = "QUOTAROOT" 14 | ) 15 | 16 | // A quota status. 17 | type Status struct { 18 | // The quota name. 19 | Name string 20 | // The quota resources. Each resource is indexed by its name and contains its 21 | // current usage as well as its limit. 22 | Resources map[string][2]uint32 23 | } 24 | 25 | func (rs *Status) Parse(fields []interface{}) error { 26 | if len(fields) < 2 { 27 | return errors.New("No enough arguments") 28 | } 29 | 30 | var ok bool 31 | if rs.Name, ok = fields[0].(string); !ok { 32 | return errors.New("Quota root must be a string") 33 | } 34 | 35 | resources, ok := fields[1].([]interface{}) 36 | if !ok { 37 | return errors.New("Resources must be a list") 38 | } 39 | 40 | var name string 41 | var usage, limit uint32 42 | var err error 43 | for i, v := range resources { 44 | if ii := i % 3; ii == 0 { 45 | if name, ok = v.(string); !ok { 46 | return errors.New("Resource name must be a string") 47 | } 48 | } else if ii == 1 { 49 | if usage, err = imap.ParseNumber(v); err != nil { 50 | return err 51 | } 52 | } else { 53 | if limit, err = imap.ParseNumber(v); err != nil { 54 | return err 55 | } 56 | rs.Resources[name] = [2]uint32{usage, limit} 57 | } 58 | } 59 | 60 | return nil 61 | } 62 | 63 | func (rs *Status) Format() (fields []interface{}) { 64 | fields = append(fields, rs.Name) 65 | 66 | var resources []interface{} 67 | for k, v := range rs.Resources { 68 | resources = append(resources, k, v[0], v[1]) 69 | } 70 | fields = append(fields, resources) 71 | return 72 | } 73 | 74 | // A QUOTA response. See RFC 2087 section 5.1. 75 | type Response struct { 76 | Quotas []*Status 77 | } 78 | 79 | func (r *Response) Handle(resp imap.Resp) error { 80 | name, fields, ok := imap.ParseNamedResp(resp) 81 | if !ok || name != responseName { 82 | return responses.ErrUnhandled 83 | } 84 | 85 | quota := &Status{Resources: make(map[string][2]uint32)} 86 | if err := quota.Parse(fields); err != nil { 87 | return err 88 | } 89 | 90 | r.Quotas = append(r.Quotas, quota) 91 | return nil 92 | } 93 | 94 | func (r *Response) WriteTo(w *imap.Writer) error { 95 | for _, quota := range r.Quotas { 96 | fields := []interface{}{responseName} 97 | fields = append(fields, quota.Format()...) 98 | 99 | res := imap.NewUntaggedResp(fields) 100 | if err := res.WriteTo(w); err != nil { 101 | return err 102 | } 103 | } 104 | 105 | return nil 106 | } 107 | 108 | type MailboxRoots struct { 109 | Name string 110 | Roots []string 111 | } 112 | 113 | func (m *MailboxRoots) Parse(fields []interface{}) error { 114 | if len(fields) < 1 { 115 | return errors.New("No enough arguments") 116 | } 117 | 118 | mailbox, ok := fields[0].(string) 119 | if !ok { 120 | return errors.New("Mailbox name must be a string") 121 | } 122 | var err error 123 | if m.Name, err = utf7.Encoding.NewDecoder().String(mailbox); err != nil { 124 | return err 125 | } 126 | 127 | for _, f := range fields[1:] { 128 | root, ok := f.(string) 129 | if !ok { 130 | return errors.New("Quota root must be a string") 131 | } 132 | m.Roots = append(m.Roots, root) 133 | } 134 | 135 | return nil 136 | } 137 | 138 | func (m *MailboxRoots) Format() (fields []interface{}) { 139 | fields = append(fields, m.Name) 140 | for _, root := range m.Roots { 141 | fields = append(fields, root) 142 | } 143 | return 144 | } 145 | 146 | // A QUOTAROOT response. See RFC 2087 section 5.1. 147 | type RootResponse struct { 148 | Mailbox *MailboxRoots 149 | } 150 | 151 | func (r *RootResponse) Handle(resp imap.Resp) error { 152 | name, fields, ok := imap.ParseNamedResp(resp) 153 | if !ok || name != rootResponseName { 154 | return responses.ErrUnhandled 155 | } 156 | 157 | m := &MailboxRoots{} 158 | if err := m.Parse(fields); err != nil { 159 | return err 160 | } 161 | 162 | r.Mailbox = m 163 | return nil 164 | } 165 | 166 | func (r *RootResponse) WriteTo(w *imap.Writer) (err error) { 167 | fields := []interface{}{rootResponseName} 168 | fields = append(fields, r.Mailbox.Format()...) 169 | 170 | res := imap.NewUntaggedResp(fields) 171 | if err = res.WriteTo(w); err != nil { 172 | return 173 | } 174 | 175 | return 176 | } 177 | --------------------------------------------------------------------------------