diff --git a/pkg/jmap/jmap.go b/pkg/jmap/jmap.go index b749139425..285412a13a 100644 --- a/pkg/jmap/jmap.go +++ b/pkg/jmap/jmap.go @@ -11,7 +11,7 @@ import ( ) type Client struct { - wellKnown WellKnownClient + wellKnown SessionClient api ApiClient io.Closer } @@ -20,7 +20,7 @@ func (j *Client) Close() error { return j.api.Close() } -func NewClient(wellKnown WellKnownClient, api ApiClient) Client { +func NewClient(wellKnown SessionClient, api ApiClient) Client { return Client{ wellKnown: wellKnown, api: api, @@ -79,41 +79,23 @@ func (s Session) DecorateLogger(l log.Logger) log.Logger { } } -var ( - errWellKnownResponseHasNoUsername = fmt.Errorf("well-known response has no username") - errWellKnownResponseHasJmapMailPrimaryAccount = fmt.Errorf("PrimaryAccounts in well-known response has no entry for %v", JmapMail) - errWellKnownResponseHasNoApiUrl = fmt.Errorf("well-known response has no API URL") -) - -type WellKnownResponseHasInvalidApiUrlError struct { - ApiUrl string - Err error -} - -func (e WellKnownResponseHasInvalidApiUrlError) Error() string { - return fmt.Sprintf("well-known response contains an invalid API URL '%s': %v", e.ApiUrl, e.Err.Error()) -} -func (e WellKnownResponseHasInvalidApiUrlError) Unwrap() error { - return e.Err -} - // Create a new Session from a WellKnownResponse. -func NewSession(wellKnownResponse WellKnownResponse) (Session, error) { - username := wellKnownResponse.Username +func NewSession(sessionResponse SessionResponse) (Session, Error) { + username := sessionResponse.Username if username == "" { - return Session{}, errWellKnownResponseHasNoUsername + return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a username")} } - accountId := wellKnownResponse.PrimaryAccounts[JmapMail] + accountId := sessionResponse.PrimaryAccounts[JmapMail] if accountId == "" { - return Session{}, errWellKnownResponseHasJmapMailPrimaryAccount + return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a primary mail account")} } - apiStr := wellKnownResponse.ApiUrl + apiStr := sessionResponse.ApiUrl if apiStr == "" { - return Session{}, errWellKnownResponseHasNoApiUrl + return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an API URL")} } apiUrl, err := url.Parse(apiStr) if err != nil { - return Session{}, WellKnownResponseHasInvalidApiUrlError{ApiUrl: apiStr, Err: err} + return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response provides an invalid API URL")} } return Session{ Username: username, @@ -123,8 +105,8 @@ func NewSession(wellKnownResponse WellKnownResponse) (Session, error) { } // Retrieve JMAP well-known data from the Stalwart server and create a Session from that. -func (j *Client) FetchSession(username string, logger *log.Logger) (Session, error) { - wk, err := j.wellKnown.GetWellKnown(username, logger) +func (j *Client) FetchSession(username string, logger *log.Logger) (Session, Error) { + wk, err := j.wellKnown.GetSession(username, logger) if err != nil { return Session{}, err } @@ -141,62 +123,62 @@ func (j *Client) loggerParams(operation string, session *Session, logger *log.Lo } // https://jmap.io/spec-mail.html#identityget -func (j *Client) GetIdentity(session *Session, ctx context.Context, logger *log.Logger) (IdentityGetResponse, error) { +func (j *Client) GetIdentity(session *Session, ctx context.Context, logger *log.Logger) (IdentityGetResponse, Error) { logger = j.logger("GetIdentity", session, logger) cmd, err := request(invocation(IdentityGet, IdentityGetCommand{AccountId: session.AccountId}, "0")) if err != nil { - return IdentityGetResponse{}, err + return IdentityGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } - return command(j.api, logger, ctx, session, cmd, func(body *Response) (IdentityGetResponse, error) { + return command(j.api, logger, ctx, session, cmd, func(body *Response) (IdentityGetResponse, Error) { var response IdentityGetResponse err = retrieveResponseMatchParameters(body, IdentityGet, "0", &response) - return response, err + return response, simpleError(err, JmapErrorInvalidJmapResponsePayload) }) } // https://jmap.io/spec-mail.html#vacationresponseget -func (j *Client) GetVacationResponse(session *Session, ctx context.Context, logger *log.Logger) (VacationResponseGetResponse, error) { +func (j *Client) GetVacationResponse(session *Session, ctx context.Context, logger *log.Logger) (VacationResponseGetResponse, Error) { logger = j.logger("GetVacationResponse", session, logger) cmd, err := request(invocation(VacationResponseGet, VacationResponseGetCommand{AccountId: session.AccountId}, "0")) if err != nil { - return VacationResponseGetResponse{}, err + return VacationResponseGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } - return command(j.api, logger, ctx, session, cmd, func(body *Response) (VacationResponseGetResponse, error) { + return command(j.api, logger, ctx, session, cmd, func(body *Response) (VacationResponseGetResponse, Error) { var response VacationResponseGetResponse err = retrieveResponseMatchParameters(body, VacationResponseGet, "0", &response) - return response, err + return response, simpleError(err, JmapErrorInvalidJmapResponsePayload) }) } // https://jmap.io/spec-mail.html#mailboxget -func (j *Client) GetMailbox(session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxGetResponse, error) { +func (j *Client) GetMailbox(session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxGetResponse, Error) { logger = j.logger("GetMailbox", session, logger) cmd, err := request(invocation(MailboxGet, MailboxGetCommand{AccountId: session.AccountId, Ids: ids}, "0")) if err != nil { - return MailboxGetResponse{}, err + return MailboxGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } - return command(j.api, logger, ctx, session, cmd, func(body *Response) (MailboxGetResponse, error) { + return command(j.api, logger, ctx, session, cmd, func(body *Response) (MailboxGetResponse, Error) { var response MailboxGetResponse err = retrieveResponseMatchParameters(body, MailboxGet, "0", &response) - return response, err + return response, simpleError(err, JmapErrorInvalidJmapResponsePayload) }) } -func (j *Client) GetAllMailboxes(session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, error) { +func (j *Client) GetAllMailboxes(session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, Error) { return j.GetMailbox(session, ctx, logger, nil) } // https://jmap.io/spec-mail.html#mailboxquery -func (j *Client) QueryMailbox(session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (MailboxQueryResponse, error) { +func (j *Client) QueryMailbox(session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (MailboxQueryResponse, Error) { logger = j.logger("QueryMailbox", session, logger) cmd, err := request(invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: session.AccountId, Filter: filter}, "0")) if err != nil { - return MailboxQueryResponse{}, err + return MailboxQueryResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } - return command(j.api, logger, ctx, session, cmd, func(body *Response) (MailboxQueryResponse, error) { + return command(j.api, logger, ctx, session, cmd, func(body *Response) (MailboxQueryResponse, Error) { var response MailboxQueryResponse err = retrieveResponseMatchParameters(body, MailboxQuery, "0", &response) - return response, err + return response, simpleError(err, JmapErrorInvalidJmapResponsePayload) }) } @@ -205,7 +187,7 @@ type Mailboxes struct { State string `json:"state,omitempty"` } -func (j *Client) SearchMailboxes(session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (Mailboxes, error) { +func (j *Client) SearchMailboxes(session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (Mailboxes, Error) { logger = j.logger("SearchMailboxes", session, logger) cmd, err := request( @@ -216,14 +198,14 @@ func (j *Client) SearchMailboxes(session *Session, ctx context.Context, logger * }, "1"), ) if err != nil { - return Mailboxes{}, err + return Mailboxes{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } - return command(j.api, logger, ctx, session, cmd, func(body *Response) (Mailboxes, error) { + return command(j.api, logger, ctx, session, cmd, func(body *Response) (Mailboxes, Error) { var response MailboxGetResponse err = retrieveResponseMatchParameters(body, MailboxGet, "1", &response) if err != nil { - return Mailboxes{}, err + return Mailboxes{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } return Mailboxes{Mailboxes: response.List, State: body.SessionState}, nil }) @@ -234,7 +216,7 @@ type Emails struct { State string `json:"state,omitempty"` } -func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Logger, mailboxId string, offset int, limit int, fetchBodies bool, maxBodyValueBytes int) (Emails, error) { +func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Logger, mailboxId string, offset int, limit int, fetchBodies bool, maxBodyValueBytes int) (Emails, Error) { logger = j.loggerParams("GetEmails", session, logger, func(z zerolog.Context) zerolog.Context { return z.Bool(logFetchBodies, fetchBodies).Int(logOffset, offset).Int(logLimit, limit) }) @@ -267,14 +249,14 @@ func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Lo invocation(EmailGet, get, "1"), ) if err != nil { - return Emails{}, err + return Emails{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } - return command(j.api, logger, ctx, session, cmd, func(body *Response) (Emails, error) { + return command(j.api, logger, ctx, session, cmd, func(body *Response) (Emails, Error) { var response EmailGetResponse err = retrieveResponseMatchParameters(body, EmailGet, "1", &response) if err != nil { - return Emails{}, err + return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } return Emails{Emails: response.List, State: body.SessionState}, nil }) diff --git a/pkg/jmap/jmap_api.go b/pkg/jmap/jmap_api.go index 31e379d425..2a48b10241 100644 --- a/pkg/jmap/jmap_api.go +++ b/pkg/jmap/jmap_api.go @@ -8,10 +8,10 @@ import ( ) type ApiClient interface { - Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, error) + Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, Error) io.Closer } -type WellKnownClient interface { - GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error) +type SessionClient interface { + GetSession(username string, logger *log.Logger) (SessionResponse, Error) } diff --git a/pkg/jmap/jmap_error.go b/pkg/jmap/jmap_error.go new file mode 100644 index 0000000000..2a39d94813 --- /dev/null +++ b/pkg/jmap/jmap_error.go @@ -0,0 +1,45 @@ +package jmap + +const ( + JmapErrorAuthenticationFailed = iota + JmapErrorInvalidHttpRequest + JmapErrorServerResponse + JmapErrorReadingResponseBody + JmapErrorDecodingResponseBody + JmapErrorEncodingRequestBody + JmapErrorCreatingRequest + JmapErrorSendingRequest + JmapErrorInvalidSessionResponse + JmapErrorInvalidJmapRequestPayload + JmapErrorInvalidJmapResponsePayload +) + +type Error interface { + Code() int + error +} + +type SimpleError struct { + code int + err error +} + +var _ Error = &SimpleError{} + +func (e SimpleError) Code() int { + return e.code +} +func (e SimpleError) Unwrap() error { + return e.err +} +func (e SimpleError) Error() string { + return e.err.Error() +} + +func simpleError(err error, code int) Error { + if err != nil { + return SimpleError{code: code, err: err} + } else { + return nil + } +} diff --git a/pkg/jmap/jmap_http.go b/pkg/jmap/jmap_http.go index d1fbf6c55c..cb8d4faa8f 100644 --- a/pkg/jmap/jmap_http.go +++ b/pkg/jmap/jmap_http.go @@ -22,8 +22,8 @@ type HttpJmapApiClient struct { } var ( - _ ApiClient = &HttpJmapApiClient{} - _ WellKnownClient = &HttpJmapApiClient{} + _ ApiClient = &HttpJmapApiClient{} + _ SessionClient = &HttpJmapApiClient{} ) /* @@ -64,28 +64,13 @@ func (h *HttpJmapApiClient) auth(username string, logger *log.Logger, req *http. return nil } -type HttpError struct { - Method string - Url string - Username string - Op string - Err error -} - -func (e HttpError) Error() string { - return fmt.Sprintf("HTTP error for method=%v url='%v' username='%v' while %v: %v", e.Method, e.Url, e.Username, e.Op, e.Err.Error()) -} -func (e HttpError) Unwrap() error { - return e.Err -} - -func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error) { +func (h *HttpJmapApiClient) GetSession(username string, logger *log.Logger) (SessionResponse, Error) { wellKnownUrl := h.baseurl.JoinPath(".well-known", "jmap").String() req, err := http.NewRequest(http.MethodGet, wellKnownUrl, nil) if err != nil { logger.Error().Err(err).Msgf("failed to create GET request for %v", wellKnownUrl) - return WellKnownResponse{}, HttpError{Op: "creating request", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err} + return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: err} } h.auth(username, logger, req) req.Header.Add("Cache-Control", "no-cache, no-store, must-revalidate") // spec recommendation @@ -93,11 +78,11 @@ func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (W res, err := h.client.Do(req) if err != nil { logger.Error().Err(err).Msgf("failed to perform GET %v", wellKnownUrl) - return WellKnownResponse{}, HttpError{Op: "performing request", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err} + return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: err} } - if res.StatusCode != 200 { + if res.StatusCode < 200 || res.StatusCode > 299 { logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 200") - return WellKnownResponse{}, HttpError{Op: "processing response", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: fmt.Errorf("status is %v", res.Status)} + return SessionResponse{}, SimpleError{code: JmapErrorServerResponse, err: fmt.Errorf("JMAP API response status is %v", res.Status)} } if res.Body != nil { defer func(Body io.ReadCloser) { @@ -111,32 +96,32 @@ func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (W body, err := io.ReadAll(res.Body) if err != nil { logger.Error().Err(err).Msg("failed to read response body") - return WellKnownResponse{}, HttpError{Op: "reading response body", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err} + return SessionResponse{}, SimpleError{code: JmapErrorReadingResponseBody, err: err} } - var data WellKnownResponse + var data SessionResponse err = json.Unmarshal(body, &data) if err != nil { logger.Error().Str("url", wellKnownUrl).Err(err).Msg("failed to decode JSON payload from .well-known/jmap response") - return WellKnownResponse{}, HttpError{Op: "reading decoding response JSON payload", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err} + return SessionResponse{}, SimpleError{code: JmapErrorDecodingResponseBody, err: err} } return data, nil } -func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, error) { +func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, Error) { jmapUrl := session.JmapUrl.String() - bodyBytes, marshalErr := json.Marshal(request) - if marshalErr != nil { - logger.Error().Err(marshalErr).Msg("failed to marshall JSON payload") - return nil, marshalErr + bodyBytes, err := json.Marshal(request) + if err != nil { + logger.Error().Err(err).Msg("failed to marshall JSON payload") + return nil, SimpleError{code: JmapErrorEncodingRequestBody, err: err} } - req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, jmapUrl, bytes.NewBuffer(bodyBytes)) - if reqErr != nil { - logger.Error().Err(reqErr).Msgf("failed to create GET request for %v", jmapUrl) - return nil, reqErr + req, err := http.NewRequestWithContext(ctx, http.MethodPost, jmapUrl, bytes.NewBuffer(bodyBytes)) + if err != nil { + logger.Error().Err(err).Msgf("failed to create POST request for %v", jmapUrl) + return nil, SimpleError{code: JmapErrorCreatingRequest, err: err} } req.Header.Add("Content-Type", "application/json") req.Header.Add("User-Agent", h.userAgent) @@ -144,12 +129,12 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses res, err := h.client.Do(req) if err != nil { - logger.Error().Err(err).Msgf("failed to perform GET %v", jmapUrl) - return nil, HttpError{Op: "performing request", Method: http.MethodPost, Url: jmapUrl, Username: session.Username, Err: err} + logger.Error().Err(err).Msgf("failed to perform POST %v", jmapUrl) + return nil, SimpleError{code: JmapErrorSendingRequest, err: err} } if res.StatusCode < 200 || res.StatusCode > 299 { logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 2xx") - return nil, HttpError{Op: "processing response", Method: http.MethodPost, Url: jmapUrl, Username: session.Username, Err: fmt.Errorf("status is %v", res.Status)} + return nil, SimpleError{code: JmapErrorServerResponse, err: err} } if res.Body != nil { defer func(Body io.ReadCloser) { @@ -163,7 +148,7 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses body, err := io.ReadAll(res.Body) if err != nil { logger.Error().Err(err).Msg("failed to read response body") - return nil, HttpError{Op: "reading response body", Method: http.MethodPost, Url: jmapUrl, Username: session.Username, Err: err} + return nil, SimpleError{code: JmapErrorServerResponse, err: err} } return body, nil diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index 58c9df51bf..c11cebe613 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -23,23 +23,23 @@ const ( JmapKeywordMdnSent = "$mdnsent" ) -type WellKnownAccount struct { +type SessionAccount struct { Name string `json:"name,omitempty"` IsPersonal bool `json:"isPersonal"` IsReadOnly bool `json:"isReadOnly"` AccountCapabilities map[string]any `json:"accountCapabilities,omitempty"` } -type WellKnownResponse struct { - Capabilities map[string]any `json:"capabilities,omitempty"` - Accounts map[string]WellKnownAccount `json:"accounts,omitempty"` - PrimaryAccounts map[string]string `json:"primaryAccounts,omitempty"` - Username string `json:"username,omitempty"` - ApiUrl string `json:"apiUrl,omitempty"` - DownloadUrl string `json:"downloadUrl,omitempty"` - UploadUrl string `json:"uploadUrl,omitempty"` - EventSourceUrl string `json:"eventSourceUrl,omitempty"` - State string `json:"state,omitempty"` +type SessionResponse struct { + Capabilities map[string]any `json:"capabilities,omitempty"` + Accounts map[string]SessionAccount `json:"accounts,omitempty"` + PrimaryAccounts map[string]string `json:"primaryAccounts,omitempty"` + Username string `json:"username,omitempty"` + ApiUrl string `json:"apiUrl,omitempty"` + DownloadUrl string `json:"downloadUrl,omitempty"` + UploadUrl string `json:"uploadUrl,omitempty"` + EventSourceUrl string `json:"eventSourceUrl,omitempty"` + State string `json:"state,omitempty"` } type Mailbox struct { diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go index dd044dd85e..8d3a09f845 100644 --- a/pkg/jmap/jmap_test.go +++ b/pkg/jmap/jmap_test.go @@ -19,7 +19,7 @@ type TestJmapWellKnownClient struct { t *testing.T } -func NewTestJmapWellKnownClient(t *testing.T) WellKnownClient { +func NewTestJmapWellKnownClient(t *testing.T) SessionClient { return &TestJmapWellKnownClient{t: t} } @@ -27,8 +27,8 @@ func (t *TestJmapWellKnownClient) Close() error { return nil } -func (t *TestJmapWellKnownClient) GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error) { - return WellKnownResponse{ +func (t *TestJmapWellKnownClient) GetSession(username string, logger *log.Logger) (SessionResponse, Error) { + return SessionResponse{ Username: generateRandomString(8), ApiUrl: "test://", PrimaryAccounts: map[string]string{JmapMail: generateRandomString(2 + seededRand.Intn(10))}, @@ -47,12 +47,12 @@ func (t TestJmapApiClient) Close() error { return nil } -func serveTestFile(t *testing.T, name string) ([]byte, error) { +func serveTestFile(t *testing.T, name string) ([]byte, Error) { cwd, _ := os.Getwd() p := filepath.Join(cwd, "testdata", name) bytes, err := os.ReadFile(p) if err != nil { - return bytes, err + return bytes, SimpleError{code: 0, err: err} } // try to parse it first to avoid any deeper issues that are caused by the test tools var target map[string]any @@ -60,10 +60,10 @@ func serveTestFile(t *testing.T, name string) ([]byte, error) { if err != nil { t.Errorf("failed to parse JSON test data file '%v': %v", p, err) } - return bytes, err + return bytes, SimpleError{code: 0, err: err} } -func (t *TestJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, error) { +func (t *TestJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, Error) { command := request.MethodCalls[0].Command switch command { case MailboxGet: @@ -72,7 +72,7 @@ func (t *TestJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses return serveTestFile(t.t, "mails1.json") default: require.Fail(t.t, "TestJmapApiClient: unsupported jmap command: %v", command) - return nil, fmt.Errorf("TestJmapApiClient: unsupported jmap command: %v", command) + return nil, SimpleError{code: 0, err: fmt.Errorf("TestJmapApiClient: unsupported jmap command: %v", command)} } } diff --git a/pkg/jmap/jmap_tools.go b/pkg/jmap/jmap_tools.go index 4d60a17e7b..590f738d02 100644 --- a/pkg/jmap/jmap_tools.go +++ b/pkg/jmap/jmap_tools.go @@ -16,20 +16,20 @@ func command[T any](api ApiClient, ctx context.Context, session *Session, request Request, - mapper func(body *Response) (T, error)) (T, error) { + mapper func(body *Response) (T, Error)) (T, Error) { - responseBody, err := api.Command(ctx, logger, session, request) - if err != nil { + responseBody, jmapErr := api.Command(ctx, logger, session, request) + if jmapErr != nil { var zero T - return zero, err + return zero, jmapErr } var data Response - err = json.Unmarshal(responseBody, &data) + err := json.Unmarshal(responseBody, &data) if err != nil { logger.Error().Err(err).Msg("failed to deserialize body JSON payload") var zero T - return zero, err + return zero, SimpleError{code: JmapErrorDecodingResponseBody, err: err} } return mapper(&data) diff --git a/pkg/jmap/jmap_tools_test.go b/pkg/jmap/jmap_tools_test.go index fccf822382..3734bc21ec 100644 --- a/pkg/jmap/jmap_tools_test.go +++ b/pkg/jmap/jmap_tools_test.go @@ -9,10 +9,10 @@ import ( func TestDeserializeMailboxGetResponse(t *testing.T) { require := require.New(t) - jsonBytes, err := serveTestFile(t, "mailboxes1.json") - require.NoError(err) + jsonBytes, jmapErr := serveTestFile(t, "mailboxes1.json") + require.NoError(jmapErr) var data Response - err = json.Unmarshal(jsonBytes, &data) + err := json.Unmarshal(jsonBytes, &data) require.NoError(err) require.Empty(data.CreatedIds) require.Equal("3e25b2a0", data.SessionState) @@ -62,10 +62,10 @@ func TestDeserializeMailboxGetResponse(t *testing.T) { func TestDeserializeEmailGetResponse(t *testing.T) { require := require.New(t) - jsonBytes, err := serveTestFile(t, "mails1.json") - require.NoError(err) + jsonBytes, jmapErr := serveTestFile(t, "mails1.json") + require.NoError(jmapErr) var data Response - err = json.Unmarshal(jsonBytes, &data) + err := json.Unmarshal(jsonBytes, &data) require.NoError(err) require.Empty(data.CreatedIds) require.Equal("3e25b2a0", data.SessionState) diff --git a/services/groupware/pkg/groupware/groupware.go b/services/groupware/pkg/groupware/groupware.go index cdf5b5348c..4a2f729f5a 100644 --- a/services/groupware/pkg/groupware/groupware.go +++ b/services/groupware/pkg/groupware/groupware.go @@ -14,7 +14,6 @@ import ( func (g Groupware) Route(r chi.Router) { r.Get("/", g.Index) - r.Get("/ping", g.Ping) r.Get("/mailboxes", g.GetMailboxes) // ?name=&role=&subcribed= r.Get("/mailbox/{id}", g.GetMailboxById) r.Get("/{mailbox}/messages", g.GetMessages) @@ -30,11 +29,6 @@ func (IndexResponse) Render(w http.ResponseWriter, r *http.Request) error { return nil } -func (g Groupware) Ping(w http.ResponseWriter, r *http.Request) { - g.logger.Info().Msg("groupware pinged") - w.WriteHeader(http.StatusNoContent) -} - func (g Groupware) Index(w http.ResponseWriter, r *http.Request) { ctx := r.Context() logger := g.logger.SubloggerWithRequestID(ctx) @@ -55,16 +49,16 @@ func (g Groupware) Index(w http.ResponseWriter, r *http.Request) { } func (g Groupware) GetIdentity(w http.ResponseWriter, r *http.Request) { - g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) { + g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, *ApiError) { res, err := g.jmap.GetIdentity(session, ctx, logger) - return res, res.State, err + return res, res.State, apiErrorFromJmap(err) }) } func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) { - g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) { + g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, *ApiError) { res, err := g.jmap.GetVacationResponse(session, ctx, logger) - return res, res.State, err + return res, res.State, apiErrorFromJmap(err) }) } @@ -75,16 +69,16 @@ func (g Groupware) GetMailboxById(w http.ResponseWriter, r *http.Request) { return } - g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) { + g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, *ApiError) { res, err := g.jmap.GetMailbox(session, ctx, logger, []string{mailboxId}) if err != nil { - return res, "", err + return res, "", apiErrorFromJmap(err) } if len(res.List) == 1 { - return res.List[0], res.State, err + return res.List[0], res.State, apiErrorFromJmap(err) } else { - return nil, res.State, err + return nil, res.State, apiErrorFromJmap(err) } }) } @@ -116,18 +110,18 @@ func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) { } if hasCriteria { - g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) { + g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, *ApiError) { mailboxes, err := g.jmap.SearchMailboxes(session, ctx, logger, filter) if err != nil { - return nil, "", err + return nil, "", apiErrorFromJmap(err) } return mailboxes.Mailboxes, mailboxes.State, nil }) } else { - g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) { + g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, *ApiError) { mailboxes, err := g.jmap.GetAllMailboxes(session, ctx, logger) if err != nil { - return nil, "", err + return nil, "", apiErrorFromJmap(err) } return mailboxes.List, mailboxes.State, nil }) @@ -136,7 +130,7 @@ func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) { func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { mailboxId := chi.URLParam(r, "mailbox") - g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error) { + g.respond(w, r, func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, *ApiError) { page, ok, _ := ParseNumericParam(r, "page", -1) if ok { logger = &log.Logger{Logger: logger.With().Int("page", page).Logger()} @@ -154,7 +148,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { emails, err := g.jmap.GetEmails(session, ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes) if err != nil { - return nil, "", err + return nil, "", apiErrorFromJmap(err) } return emails, emails.State, nil diff --git a/services/groupware/pkg/groupware/groupware_api_error.go b/services/groupware/pkg/groupware/groupware_api_error.go new file mode 100644 index 0000000000..4c26e55af0 --- /dev/null +++ b/services/groupware/pkg/groupware/groupware_api_error.go @@ -0,0 +1,295 @@ +package groupware + +import ( + "net/http" + "strconv" + + "github.com/go-chi/render" + "github.com/google/uuid" + "github.com/opencloud-eu/opencloud/pkg/jmap" +) + +type Link struct { + Href string `json:"href"` + Rel string `json:"rel,omitempty"` + Title string `json:"title,omitempty"` + Type string `json:"type,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +type ErrorLinks struct { + About any `json:"about,omitempty"` + Type any `json:"type"` // either a string containing an URL, or a Link object +} + +type ErrorSource struct { + Pointer string `json:"pointer,omitempty"` // a JSON Pointer [RFC6901] to the value in the request document that caused the error + Parameter string `json:"parameter,omitempty"` // a string indicating which URI query parameter caused the error + Header string `json:"header,omitempty"` // a string indicating the name of a single request header which caused the error +} + +type ApiError struct { + Id string `json:"id"` // a unique identifier for this particular occurrence of the problem + Links *ErrorLinks `json:"links,omitempty"` + NumStatus int `json:"-"` + Status string `json:"status"` // the HTTP status code applicable to this problem, expressed as a string value + Code string `json:"code"` // an application-specific error code, expressed as a string value + Title string `json:"title,omitempty"` // a short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence of the problem + Detail string `json:"detail,omitempty"` // a human-readable explanation specific to this occurrence of the problem + Source *ErrorSource `json:"source,omitempty"` // an object containing references to the primary source of the error + Meta map[string]any `json:"meta,omitempty"` // a meta object containing non-standard meta-information about the error +} + +type ErrorResponse struct { + Errors []ApiError `json:"errors"` +} + +var _ render.Renderer = ErrorResponse{} + +func (e ErrorResponse) Render(w http.ResponseWriter, r *http.Request) error { + w.Header().Add("Content-Type", ContentTypeJsonApi) + if len(e.Errors) > 0 { + render.Status(r, e.Errors[0].NumStatus) + } else { + render.Status(r, http.StatusInternalServerError) + } + return nil +} + +const ( + ContentTypeJsonApi = "application/vnd.api+json" +) + +type GroupwareError struct { + Status int + Code string + Title string + Detail string +} + +func groupwareErrorFromJmap(j jmap.Error) *GroupwareError { + if j == nil { + return nil + } + switch j.Code() { + case jmap.JmapErrorAuthenticationFailed: + return &ErrorForbidden + case jmap.JmapErrorInvalidHttpRequest: + return &ErrorInvalidRequest + case jmap.JmapErrorServerResponse: + return &ErrorServerResponse + case jmap.JmapErrorReadingResponseBody: + return &ErrorReadingResponse + case jmap.JmapErrorDecodingResponseBody: + return &ErrorProcessingResponse + case jmap.JmapErrorEncodingRequestBody: + return &ErrorEncodingRequestBody + case jmap.JmapErrorCreatingRequest: + return &ErrorCreatingRequest + case jmap.JmapErrorSendingRequest: + return &ErrorSendingRequest + case jmap.JmapErrorInvalidSessionResponse: + return &ErrorInvalidSessionResponse + case jmap.JmapErrorInvalidJmapRequestPayload: + return &ErrorInvalidRequestPayload + case jmap.JmapErrorInvalidJmapResponsePayload: + return &ErrorInvalidResponsePayload + default: + return &ErrorGeneric + } +} + +const ( + ErrorCodeGeneric = "ERRGEN" + ErrorCodeMissingAuthentication = "AUTMIS" + ErrorCodeForbiddenGeneric = "AUTFOR" + ErrorCodeInvalidRequest = "INVREQ" + ErrorCodeServerResponse = "SRVRSP" + ErrorCodeServerReadingResponse = "SRVRRE" + ErrorCodeServerDecodingResponseBody = "SRVDRB" + ErrorCodeEncodingRequestBody = "ENCREQ" + ErrorCodeCreatingRequest = "CREREQ" + ErrorCodeSendingRequest = "SNDREQ" + ErrorCodeInvalidSessionResponse = "INVSES" + ErrorCodeInvalidRequestPayload = "INVRQP" + ErrorCodeInvalidResponsePayload = "INVRSP" +) + +var ( + ErrorGeneric = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeGeneric, + Title: "Unspecific Error", + Detail: "Error without a specific description.", + } + ErrorMissingAuthentication = GroupwareError{ + Status: http.StatusUnauthorized, + Code: ErrorCodeMissingAuthentication, + Title: "Missing Authentication", + Detail: "No authentication credentials were provided.", + } + ErrorForbidden = GroupwareError{ + Status: http.StatusForbidden, + Code: ErrorCodeForbiddenGeneric, + Title: "Invalid Authentication", + Detail: "Authentication credentials were provided but are either invalid or not authorized to perform the request operation.", + } + ErrorInvalidRequest = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeInvalidRequest, + Title: "Invalid Request", + Detail: "The request that was meant to be sent to the mail server is invalid, which might be caused by configuration issues.", + } + ErrorServerResponse = GroupwareError{ + Status: http.StatusServiceUnavailable, + Code: ErrorCodeServerResponse, + Title: "Server responds with an Error", + Detail: "The mail server responded with an error.", + } + ErrorReadingResponse = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeServerResponse, + Title: "Server Response Body could not be decoded", + Detail: "The mail server response body could not be decoded.", + } + ErrorProcessingResponse = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeServerResponse, + Title: "Server Response Body could not be decoded", + Detail: "The mail server response body could not be decoded.", + } + ErrorEncodingRequestBody = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeEncodingRequestBody, + Title: "Failed to encode the Request Body", + Detail: "Failed to encode the body of the request to be sent to the mail server.", + } + ErrorCreatingRequest = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeCreatingRequest, + Title: "Failed to create the Request", + Detail: "Failed to create the request to be sent to the mail server.", + } + ErrorSendingRequest = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeSendingRequest, + Title: "Failed to send the Request", + Detail: "Failed to send the request to the mail server.", + } + ErrorInvalidSessionResponse = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeInvalidSessionResponse, + Title: "Invalid JMAP Session Response", + Detail: "The JMAP session response that was provided by the mail server is invalid.", + } + ErrorInvalidRequestPayload = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeInvalidRequestPayload, + Title: "Invalid Request Payload", + Detail: "The request to the mail server is invalid.", + } + ErrorInvalidResponsePayload = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeInvalidResponsePayload, + Title: "Invalid Response Payload", + Detail: "The payload of the response received from the mail server is invalid.", + } +) + +type ErrorOpt interface { + apply(error *ApiError) +} + +type ErrorLinksOpt struct { + links *ErrorLinks +} + +func (o ErrorLinksOpt) apply(error *ApiError) { + error.Links = o.links +} + +type SourceLinksOpt struct { + source *ErrorSource +} + +func (o SourceLinksOpt) apply(error *ApiError) { + error.Source = o.source +} + +type MetaLinksOpt struct { + meta map[string]any +} + +func (o MetaLinksOpt) apply(error *ApiError) { + error.Meta = o.meta +} + +type CodeOpt struct { + code string +} + +func (o CodeOpt) apply(error *ApiError) { + error.Code = o.code +} + +type TitleOpt struct { + title string + detail string +} + +func (o TitleOpt) apply(error *ApiError) { + error.Title = o.title + error.Detail = o.detail +} + +func errorResponse(id string, error GroupwareError, options ...ErrorOpt) ErrorResponse { + err := ApiError{ + Id: id, + NumStatus: error.Status, + Status: strconv.Itoa(error.Status), + Code: error.Code, + Title: error.Title, + Detail: error.Detail, + } + + for _, o := range options { + o.apply(&err) + } + + return ErrorResponse{ + Errors: []ApiError{err}, + } +} + +func apiError(id string, error GroupwareError, options ...ErrorOpt) ApiError { + err := ApiError{ + Id: id, + NumStatus: error.Status, + Status: strconv.Itoa(error.Status), + Code: error.Code, + Title: error.Title, + Detail: error.Detail, + } + + for _, o := range options { + o.apply(&err) + } + + return err +} + +func apiErrorFromJmap(error jmap.Error) *ApiError { + if error == nil { + return nil + } + gwe := groupwareErrorFromJmap(error) + if gwe == nil { + return nil + } + api := apiError(uuid.NewString(), *gwe) + return &api +} + +func errorResponses(errors ...ApiError) ErrorResponse { + return ErrorResponse{Errors: errors} +} diff --git a/services/groupware/pkg/groupware/groupware_lowlevel.go b/services/groupware/pkg/groupware/groupware_lowlevel.go index f5a4e6aad3..82d1d9c1f6 100644 --- a/services/groupware/pkg/groupware/groupware_lowlevel.go +++ b/services/groupware/pkg/groupware/groupware_lowlevel.go @@ -124,27 +124,11 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux) (*Gro return nil, GroupwareInitializationError{Message: "Mail.Master.Password is empty"} } - defaultEmailLimit := config.Mail.DefaultEmailLimit - if defaultEmailLimit < 0 { - defaultEmailLimit = 0 - } - maxBodyValueBytes := config.Mail.MaxBodyValueBytes - if maxBodyValueBytes < 0 { - maxBodyValueBytes = 0 - } - - responseHeaderTimeout := config.Mail.ResponseHeaderTimeout - if responseHeaderTimeout < 0 { - responseHeaderTimeout = 0 - } - sessionCacheTtl := config.Mail.SessionCacheTtl - if sessionCacheTtl < 0 { - sessionCacheTtl = 0 - } - sessionFailureCacheTtl := config.Mail.SessionFailureCacheTtl - if sessionFailureCacheTtl < 0 { - sessionFailureCacheTtl = 0 - } + defaultEmailLimit := max(config.Mail.DefaultEmailLimit, 0) + maxBodyValueBytes := max(config.Mail.MaxBodyValueBytes, 0) + responseHeaderTimeout := max(config.Mail.ResponseHeaderTimeout, 0) + sessionCacheTtl := max(config.Mail.SessionCacheTtl, 0) + sessionFailureCacheTtl := max(config.Mail.SessionFailureCacheTtl, 0) tr := http.DefaultTransport.(*http.Transport).Clone() tr.ResponseHeaderTimeout = responseHeaderTimeout @@ -223,7 +207,7 @@ func (g Groupware) session(req *http.Request, ctx context.Context, logger *log.L } func (g Groupware) respond(w http.ResponseWriter, r *http.Request, - handler func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, error)) { + handler func(r *http.Request, ctx context.Context, logger *log.Logger, session *jmap.Session) (any, string, *ApiError)) { ctx := r.Context() logger := g.logger.SubloggerWithRequestID(ctx) session, ok, err := g.session(r, ctx, &logger) @@ -240,10 +224,10 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, } logger = session.DecorateLogger(logger) - response, state, err := handler(r, ctx, &logger, &session) - if err != nil { - logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg(err.Error()) - w.WriteHeader(http.StatusInternalServerError) + response, state, apierr := handler(r, ctx, &logger, &session) + if apierr != nil { + logger.Warn().Interface("error", apierr).Msgf("API error: %v", apierr) + render.Render(w, r, errorResponses(*apierr)) return } @@ -251,7 +235,7 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, w.Header().Add("ETag", state) } if response == nil { - w.WriteHeader(http.StatusNotFound) + render.Status(r, http.StatusNotFound) } else { render.Status(r, http.StatusOK) render.JSON(w, r, response)