diff --git a/connector/connector.go b/connector/connector.go index 2b3f1092..b512d66e 100644 --- a/connector/connector.go +++ b/connector/connector.go @@ -10,6 +10,7 @@ import ( var ErrOperationNotAllowed = errors.New("operation not allowed") var ErrMessageSizeExceedsLimits = errors.New("message size exceeds limits") +var ErrOverQuota = errors.New("quota exceeded") // Connector connects the gluon server to a remote mail store. type Connector interface { @@ -57,6 +58,13 @@ type Connector interface { // MarkMessagesForwarded sets the forwarded value of the give messages. MarkMessagesForwarded(ctx context.Context, cache IMAPStateWrite, messageIDs []imap.MessageID, forwarded bool) error + // GetQuota returns the quota for the given quota root name. + GetQuota(ctx context.Context, rootName string) (*imap.QuotaRoot, error) + + // GetQuotaRoot returns the list of quota root names applicable to the given mailbox, + // along with the resource usage for each of those roots. + GetQuotaRoot(ctx context.Context, mailboxName string) ([]string, []*imap.QuotaRoot, error) + // GetUpdates returns a stream of updates that the gluon server should apply. GetUpdates() <-chan imap.Update diff --git a/connector/dummy.go b/connector/dummy.go index 04ae030a..d5b2b052 100644 --- a/connector/dummy.go +++ b/connector/dummy.go @@ -59,6 +59,12 @@ type Dummy struct { allowMessageCreateWithUnknownMailboxID bool updatesAllowedToFail int32 + + // quotaRoots holds configured quota roots for testing. + quotaRoots map[string]*imap.QuotaRoot + + // mailboxQuotaRoots maps mailbox names to their applicable quota root names. + mailboxQuotaRoots map[string][]string } func NewDummy(usernames []string, password []byte, period time.Duration, flags, permFlags, attrs imap.FlagSet) *Dummy { @@ -73,6 +79,8 @@ func NewDummy(usernames []string, password []byte, period time.Duration, flags, updateQuitCh: make(chan struct{}), ticker: ticker.New(period), mailboxVisibilities: make(map[imap.MailboxID]imap.MailboxVisibility), + quotaRoots: make(map[string]*imap.QuotaRoot), + mailboxQuotaRoots: make(map[string][]string), } go func() { @@ -341,6 +349,48 @@ func (conn *Dummy) SetMailboxVisibility(id imap.MailboxID, visibility imap.Mailb conn.mailboxVisibilities[id] = visibility } +func (conn *Dummy) GetQuota(_ context.Context, rootName string) (*imap.QuotaRoot, error) { + root, ok := conn.quotaRoots[rootName] + if !ok { + return nil, fmt.Errorf("no such quota root") + } + + return root, nil +} + +func (conn *Dummy) GetQuotaRoot(_ context.Context, mailboxName string) ([]string, []*imap.QuotaRoot, error) { + rootNames, ok := conn.mailboxQuotaRoots[mailboxName] + if !ok { + // Default: return the empty-string root if it exists. + if root, exists := conn.quotaRoots[""]; exists { + return []string{""}, []*imap.QuotaRoot{root}, nil + } + + return nil, nil, nil + } + + var roots []*imap.QuotaRoot + + for _, name := range rootNames { + if root, exists := conn.quotaRoots[name]; exists { + roots = append(roots, root) + } + } + + return rootNames, roots, nil +} + +func (conn *Dummy) SetQuota(rootName string, resources ...imap.QuotaResource) { + conn.quotaRoots[rootName] = &imap.QuotaRoot{ + RootName: rootName, + Resources: resources, + } +} + +func (conn *Dummy) SetMailboxQuotaRoot(mailboxName string, rootNames ...string) { + conn.mailboxQuotaRoots[mailboxName] = rootNames +} + func (conn *Dummy) pushUpdate(update imap.Update) { conn.queueLock.Lock() defer conn.queueLock.Unlock() diff --git a/connector/mock_connector/connector.go b/connector/mock_connector/connector.go index 11f8135c..ea79436f 100644 --- a/connector/mock_connector/connector.go +++ b/connector/mock_connector/connector.go @@ -123,6 +123,37 @@ func (mr *MockConnectorMockRecorder) DeleteMailbox(arg0, arg1 interface{}) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMailbox", reflect.TypeOf((*MockConnector)(nil).DeleteMailbox), arg0, arg1) } +// GetQuota mocks base method. +func (m *MockConnector) GetQuota(arg0 context.Context, arg1 string) (*imap.QuotaRoot, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetQuota", arg0, arg1) + ret0, _ := ret[0].(*imap.QuotaRoot) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetQuota indicates an expected call of GetQuota. +func (mr *MockConnectorMockRecorder) GetQuota(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetQuota", reflect.TypeOf((*MockConnector)(nil).GetQuota), arg0, arg1) +} + +// GetQuotaRoot mocks base method. +func (m *MockConnector) GetQuotaRoot(arg0 context.Context, arg1 string) ([]string, []*imap.QuotaRoot, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetQuotaRoot", arg0, arg1) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].([]*imap.QuotaRoot) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetQuotaRoot indicates an expected call of GetQuotaRoot. +func (mr *MockConnectorMockRecorder) GetQuotaRoot(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetQuotaRoot", reflect.TypeOf((*MockConnector)(nil).GetQuotaRoot), arg0, arg1) +} + // GetMailboxVisibility mocks base method. func (m *MockConnector) GetMailboxVisibility(arg0 context.Context, arg1 imap.MailboxID) imap.MailboxVisibility { m.ctrl.T.Helper() diff --git a/imap/capabilities.go b/imap/capabilities.go index 51013903..11d0c517 100644 --- a/imap/capabilities.go +++ b/imap/capabilities.go @@ -11,13 +11,14 @@ const ( MOVE Capability = `MOVE` ID Capability = `ID` AUTHPLAIN Capability = `AUTH=PLAIN` + QUOTA Capability = `QUOTA` ) func IsCapabilityAvailableBeforeAuth(c Capability) bool { switch c { case IMAP4rev1, StartTLS, IDLE, ID, AUTHPLAIN: return true - case UNSELECT, UIDPLUS, MOVE: + case UNSELECT, UIDPLUS, MOVE, QUOTA: return false } diff --git a/imap/command/getquota.go b/imap/command/getquota.go new file mode 100644 index 00000000..0da4a74f --- /dev/null +++ b/imap/command/getquota.go @@ -0,0 +1,37 @@ +package command + +import ( + "fmt" + + "github.com/ProtonMail/gluon/rfcparser" +) + +type GetQuota struct { + Root string +} + +func (g GetQuota) String() string { + return fmt.Sprintf("GETQUOTA '%v'", g.Root) +} + +func (g GetQuota) SanitizedString() string { + return g.String() +} + +type GetQuotaCommandParser struct{} + +func (GetQuotaCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { + // getquota = "GETQUOTA" SP astring + if err := p.Consume(rfcparser.TokenTypeSP, "expected space after command"); err != nil { + return nil, err + } + + root, err := p.ParseAString() + if err != nil { + return nil, err + } + + return &GetQuota{ + Root: root.Value, + }, nil +} diff --git a/imap/command/getquotaroot.go b/imap/command/getquotaroot.go new file mode 100644 index 00000000..c8e8ad95 --- /dev/null +++ b/imap/command/getquotaroot.go @@ -0,0 +1,37 @@ +package command + +import ( + "fmt" + + "github.com/ProtonMail/gluon/rfcparser" +) + +type GetQuotaRoot struct { + Mailbox string +} + +func (g GetQuotaRoot) String() string { + return fmt.Sprintf("GETQUOTAROOT '%v'", g.Mailbox) +} + +func (g GetQuotaRoot) SanitizedString() string { + return g.String() +} + +type GetQuotaRootCommandParser struct{} + +func (GetQuotaRootCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { + // getquotaroot = "GETQUOTAROOT" SP mailbox + if err := p.Consume(rfcparser.TokenTypeSP, "expected space after command"); err != nil { + return nil, err + } + + mailbox, err := ParseMailbox(p) + if err != nil { + return nil, err + } + + return &GetQuotaRoot{ + Mailbox: mailbox.Value, + }, nil +} diff --git a/imap/command/parser.go b/imap/command/parser.go index 1222e207..02e67234 100644 --- a/imap/command/parser.go +++ b/imap/command/parser.go @@ -60,34 +60,36 @@ func NewParser(s *rfcparser.Scanner, options ...Option) *Parser { } commands := map[string]Builder{ - "list": &ListCommandParser{}, - "append": &AppendCommandParser{}, - "search": &SearchCommandParser{}, - "fetch": &FetchCommandParser{}, - "capability": &CapabilityCommandParser{}, - "idle": &IdleCommandParser{}, - "noop": &NoopCommandParser{}, - "logout": &LogoutCommandParser{}, - "check": &CheckCommandParser{}, - "close": &CloseCommandParser{}, - "expunge": &ExpungeCommandParser{}, - "unselect": &UnselectCommandParser{}, - "starttls": &StartTLSCommandParser{}, - "status": &StatusCommandParser{}, - "select": &SelectCommandParser{}, - "examine": &ExamineCommandParser{}, - "create": &CreateCommandParser{}, - "delete": &DeleteCommandParser{}, - "subscribe": &SubscribeCommandParser{}, - "unsubscribe": &UnsubscribeCommandParser{}, - "rename": &RenameCommandParser{}, - "lsub": &LSubCommandParser{}, - "login": &LoginCommandParser{}, - "store": &StoreCommandParser{}, - "copy": &CopyCommandParser{}, - "move": &MoveCommandParser{}, - "uid": NewUIDCommandParser(), - "id": &IDCommandParser{}, + "list": &ListCommandParser{}, + "append": &AppendCommandParser{}, + "search": &SearchCommandParser{}, + "fetch": &FetchCommandParser{}, + "capability": &CapabilityCommandParser{}, + "idle": &IdleCommandParser{}, + "noop": &NoopCommandParser{}, + "logout": &LogoutCommandParser{}, + "check": &CheckCommandParser{}, + "close": &CloseCommandParser{}, + "expunge": &ExpungeCommandParser{}, + "unselect": &UnselectCommandParser{}, + "starttls": &StartTLSCommandParser{}, + "status": &StatusCommandParser{}, + "select": &SelectCommandParser{}, + "examine": &ExamineCommandParser{}, + "create": &CreateCommandParser{}, + "delete": &DeleteCommandParser{}, + "subscribe": &SubscribeCommandParser{}, + "unsubscribe": &UnsubscribeCommandParser{}, + "rename": &RenameCommandParser{}, + "lsub": &LSubCommandParser{}, + "login": &LoginCommandParser{}, + "store": &StoreCommandParser{}, + "copy": &CopyCommandParser{}, + "move": &MoveCommandParser{}, + "uid": NewUIDCommandParser(), + "id": &IDCommandParser{}, + "getquota": &GetQuotaCommandParser{}, + "getquotaroot": &GetQuotaRootCommandParser{}, } if !builder.disableIMAPAuthenticate { diff --git a/imap/quota.go b/imap/quota.go new file mode 100644 index 00000000..11d8b38c --- /dev/null +++ b/imap/quota.go @@ -0,0 +1,14 @@ +package imap + +// QuotaResource represents a single resource within a quota root (e.g. STORAGE or MESSAGE). +type QuotaResource struct { + ResourceName string + Usage uint32 + Limit uint32 +} + +// QuotaRoot represents a named quota root and its associated resources. +type QuotaRoot struct { + RootName string + Resources []QuotaResource +} diff --git a/internal/backend/state_connector_impl.go b/internal/backend/state_connector_impl.go index 9320ccf0..ba34e249 100644 --- a/internal/backend/state_connector_impl.go +++ b/internal/backend/state_connector_impl.go @@ -227,3 +227,15 @@ func (sc *stateConnectorImpl) newContextWithMetadata(ctx context.Context) contex return ctx } + +func (sc *stateConnectorImpl) GetQuota(ctx context.Context, rootName string) (*imap.QuotaRoot, error) { + ctx = sc.newContextWithMetadata(ctx) + + return sc.connector.GetQuota(ctx, rootName) +} + +func (sc *stateConnectorImpl) GetQuotaRoot(ctx context.Context, mailboxName string) ([]string, []*imap.QuotaRoot, error) { + ctx = sc.newContextWithMetadata(ctx) + + return sc.connector.GetQuotaRoot(ctx, mailboxName) +} diff --git a/internal/response/item_overquota.go b/internal/response/item_overquota.go new file mode 100644 index 00000000..1e33913a --- /dev/null +++ b/internal/response/item_overquota.go @@ -0,0 +1,11 @@ +package response + +type itemOverQuota struct{} + +func ItemOverQuota() *itemOverQuota { + return &itemOverQuota{} +} + +func (c *itemOverQuota) String() string { + return "OVERQUOTA" +} diff --git a/internal/response/quota.go b/internal/response/quota.go new file mode 100644 index 00000000..0e74b0a6 --- /dev/null +++ b/internal/response/quota.go @@ -0,0 +1,38 @@ +package response + +import ( + "fmt" + "strings" + + "github.com/ProtonMail/gluon/imap" +) + +type quota struct { + root *imap.QuotaRoot +} + +func Quota(root *imap.QuotaRoot) *quota { + return "a{root: root} +} + +func (r *quota) Send(s Session) error { + return s.WriteResponse(r.String()) +} + +func (r *quota) String() string { + var resources []string + + for _, res := range r.root.Resources { + resources = append(resources, fmt.Sprintf("%v %v %v", res.ResourceName, res.Usage, res.Limit)) + } + + return fmt.Sprintf(`* QUOTA %v (%v)`, quoteQuotaRoot(r.root.RootName), strings.Join(resources, " ")) +} + +func quoteQuotaRoot(name string) string { + if name == "" { + return `""` + } + + return fmt.Sprintf(`"%v"`, name) +} diff --git a/internal/response/quotaroot.go b/internal/response/quotaroot.go new file mode 100644 index 00000000..fd55b84b --- /dev/null +++ b/internal/response/quotaroot.go @@ -0,0 +1,38 @@ +package response + +import ( + "fmt" + "strings" +) + +type quotaroot struct { + mailbox string + rootNames []string +} + +func QuotaRoot(mailbox string, rootNames []string) *quotaroot { + return "aroot{ + mailbox: mailbox, + rootNames: rootNames, + } +} + +func (r *quotaroot) Send(s Session) error { + return s.WriteResponse(r.String()) +} + +func (r *quotaroot) String() string { + var parts []string + + parts = append(parts, fmt.Sprintf("* QUOTAROOT %v", r.mailbox)) + + for _, name := range r.rootNames { + if name == "" { + parts = append(parts, `""`) + } else { + parts = append(parts, fmt.Sprintf(`"%v"`, name)) + } + } + + return strings.Join(parts, " ") +} diff --git a/internal/session/errors.go b/internal/session/errors.go index 16140a86..ffeb39a7 100644 --- a/internal/session/errors.go +++ b/internal/session/errors.go @@ -32,6 +32,8 @@ func shouldReportIMAPCommandError(err error) bool { return false case errors.Is(err, connector.ErrOperationNotAllowed): return false + case errors.Is(err, connector.ErrOverQuota): + return false case errors.Is(err, context.Canceled): return false case errors.As(err, &netErr): diff --git a/internal/session/handle.go b/internal/session/handle.go index 78b415d3..4848d17e 100644 --- a/internal/session/handle.go +++ b/internal/session/handle.go @@ -73,7 +73,9 @@ func (s *Session) handleCommand( *command.List, *command.LSub, *command.Status, - *command.Append: + *command.Append, + *command.GetQuota, + *command.GetQuotaRoot: return s.handleAuthenticatedCommand(ctx, tag, cmd, ch) case *command.Check, @@ -199,6 +201,14 @@ func (s *Session) handleAuthenticatedCommand( // 6.3.11. APPEND Command return s.handleAppend(ctx, tag, cmd, ch) + case *command.GetQuota: + // RFC 2087 GETQUOTA Command + return s.handleGetQuota(ctx, tag, cmd, ch) + + case *command.GetQuotaRoot: + // RFC 2087 GETQUOTAROOT Command + return s.handleGetQuotaRoot(ctx, tag, cmd, ch) + default: return fmt.Errorf("bad command") } diff --git a/internal/session/handle_append.go b/internal/session/handle_append.go index ba9e36d2..4002cd57 100644 --- a/internal/session/handle_append.go +++ b/internal/session/handle_append.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "github.com/ProtonMail/gluon/connector" "github.com/ProtonMail/gluon/imap/command" "github.com/ProtonMail/gluon/internal/response" "github.com/ProtonMail/gluon/internal/state" @@ -62,6 +63,8 @@ func (s *Session) handleAppend(ctx context.Context, tag string, cmd *command.App return nil }); errors.Is(err, state.ErrNoSuchMailbox) { return response.No(tag).WithError(err).WithItems(response.ItemTryCreate()) + } else if errors.Is(err, connector.ErrOverQuota) { + return response.No(tag).WithError(err).WithItems(response.ItemOverQuota()) } else if err != nil { return err } diff --git a/internal/session/handle_copy.go b/internal/session/handle_copy.go index 30f8e89b..9bb607d0 100644 --- a/internal/session/handle_copy.go +++ b/internal/session/handle_copy.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "github.com/ProtonMail/gluon/connector" "github.com/ProtonMail/gluon/imap/command" "github.com/ProtonMail/gluon/internal/contexts" "github.com/ProtonMail/gluon/internal/response" @@ -38,6 +39,8 @@ func (s *Session) handleCopy(ctx context.Context, tag string, cmd *command.Copy, return response.Bad(tag).WithError(err), nil } else if errors.Is(err, state.ErrNoSuchMailbox) { return response.No(tag).WithError(err).WithItems(response.ItemTryCreate()), nil + } else if errors.Is(err, connector.ErrOverQuota) { + return response.No(tag).WithError(err).WithItems(response.ItemOverQuota()), nil } else if err != nil { observability.AddMessageRelatedMetric(ctx, metrics.GenerateFailedToCopyMessagesMetric()) return nil, err diff --git a/internal/session/handle_getquota.go b/internal/session/handle_getquota.go new file mode 100644 index 00000000..292ad458 --- /dev/null +++ b/internal/session/handle_getquota.go @@ -0,0 +1,25 @@ +package session + +import ( + "context" + + "github.com/ProtonMail/gluon/imap/command" + "github.com/ProtonMail/gluon/internal/response" + "github.com/ProtonMail/gluon/profiling" +) + +func (s *Session) handleGetQuota(ctx context.Context, tag string, cmd *command.GetQuota, ch chan response.Response) error { + profiling.Start(ctx, profiling.CmdTypeGetQuota) + defer profiling.Stop(ctx, profiling.CmdTypeGetQuota) + + root, err := s.state.GetQuota(ctx, cmd.Root) + if err != nil { + return err + } + + ch <- response.Quota(root) + + ch <- response.Ok(tag).WithMessage("GETQUOTA") + + return nil +} diff --git a/internal/session/handle_getquotaroot.go b/internal/session/handle_getquotaroot.go new file mode 100644 index 00000000..f487001c --- /dev/null +++ b/internal/session/handle_getquotaroot.go @@ -0,0 +1,29 @@ +package session + +import ( + "context" + + "github.com/ProtonMail/gluon/imap/command" + "github.com/ProtonMail/gluon/internal/response" + "github.com/ProtonMail/gluon/profiling" +) + +func (s *Session) handleGetQuotaRoot(ctx context.Context, tag string, cmd *command.GetQuotaRoot, ch chan response.Response) error { + profiling.Start(ctx, profiling.CmdTypeGetQuotaRoot) + defer profiling.Stop(ctx, profiling.CmdTypeGetQuotaRoot) + + rootNames, roots, err := s.state.GetQuotaRoot(ctx, cmd.Mailbox) + if err != nil { + return err + } + + ch <- response.QuotaRoot(cmd.Mailbox, rootNames) + + for _, root := range roots { + ch <- response.Quota(root) + } + + ch <- response.Ok(tag).WithMessage("GETQUOTAROOT") + + return nil +} diff --git a/internal/session/handle_move.go b/internal/session/handle_move.go index ebf6c628..e405058a 100644 --- a/internal/session/handle_move.go +++ b/internal/session/handle_move.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "github.com/ProtonMail/gluon/connector" "github.com/ProtonMail/gluon/imap/command" "github.com/ProtonMail/gluon/internal/contexts" "github.com/ProtonMail/gluon/internal/response" @@ -36,6 +37,8 @@ func (s *Session) handleMove(ctx context.Context, tag string, cmd *command.Move, return response.Bad(tag).WithError(err), nil } else if errors.Is(err, state.ErrNoSuchMailbox) { return response.No(tag).WithError(err).WithItems(response.ItemTryCreate()), nil + } else if errors.Is(err, connector.ErrOverQuota) { + return response.No(tag).WithError(err).WithItems(response.ItemOverQuota()), nil } else if err != nil { observability.AddMessageRelatedMetric(ctx, metrics.GenerateFailedToMoveMessagesFromMailboxMetric()) return nil, err diff --git a/internal/session/session.go b/internal/session/session.go index 0c02c647..ae780ef6 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -118,7 +118,7 @@ func New( inputCollector := command.NewInputCollector(bufio.NewReader(conn)) scanner := rfcparser.NewScannerWithReader(inputCollector) - caps := []imap.Capability{imap.IMAP4rev1, imap.UNSELECT, imap.UIDPLUS, imap.MOVE, imap.ID} + caps := []imap.Capability{imap.IMAP4rev1, imap.UNSELECT, imap.UIDPLUS, imap.MOVE, imap.ID, imap.QUOTA} if !featureFlagProvider.GetFlagValue(unleash.CapabilityKillSwitchMap[string(imap.IDLE)]) { caps = append(caps, imap.IDLE) diff --git a/internal/state/connector.go b/internal/state/connector.go index b7d01c73..87754dc6 100644 --- a/internal/state/connector.go +++ b/internal/state/connector.go @@ -83,4 +83,11 @@ type Connector interface { // SetMessagesForwarded marks the message with the given ID as forwarded. SetMessagesForwarded(ctx context.Context, tx db.Transaction, messageIDs []imap.MessageID, forwarded bool) ([]Update, error) + + // GetQuota returns the quota for the given quota root name. + GetQuota(ctx context.Context, rootName string) (*imap.QuotaRoot, error) + + // GetQuotaRoot returns the list of quota root names applicable to the given mailbox, + // along with the resource usage for each of those roots. + GetQuotaRoot(ctx context.Context, mailboxName string) ([]string, []*imap.QuotaRoot, error) } diff --git a/internal/state/quota.go b/internal/state/quota.go new file mode 100644 index 00000000..112ea461 --- /dev/null +++ b/internal/state/quota.go @@ -0,0 +1,15 @@ +package state + +import ( + "context" + + "github.com/ProtonMail/gluon/imap" +) + +func (state *State) GetQuota(ctx context.Context, rootName string) (*imap.QuotaRoot, error) { + return state.user.GetRemote().GetQuota(ctx, rootName) +} + +func (state *State) GetQuotaRoot(ctx context.Context, mailboxName string) ([]string, []*imap.QuotaRoot, error) { + return state.user.GetRemote().GetQuotaRoot(ctx, mailboxName) +} diff --git a/profiling/cmd_profiler.go b/profiling/cmd_profiler.go index ad4ebe4a..774a567a 100644 --- a/profiling/cmd_profiler.go +++ b/profiling/cmd_profiler.go @@ -31,6 +31,8 @@ const ( CmdTypeUIDStore CmdTypeUIDFetch CmdTypeUIDSearch + CmdTypeGetQuota + CmdTypeGetQuotaRoot CmdTypeTotal ) @@ -96,6 +98,10 @@ func CmdTypeToString(cmdType int) string { return "USTORE " case CmdTypeUIDSearch: return "USEARCH" + case CmdTypeGetQuota: + return "GQUOTA " + case CmdTypeGetQuotaRoot: + return "GQROOT " default: return "Unknown" diff --git a/tests/authenticate_test.go b/tests/authenticate_test.go index 2a3df0c8..e7f19799 100644 --- a/tests/authenticate_test.go +++ b/tests/authenticate_test.go @@ -73,7 +73,7 @@ func TestAuthenticateCapabilities(t *testing.T) { c.C("A001 AUTHENTICATE PLAIN") c.S("+") c.C(base64AuthString("user", "pass")) - c.S(`A001 OK [CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT] Logged in`) + c.S(`A001 OK [CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE QUOTA STARTTLS UIDPLUS UNSELECT] Logged in`) }) } diff --git a/tests/capability_test.go b/tests/capability_test.go index 4afb52a3..00278e52 100644 --- a/tests/capability_test.go +++ b/tests/capability_test.go @@ -11,10 +11,10 @@ func TestCapability(t *testing.T) { c.S("A001 OK CAPABILITY") c.C(`A002 login "user" "pass"`) - c.S(`A002 OK [CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT] Logged in`) + c.S(`A002 OK [CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE QUOTA STARTTLS UIDPLUS UNSELECT] Logged in`) c.C("A003 Capability") - c.S(`* CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT`) + c.S(`* CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE QUOTA STARTTLS UIDPLUS UNSELECT`) c.S("A003 OK CAPABILITY") }) } @@ -26,10 +26,10 @@ func TestCapabilityAuthenticateDisabled(t *testing.T) { c.S("A001 OK CAPABILITY") c.C(`A002 login "user" "pass"`) - c.S(`A002 OK [CAPABILITY ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT] Logged in`) + c.S(`A002 OK [CAPABILITY ID IDLE IMAP4rev1 MOVE QUOTA STARTTLS UIDPLUS UNSELECT] Logged in`) c.C("A003 Capability") - c.S(`* CAPABILITY ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT`) + c.S(`* CAPABILITY ID IDLE IMAP4rev1 MOVE QUOTA STARTTLS UIDPLUS UNSELECT`) c.S("A003 OK CAPABILITY") }) } diff --git a/tests/login_test.go b/tests/login_test.go index 96d166c8..cf3ea24c 100644 --- a/tests/login_test.go +++ b/tests/login_test.go @@ -95,7 +95,7 @@ func TestLoginLiteralFailure(t *testing.T) { func TestLoginCapabilities(t *testing.T) { runOneToOneTest(t, defaultServerOptions(t), func(c *testConnection, _ *testSession) { c.C("A001 login user pass") - c.S(`A001 OK [CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT] Logged in`) + c.S(`A001 OK [CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE QUOTA STARTTLS UIDPLUS UNSELECT] Logged in`) }) } diff --git a/tests/quota_test.go b/tests/quota_test.go new file mode 100644 index 00000000..38566124 --- /dev/null +++ b/tests/quota_test.go @@ -0,0 +1,92 @@ +package tests + +import ( + "testing" + + "github.com/ProtonMail/gluon/imap" +) + +func TestGetQuota(t *testing.T) { + runOneToOneTestWithAuth(t, defaultServerOptions(t), func(c *testConnection, s *testSession) { + s.setQuota("user", "", imap.QuotaResource{ + ResourceName: "STORAGE", + Usage: 100, + Limit: 512, + }) + + c.C(`A001 GETQUOTA ""`) + c.S(`* QUOTA "" (STORAGE 100 512)`) + c.S(`A001 OK GETQUOTA`) + }) +} + +func TestGetQuotaMultipleResources(t *testing.T) { + runOneToOneTestWithAuth(t, defaultServerOptions(t), func(c *testConnection, s *testSession) { + s.setQuota("user", "user-root", imap.QuotaResource{ + ResourceName: "STORAGE", + Usage: 200, + Limit: 1024, + }, imap.QuotaResource{ + ResourceName: "MESSAGE", + Usage: 50, + Limit: 1000, + }) + + c.C(`A001 GETQUOTA "user-root"`) + c.S(`* QUOTA "user-root" (STORAGE 200 1024 MESSAGE 50 1000)`) + c.S(`A001 OK GETQUOTA`) + }) +} + +func TestGetQuotaRoot(t *testing.T) { + runOneToOneTestWithAuth(t, defaultServerOptions(t), func(c *testConnection, s *testSession) { + s.setQuota("user", "", imap.QuotaResource{ + ResourceName: "STORAGE", + Usage: 100, + Limit: 512, + }) + + c.C(`A001 GETQUOTAROOT INBOX`) + c.S(`* QUOTAROOT INBOX ""`) + c.S(`* QUOTA "" (STORAGE 100 512)`) + c.S(`A001 OK GETQUOTAROOT`) + }) +} + +func TestGetQuotaRootWithNamedRoot(t *testing.T) { + runOneToOneTestWithAuth(t, defaultServerOptions(t), func(c *testConnection, s *testSession) { + s.setQuota("user", "my-root", imap.QuotaResource{ + ResourceName: "STORAGE", + Usage: 300, + Limit: 2048, + }) + s.setMailboxQuotaRoot("user", "INBOX", "my-root") + + c.C(`A001 GETQUOTAROOT INBOX`) + c.S(`* QUOTAROOT INBOX "my-root"`) + c.S(`* QUOTA "my-root" (STORAGE 300 2048)`) + c.S(`A001 OK GETQUOTAROOT`) + }) +} + +func TestGetQuotaRootMultipleRoots(t *testing.T) { + runOneToOneTestWithAuth(t, defaultServerOptions(t), func(c *testConnection, s *testSession) { + s.setQuota("user", "root-a", imap.QuotaResource{ + ResourceName: "STORAGE", + Usage: 10, + Limit: 100, + }) + s.setQuota("user", "root-b", imap.QuotaResource{ + ResourceName: "MESSAGE", + Usage: 5, + Limit: 50, + }) + s.setMailboxQuotaRoot("user", "INBOX", "root-a", "root-b") + + c.C(`A001 GETQUOTAROOT INBOX`) + c.S(`* QUOTAROOT INBOX "root-a" "root-b"`) + c.S(`* QUOTA "root-a" (STORAGE 10 100)`) + c.S(`* QUOTA "root-b" (MESSAGE 5 50)`) + c.S(`A001 OK GETQUOTAROOT`) + }) +} diff --git a/tests/session_test.go b/tests/session_test.go index c98de208..7920d21a 100644 --- a/tests/session_test.go +++ b/tests/session_test.go @@ -53,6 +53,9 @@ type Connector interface { Flush() SetUpdatesAllowedToFail(bool) + + SetQuota(rootName string, resources ...imap.QuotaResource) + SetMailboxQuotaRoot(mailboxName string, rootNames ...string) } type testSession struct { @@ -373,6 +376,14 @@ func (s *testSession) setUpdatesAllowedToFail(user string, value bool) { s.conns[s.userIDs[user]].SetUpdatesAllowedToFail(value) } +func (s *testSession) setQuota(user string, rootName string, resources ...imap.QuotaResource) { + s.conns[s.userIDs[user]].SetQuota(rootName, resources...) +} + +func (s *testSession) setMailboxQuotaRoot(user string, mailboxName string, rootNames ...string) { + s.conns[s.userIDs[user]].SetMailboxQuotaRoot(mailboxName, rootNames...) +} + func (s *testSession) removeAccount(t testing.TB, user string) string { userID := s.userIDs[user]