diff --git a/pkg/jmap/jmap_api_mailbox.go b/pkg/jmap/jmap_api_mailbox.go index 633499e19f..6c43e20b65 100644 --- a/pkg/jmap/jmap_api_mailbox.go +++ b/pkg/jmap/jmap_api_mailbox.go @@ -2,6 +2,7 @@ package jmap import ( "context" + "slices" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/structs" @@ -15,41 +16,27 @@ type MailboxesResponse struct { } // https://jmap.io/spec-mail.html#mailboxget -func (j *Client) GetMailbox(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, ids []string) (map[string]MailboxesResponse, SessionState, Error) { +func (j *Client) GetMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxesResponse, SessionState, Error) { logger = j.logger("GetMailbox", session, logger) - uniqueAccountIds := structs.Uniq(accountIds) - n := len(uniqueAccountIds) - if n < 1 { - return map[string]MailboxesResponse{}, "", nil - } - - invocations := make([]Invocation, n) - for i, accountId := range uniqueAccountIds { - invocations[i] = invocation(CommandMailboxGet, MailboxGetCommand{AccountId: accountId, Ids: ids}, mcid(accountId, "0")) - } - - cmd, err := j.request(session, logger, invocations...) + cmd, err := j.request(session, logger, + invocation(CommandMailboxGet, MailboxGetCommand{AccountId: accountId, Ids: ids}, "0"), + ) if err != nil { - return map[string]MailboxesResponse{}, "", err + return MailboxesResponse{}, "", err } - return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (map[string]MailboxesResponse, Error) { - resp := map[string]MailboxesResponse{} - for _, accountId := range uniqueAccountIds { - var response MailboxGetResponse - err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, mcid(accountId, "0"), &response) - if err != nil { - return map[string]MailboxesResponse{}, err - } - - resp[accountId] = MailboxesResponse{ - Mailboxes: response.List, - NotFound: response.NotFound, - State: response.State, - } + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (MailboxesResponse, Error) { + var response MailboxGetResponse + err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, "0", &response) + if err != nil { + return MailboxesResponse{}, err } - return resp, nil + return MailboxesResponse{ + Mailboxes: response.List, + NotFound: response.NotFound, + State: response.State, + }, nil }) } @@ -59,20 +46,40 @@ type AllMailboxesResponse struct { } func (j *Client) GetAllMailboxes(accountIds []string, session *Session, ctx context.Context, logger *log.Logger) (map[string]AllMailboxesResponse, SessionState, Error) { - resp, sessionState, err := j.GetMailbox(accountIds, session, ctx, logger, nil) + logger = j.logger("GetAllMailboxes", session, logger) + + uniqueAccountIds := structs.Uniq(accountIds) + n := len(uniqueAccountIds) + if n < 1 { + return map[string]AllMailboxesResponse{}, "", nil + } + + invocations := make([]Invocation, n) + for i, accountId := range uniqueAccountIds { + invocations[i] = invocation(CommandMailboxGet, MailboxGetCommand{AccountId: accountId}, mcid(accountId, "0")) + } + + cmd, err := j.request(session, logger, invocations...) if err != nil { - return map[string]AllMailboxesResponse{}, sessionState, err + return map[string]AllMailboxesResponse{}, "", err } - mapped := make(map[string]AllMailboxesResponse, len(resp)) - for accountId, mailboxesResponse := range resp { - mapped[accountId] = AllMailboxesResponse{ - Mailboxes: mailboxesResponse.Mailboxes, - State: mailboxesResponse.State, + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (map[string]AllMailboxesResponse, Error) { + resp := map[string]AllMailboxesResponse{} + for _, accountId := range uniqueAccountIds { + var response MailboxGetResponse + err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, mcid(accountId, "0"), &response) + if err != nil { + return map[string]AllMailboxesResponse{}, err + } + + resp[accountId] = AllMailboxesResponse{ + Mailboxes: response.List, + State: response.State, + } } - } - - return mapped, sessionState, nil + return resp, nil + }) } type Mailboxes struct { @@ -92,7 +99,11 @@ func (j *Client) SearchMailboxes(accountIds []string, session *Session, ctx cont invocations[i*2+0] = invocation(CommandMailboxQuery, MailboxQueryCommand{AccountId: accountId, Filter: filter}, mcid(accountId, "0")) invocations[i*2+1] = invocation(CommandMailboxGet, MailboxGetRefCommand{ AccountId: accountId, - IdRef: &ResultReference{Name: CommandMailboxQuery, Path: "/ids/*", ResultOf: mcid(accountId, "0")}, + IdsRef: &ResultReference{ + Name: CommandMailboxQuery, + Path: "/ids/*", + ResultOf: mcid(accountId, "0"), + }, }, mcid(accountId, "1")) } cmd, err := j.request(session, logger, invocations...) @@ -288,3 +299,56 @@ func (j *Client) GetMailboxChangesForMultipleAccounts(accountIds []string, sessi return resp, nil }) } + +func (j *Client) GetMailboxRolesForMultipleAccounts(accountIds []string, session *Session, ctx context.Context, logger *log.Logger) (map[string][]string, SessionState, Error) { + logger = j.logger("GetMailboxRolesForMultipleAccounts", session, logger) + + uniqueAccountIds := structs.Uniq(accountIds) + n := len(uniqueAccountIds) + if n < 1 { + return map[string][]string{}, "", nil + } + + t := true + + invocations := make([]Invocation, n*2) + for i, accountId := range uniqueAccountIds { + invocations[i*2+0] = invocation(CommandMailboxQuery, MailboxQueryCommand{ + AccountId: accountId, + Filter: MailboxFilterCondition{ + HasAnyRole: &t, + }, + }, mcid(accountId, "0")) + invocations[i*2+1] = invocation(CommandMailboxGet, MailboxGetRefCommand{ + AccountId: accountId, + IdsRef: &ResultReference{ + ResultOf: mcid(accountId, "0"), + Name: CommandMailboxQuery, + Path: "/ids", + }, + }, mcid(accountId, "1")) + } + + cmd, err := j.request(session, logger, invocations...) + if err != nil { + return map[string][]string{}, "", err + } + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (map[string][]string, Error) { + resp := make(map[string][]string, n) + for _, accountId := range uniqueAccountIds { + var getResponse MailboxGetResponse + err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, mcid(accountId, "1"), &getResponse) + if err != nil { + return map[string][]string{}, err + } + roles := make([]string, len(getResponse.List)) + for i, mailbox := range getResponse.List { + roles[i] = mailbox.Role + } + slices.Sort(roles) + resp[accountId] = roles + } + return resp, nil + }) +} diff --git a/pkg/jmap/jmap_error.go b/pkg/jmap/jmap_error.go index bbb49b520d..710ff397bb 100644 --- a/pkg/jmap/jmap_error.go +++ b/pkg/jmap/jmap_error.go @@ -18,9 +18,18 @@ const ( JmapErrorInvalidSessionResponse JmapErrorInvalidJmapRequestPayload JmapErrorInvalidJmapResponsePayload - JmapErrorMethodLevel JmapErrorSetError JmapErrorTooManyMethodCalls + JmapErrorUnspecifiedType + JmapErrorServerUnavailable + JmapErrorServerFail + JmapErrorUnknownMethod + JmapErrorInvalidArguments + JmapErrorInvalidResultReference + JmapErrorForbidden + JmapErrorAccountNotFound + JmapErrorAccountNotSupportedByMethod + JmapErrorAccountReadOnly ) var ( diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index 9ce4552005..48dbd50746 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -27,6 +27,28 @@ const ( JmapKeywordJunk = "$junk" JmapKeywordNotJunk = "$notjunk" JmapKeywordMdnSent = "$mdnsent" + + // https://www.iana.org/assignments/imap-mailbox-name-attributes/imap-mailbox-name-attributes.xhtml + //JmapMailboxRoleAll = "all" + //JmapMailboxRoleArchive = "archive" + JmapMailboxRoleDrafts = "drafts" + //JmapMailboxRoleFlagged = "flagged" + //JmapMailboxRoleImportant = "important" + JmapMailboxRoleInbox = "inbox" + JmapMailboxRoleJunk = "junk" + JmapMailboxRoleSent = "sent" + //JmapMailboxRoleSubscribed = "subscribed" + JmapMailboxRoleTrash = "trash" +) + +var ( + JmapMailboxRoles = []string{ + JmapMailboxRoleInbox, + JmapMailboxRoleSent, + JmapMailboxRoleDrafts, + JmapMailboxRoleJunk, + JmapMailboxRoleTrash, + } ) type SessionMailAccountCapabilities struct { @@ -352,6 +374,52 @@ type SessionResponse struct { State SessionState `json:"state,omitempty"` } +// Method level error types. +const ( + // Some internal server resource was temporarily unavailable. + // + // Attempting the same operation later (perhaps after a backoff with a random factor) may succeed. + MethodLevelErrorServerUnavailable = "serverUnavailable" + + // An unexpected or unknown error occurred during the processing of the call. + // + // A description property should provide more details about the error. The method call made no changes + // to the server’s state. Attempting the same operation again is expected to fail again. + // Contacting the service administrator is likely necessary to resolve this problem if it is persistent. + MethodLevelErrorServerFail = "serverFail" + + // Some, but not all, expected changes described by the method occurred. + // + // The client MUST resynchronise impacted data to determine server state. Use of this error is strongly discouraged. + MethodLevelErrorServerPartialFail = "serverPartialFail" + + // The server does not recognise this method name. + MethodLevelErrorUnknownMethod = "unknownMethod" + + // One of the arguments is of the wrong type or is otherwise invalid, or a required argument is missing. + // + // A description property MAY be present to help debug with an explanation of what the problem was. + // This is a non-localised string, and it is not intended to be shown directly to end users. + MethodLevelErrorInvalidArguments = "invalidArguments" + + // The method used a result reference for one of its arguments, but this failed to resolve. + MethodLevelErrorInvalidResultReference = "invalidResultReference" + + // The method and arguments are valid, but executing the method would violate an Access Control List + // (ACL) or other permissions policy. + MethodLevelErrorForbidden = "forbidden" + + // The accountId does not correspond to a valid account. + MethodLevelErrorAccountNotFound = "accountNotFound" + + // The accountId given corresponds to a valid account, but the account does not support this method or data type. + MethodLevelErrorAccountNotSupportedByMethod = "accountNotSupportedByMethod" + + // This method modifies state, but the account is read-only (as returned on the corresponding Account object in + // the JMAP Session resource). + MethodLevelErrorAccountReadOnly = "accountReadOnly" +) + // SetError type values. const ( // The create/update/destroy would violate an ACL or other permissions policy. @@ -648,7 +716,7 @@ type MailboxGetCommand struct { type MailboxGetRefCommand struct { AccountId string `json:"accountId"` - IdRef *ResultReference `json:"#ids,omitempty"` + IdsRef *ResultReference `json:"#ids,omitempty"` } type MailboxChangesCommand struct { @@ -2654,7 +2722,13 @@ type SearchSnippetGetResponse struct { NotFound []string `json:"notFound,omitempty"` } +type ErrorResponse struct { + Type string `json:"type"` + Description string `json:"description,omitempty"` +} + const ( + ErrorCommand Command = "error" // only occurs in responses CommandBlobGet Command = "Blob/get" CommandBlobUpload Command = "Blob/upload" CommandEmailGet Command = "Email/get" @@ -2675,6 +2749,7 @@ const ( ) var CommandResponseTypeMap = map[Command]func() any{ + ErrorCommand: func() any { return ErrorResponse{} }, CommandBlobGet: func() any { return BlobGetResponse{} }, CommandBlobUpload: func() any { return BlobUploadResponse{} }, CommandMailboxQuery: func() any { return MailboxQueryResponse{} }, diff --git a/pkg/jmap/jmap_tools.go b/pkg/jmap/jmap_tools.go index 81feedfaa8..4db5c4709c 100644 --- a/pkg/jmap/jmap_tools.go +++ b/pkg/jmap/jmap_tools.go @@ -3,9 +3,11 @@ package jmap import ( "context" "encoding/json" + "errors" "fmt" "maps" "reflect" + "strings" "sync" "time" @@ -66,7 +68,7 @@ func command[T any](api ApiClient, var response Response err := json.Unmarshal(responseBody, &response) if err != nil { - logger.Error().Err(err).Msg("failed to deserialize body JSON payload") + logger.Error().Err(err).Msgf("failed to deserialize body JSON payload into a %T", response) var zero T return zero, "", SimpleError{code: JmapErrorDecodingResponseBody, err: err} } @@ -80,15 +82,50 @@ func command[T any](api ApiClient, // search for an "error" response // https://jmap.io/spec-core.html#method-level-errors for _, mr := range response.MethodResponses { - if mr.Command == "error" { - err := fmt.Errorf("found method level error in response '%v'", mr.Tag) - if payload, ok := mr.Parameters.(map[string]any); ok { - if errorType, ok := payload["type"]; ok { - err = fmt.Errorf("found method level error in response '%v', type: '%v'", mr.Tag, errorType) + if mr.Command == ErrorCommand { + if errorParameters, ok := mr.Parameters.(ErrorResponse); ok { + code := JmapErrorServerFail + switch errorParameters.Type { + case MethodLevelErrorServerUnavailable: + code = JmapErrorServerUnavailable + case MethodLevelErrorServerFail, MethodLevelErrorServerPartialFail: + code = JmapErrorServerFail + case MethodLevelErrorUnknownMethod: + code = JmapErrorUnknownMethod + case MethodLevelErrorInvalidArguments: + code = JmapErrorInvalidArguments + case MethodLevelErrorInvalidResultReference: + code = JmapErrorInvalidResultReference + case MethodLevelErrorForbidden: + // there's a quirk here: when referencing an account that exists but that this + // user has no access to, Stalwart returns the 'forbidden' error, but this might + // leak the existence of an account to an attacker -- instead, we deem it safer to + // return a "account does not exist" error instead + if strings.HasPrefix(errorParameters.Description, "You do not have access to account") { + code = JmapErrorAccountNotFound + } else { + code = JmapErrorForbidden + } + case MethodLevelErrorAccountNotFound: + code = JmapErrorAccountNotFound + case MethodLevelErrorAccountNotSupportedByMethod: + code = JmapErrorAccountNotSupportedByMethod + case MethodLevelErrorAccountReadOnly: + code = JmapErrorAccountReadOnly } + msg := fmt.Sprintf("found method level error in response '%v', type: '%v', description: '%v'", mr.Tag, errorParameters.Type, errorParameters.Description) + err = errors.New(msg) + logger.Warn().Int("code", code).Str("type", errorParameters.Type).Msg(msg) + var zero T + return zero, response.SessionState, SimpleError{code: code, err: err} + } else { + code := JmapErrorUnspecifiedType + msg := fmt.Sprintf("found method level error in response '%v'", mr.Tag) + err := errors.New(msg) + logger.Warn().Int("code", code).Msg(msg) + var zero T + return zero, response.SessionState, SimpleError{code: code, err: err} } - var zero T - return zero, response.SessionState, SimpleError{code: JmapErrorMethodLevel, err: err} } } diff --git a/pkg/jmap/jmap_tools_test.go b/pkg/jmap/jmap_tools_test.go index d6c385b770..2c0beea6d8 100644 --- a/pkg/jmap/jmap_tools_test.go +++ b/pkg/jmap/jmap_tools_test.go @@ -134,3 +134,19 @@ func TestMarshallingUnknown(t *testing.T) { require.NoError(err) require.Equal(`{"subject":"aaa","bodyStructure":{"header:a":"bc","header:x":"yz","partId":"b","type":"a"}}`, string(result)) } + +func TestUnmarshallingError(t *testing.T) { + require := require.New(t) + + responseBody := `{"methodResponses":[["error",{"type":"forbidden","description":"You do not have access to account a"},"a:0"]],"sessionState":"3e25b2a0"}` + var response Response + err := json.Unmarshal([]byte(responseBody), &response) + require.NoError(err) + require.Len(response.MethodResponses, 1) + require.Equal(ErrorCommand, response.MethodResponses[0].Command) + require.Equal("a:0", response.MethodResponses[0].Tag) + require.IsType(ErrorResponse{}, response.MethodResponses[0].Parameters) + er, _ := response.MethodResponses[0].Parameters.(ErrorResponse) + require.Equal("forbidden", er.Type) + require.Equal("You do not have access to account a", er.Description) +} diff --git a/services/groupware/pkg/groupware/groupware_api_mailbox.go b/services/groupware/pkg/groupware/groupware_api_mailbox.go index 75ad42634d..2472e6a21a 100644 --- a/services/groupware/pkg/groupware/groupware_api_mailbox.go +++ b/services/groupware/pkg/groupware/groupware_api_mailbox.go @@ -41,13 +41,12 @@ func (g *Groupware) GetMailbox(w http.ResponseWriter, r *http.Request) { return errorResponse(err) } - mailboxesByAccountId, sessionState, jerr := g.jmap.GetMailbox([]string{accountId}, req.session, req.ctx, req.logger, []string{mailboxId}) + mailboxes, sessionState, jerr := g.jmap.GetMailbox(accountId, req.session, req.ctx, req.logger, []string{mailboxId}) if jerr != nil { return req.errorResponseFromJmap(jerr) } - mailboxes, ok := mailboxesByAccountId[accountId] - if ok && len(mailboxes.Mailboxes) == 1 { + if len(mailboxes.Mailboxes) == 1 { return etagResponse(mailboxes.Mailboxes[0], sessionState, mailboxes.State) } else { return notFoundResponse(sessionState) @@ -209,6 +208,27 @@ func (g *Groupware) GetMailboxesForAllAccounts(w http.ResponseWriter, r *http.Re }) } +func (g *Groupware) GetMailboxByRoleForAllAccounts(w http.ResponseWriter, r *http.Request) { + role := chi.URLParam(r, UriParamRole) + g.respond(w, r, func(req Request) Response { + accountIds := structs.Keys(req.session.Accounts) + if len(accountIds) < 1 { + return noContentResponse("") + } + logger := log.From(req.logger.With().Array(logAccountId, log.SafeStringArray(accountIds)).Str("role", role)) + + filter := jmap.MailboxFilterCondition{ + Role: role, + } + + mailboxesByAccountId, sessionState, err := g.jmap.SearchMailboxes(accountIds, req.session, req.ctx, logger, filter) + if err != nil { + return req.errorResponseFromJmap(err) + } + return response(mailboxesByAccountId, sessionState) + }) +} + // When the request succeeds. // swagger:response MailboxChangesResponse200 type SwaggerMailboxChangesResponse200 struct { @@ -308,3 +328,19 @@ func (g *Groupware) GetMailboxChangesForAllAccounts(w http.ResponseWriter, r *ht return response(changesByAccountId, sessionState) }) } + +func (g *Groupware) GetMailboxRoles(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + l := req.logger.With() + allAccountIds := structs.Keys(req.session.Accounts) // TODO(pbleser-oc) do we need a limit for a maximum amount of accounts to query at once? + l.Array(logAccountId, log.SafeStringArray(allAccountIds)) + logger := log.From(l) + + rolesByAccountId, sessionState, jerr := g.jmap.GetMailboxRolesForMultipleAccounts(allAccountIds, req.session, req.ctx, logger) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + + return response(rolesByAccountId, sessionState) + }) +} diff --git a/services/groupware/pkg/groupware/groupware_error.go b/services/groupware/pkg/groupware/groupware_error.go index ae95a0a3f4..0039cb7fd7 100644 --- a/services/groupware/pkg/groupware/groupware_error.go +++ b/services/groupware/pkg/groupware/groupware_error.go @@ -138,6 +138,20 @@ func groupwareErrorFromJmap(j jmap.Error) *GroupwareError { return &ErrorInvalidRequestPayload case jmap.JmapErrorInvalidJmapResponsePayload: return &ErrorInvalidResponsePayload + case jmap.JmapErrorUnspecifiedType, jmap.JmapErrorUnknownMethod, jmap.JmapErrorInvalidArguments, jmap.JmapErrorInvalidResultReference: + return &ErrorInvalidGroupwareRequest + case jmap.JmapErrorServerUnavailable: + return &ErrorServerUnavailable + case jmap.JmapErrorServerFail: + return &ErrorServerFailure + case jmap.JmapErrorForbidden: + return &ErrorForbiddenOperation + case jmap.JmapErrorAccountNotFound: + return &ErrorAccountNotFound + case jmap.JmapErrorAccountNotSupportedByMethod: + return &ErrorAccountNotSupportedByMethod + case jmap.JmapErrorAccountReadOnly: + return &ErrorAccountReadOnly default: return &ErrorGeneric } @@ -167,6 +181,13 @@ const ( ErrorCodeInvalidUserRequest = "INVURQ" ErrorCodeUsernameEmailDomainNotGreenListed = "UEDGRE" ErrorCodeUsernameEmailDomainRedListed = "UEDRED" + ErrorCodeInvalidGroupwareRequest = "GPRERR" + ErrorCodeServerUnavailable = "SRVUNA" + ErrorCodeServerFailure = "SRVFLR" + ErrorCodeForbiddenOperation = "FRBOPR" + ErrorCodeAccountNotFound = "ACCNFD" + ErrorCodeAccountNotSupportedByMethod = "ACCNSM" + ErrorCodeAccountReadOnly = "ACCRDO" ) var ( @@ -308,6 +329,48 @@ var ( Title: "Domain is redlisted", Detail: "The username email address domain is redlisted.", } + ErrorInvalidGroupwareRequest = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeInvalidGroupwareRequest, + Title: "Internal Request Error", + Detail: "The request constructed by the Groupware is regarded as invalid by the Mail server.", + } + ErrorServerUnavailable = GroupwareError{ + Status: http.StatusServiceUnavailable, + Code: ErrorCodeServerUnavailable, + Title: "Mail Server is unavailable", + Detail: "The Mail Server is currently unable to process the request.", + } + ErrorServerFailure = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeServerFailure, + Title: "Mail Server is unable to process the Request", + Detail: "The Mail Server is unable to process the request.", + } + ErrorForbiddenOperation = GroupwareError{ + Status: http.StatusForbidden, + Code: ErrorCodeForbiddenOperation, + Title: "The Operation is forbidden by the Mail Server", + Detail: "The Mail Server refuses to perform the request.", + } + ErrorAccountNotFound = GroupwareError{ + Status: http.StatusNotFound, + Code: ErrorCodeAccountNotFound, + Title: "The referenced Account does not exist", + Detail: "The Account that was referenced in the request does not exist.", + } + ErrorAccountNotSupportedByMethod = GroupwareError{ + Status: http.StatusForbidden, + Code: ErrorCodeAccountNotSupportedByMethod, + Title: "The referenced Account does not supported the requested method", + Detail: "The Account that was referenced in the request does not supported the requested method or data type.", + } + ErrorAccountReadOnly = GroupwareError{ + Status: http.StatusForbidden, + Code: ErrorCodeAccountReadOnly, + Title: "The referenced Account is read-only", + Detail: "The Account that was referenced in the request only supports read-only operations.", + } ) type ErrorOpt interface { diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go index 664fb61e99..b87126b38a 100644 --- a/services/groupware/pkg/groupware/groupware_route.go +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -15,6 +15,7 @@ const ( UriParamBlobId = "blobid" UriParamBlobName = "blobname" UriParamStreamId = "stream" + UriParamRole = "role" QueryParamMailboxSearchName = "name" QueryParamMailboxSearchRole = "role" QueryParamMailboxSearchSubscribed = "subscribed" @@ -48,8 +49,10 @@ func (g *Groupware) Route(r chi.Router) { r.Get("/accounts", g.GetAccounts) r.Route("/accounts/all", func(r chi.Router) { r.Route("/mailboxes", func(r chi.Router) { - r.Get("/", g.GetMailboxesForAllAccounts) + r.Get("/", g.GetMailboxesForAllAccounts) // ?role= r.Get("/changes", g.GetMailboxChangesForAllAccounts) + r.Get("/roles", g.GetMailboxRoles) // ?role= + r.Get("/roles/{role}", g.GetMailboxByRoleForAllAccounts) // ?role= }) }) r.Route("/accounts/{accountid}", func(r chi.Router) {