fix(groupware): fix JMAP error handling

* the JMAP error handling was not working properly, fixed it and added
   error definitions accordingly

 * add operations to retrieve mailbox roles and mailboxes by role for
   all accounts
This commit is contained in:
Pascal Bleser
2025-09-10 17:01:57 +02:00
parent 641d203efe
commit eb2660a631
8 changed files with 357 additions and 54 deletions

View File

@@ -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
})
}

View File

@@ -18,9 +18,18 @@ const (
JmapErrorInvalidSessionResponse
JmapErrorInvalidJmapRequestPayload
JmapErrorInvalidJmapResponsePayload
JmapErrorMethodLevel
JmapErrorSetError
JmapErrorTooManyMethodCalls
JmapErrorUnspecifiedType
JmapErrorServerUnavailable
JmapErrorServerFail
JmapErrorUnknownMethod
JmapErrorInvalidArguments
JmapErrorInvalidResultReference
JmapErrorForbidden
JmapErrorAccountNotFound
JmapErrorAccountNotSupportedByMethod
JmapErrorAccountReadOnly
)
var (

View File

@@ -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 servers 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{} },

View File

@@ -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}
}
}

View File

@@ -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)
}

View File

@@ -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)
})
}

View File

@@ -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 {

View File

@@ -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) {