From 6cbce01a463196283d86a780e39e0377bd33ded6 Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Mon, 28 Jul 2025 16:57:17 +0200
Subject: [PATCH] groupware: implement JSON:API's error response format, with a
revamped error handling in jmap and services/groupware
---
pkg/jmap/jmap.go | 92 +++---
pkg/jmap/jmap_api.go | 6 +-
pkg/jmap/jmap_error.go | 45 +++
pkg/jmap/jmap_http.go | 61 ++--
pkg/jmap/jmap_model.go | 22 +-
pkg/jmap/jmap_test.go | 16 +-
pkg/jmap/jmap_tools.go | 12 +-
pkg/jmap/jmap_tools_test.go | 12 +-
services/groupware/pkg/groupware/groupware.go | 34 +-
.../pkg/groupware/groupware_api_error.go | 295 ++++++++++++++++++
.../pkg/groupware/groupware_lowlevel.go | 38 +--
11 files changed, 459 insertions(+), 174 deletions(-)
create mode 100644 pkg/jmap/jmap_error.go
create mode 100644 services/groupware/pkg/groupware/groupware_api_error.go
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)