From 4dcbb5d8e35404832591bdcd3513ca2d3d92abc2 Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Thu, 31 Jul 2025 20:34:01 +0200
Subject: [PATCH] groupware: further implementation and improvements
---
pkg/jmap/jmap.go | 174 ++++++++++++------
pkg/jmap/jmap_error.go | 1 +
pkg/jmap/jmap_http.go | 12 +-
pkg/jmap/jmap_model.go | 89 ++++++---
pkg/jmap/jmap_test.go | 6 +-
pkg/jmap/jmap_tools.go | 46 ++++-
.../pkg/groupware/groupware_api_account.go | 12 +-
.../pkg/groupware/groupware_api_identity.go | 4 +-
.../pkg/groupware/groupware_api_index.go | 1 +
.../pkg/groupware/groupware_api_mailbox.go | 49 +----
.../pkg/groupware/groupware_api_messages.go | 77 ++++++++
.../pkg/groupware/groupware_api_vacation.go | 4 +-
.../pkg/groupware/groupware_error.go | 112 ++++++++++-
.../pkg/groupware/groupware_framework.go | 174 +++++++++++++++---
.../pkg/groupware/groupware_route.go | 15 +-
.../pkg/groupware/groupware_session.go | 2 +-
.../pkg/groupware/groupware_tools.go | 30 ---
17 files changed, 605 insertions(+), 203 deletions(-)
create mode 100644 services/groupware/pkg/groupware/groupware_api_messages.go
delete mode 100644 services/groupware/pkg/groupware/groupware_tools.go
diff --git a/pkg/jmap/jmap.go b/pkg/jmap/jmap.go
index 363f00eb7..65580d0f8 100644
--- a/pkg/jmap/jmap.go
+++ b/pkg/jmap/jmap.go
@@ -10,9 +10,14 @@ import (
"github.com/rs/zerolog"
)
+type SessionEventListener interface {
+ OnSessionOutdated(session *Session)
+}
+
type Client struct {
- wellKnown SessionClient
- api ApiClient
+ wellKnown SessionClient
+ api ApiClient
+ sessionEventListeners *eventListeners[SessionEventListener]
io.Closer
}
@@ -22,8 +27,9 @@ func (j *Client) Close() error {
func NewClient(wellKnown SessionClient, api ApiClient) Client {
return Client{
- wellKnown: wellKnown,
- api: api,
+ wellKnown: wellKnown,
+ api: api,
+ sessionEventListeners: newEventListeners[SessionEventListener](),
}
}
@@ -52,19 +58,31 @@ type Session struct {
// The base URL to use for JMAP operations towards Stalwart
JmapUrl url.URL
// TODO
- MailAccountId string
+ DefaultMailAccountId string
SessionResponse
}
+func (s *Session) MailAccountId(accountId string) string {
+ if accountId != "" && accountId != defaultAccountId {
+ return accountId
+ }
+ // TODO(pbleser-oc) handle case where there is no default mail account
+ return s.DefaultMailAccountId
+}
+
const (
- logOperation = "operation"
- logUsername = "username"
- logAccountId = "account-id"
- logMailboxId = "mailbox-id"
- logFetchBodies = "fetch-bodies"
- logOffset = "offset"
- logLimit = "limit"
+ logOperation = "operation"
+ logUsername = "username"
+ logAccountId = "account-id"
+ logMailboxId = "mailbox-id"
+ logFetchBodies = "fetch-bodies"
+ logOffset = "offset"
+ logLimit = "limit"
+ logApiUrl = "apiurl"
+ logSessionState = "session-state"
+
+ defaultAccountId = "*"
emailSortByReceivedAt = "receivedAt"
emailSortBySize = "size"
@@ -79,13 +97,25 @@ const (
// Create a new log.Logger that is decorated with fields containing information about the Session.
func (s Session) DecorateLogger(l log.Logger) log.Logger {
- return log.Logger{
- Logger: l.With().Str(logUsername, s.Username).Str(logAccountId, s.MailAccountId).Logger(),
- }
+ return log.Logger{Logger: l.With().
+ Str(logUsername, s.Username).
+ Str(logApiUrl, s.ApiUrl).
+ Str(logSessionState, s.State).
+ Logger()}
+}
+
+func (j *Client) AddSessionEventListener(listener SessionEventListener) {
+ j.sessionEventListeners.add(listener)
+}
+
+func (j *Client) onSessionOutdated(session *Session) {
+ j.sessionEventListeners.signal(func(listener SessionEventListener) {
+ listener.OnSessionOutdated(session)
+ })
}
// Create a new Session from a WellKnownResponse.
-func NewSession(sessionResponse SessionResponse) (Session, Error) {
+func newSession(sessionResponse SessionResponse) (Session, Error) {
username := sessionResponse.Username
if username == "" {
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a username")}
@@ -103,10 +133,10 @@ func NewSession(sessionResponse SessionResponse) (Session, Error) {
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response provides an invalid API URL")}
}
return Session{
- Username: username,
- MailAccountId: mailAccountId,
- JmapUrl: *apiUrl,
- SessionResponse: sessionResponse,
+ Username: username,
+ DefaultMailAccountId: mailAccountId,
+ JmapUrl: *apiUrl,
+ SessionResponse: sessionResponse,
}, nil
}
@@ -116,26 +146,34 @@ func (j *Client) FetchSession(username string, logger *log.Logger) (Session, Err
if err != nil {
return Session{}, err
}
- return NewSession(wk)
+ return newSession(wk)
}
-func (j *Client) logger(operation string, session *Session, logger *log.Logger) *log.Logger {
- return &log.Logger{Logger: logger.With().Str(logOperation, operation).Str(logUsername, session.Username).Str(logAccountId, session.MailAccountId).Logger()}
+func (j *Client) logger(accountId string, operation string, session *Session, logger *log.Logger) *log.Logger {
+ zc := logger.With().Str(logOperation, operation).Str(logUsername, session.Username)
+ if accountId != "" {
+ zc = zc.Str(logAccountId, accountId)
+ }
+ return &log.Logger{Logger: zc.Logger()}
}
-func (j *Client) loggerParams(operation string, session *Session, logger *log.Logger, params func(zerolog.Context) zerolog.Context) *log.Logger {
- base := logger.With().Str(logOperation, operation).Str(logUsername, session.Username).Str(logAccountId, session.MailAccountId)
- return &log.Logger{Logger: params(base).Logger()}
+func (j *Client) loggerParams(accountId string, operation string, session *Session, logger *log.Logger, params func(zerolog.Context) zerolog.Context) *log.Logger {
+ zc := logger.With().Str(logOperation, operation).Str(logUsername, session.Username)
+ if accountId != "" {
+ zc = zc.Str(logAccountId, accountId)
+ }
+ return &log.Logger{Logger: params(zc).Logger()}
}
// https://jmap.io/spec-mail.html#identityget
-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.MailAccountId}, "0"))
+func (j *Client) GetIdentity(accountId string, session *Session, ctx context.Context, logger *log.Logger) (IdentityGetResponse, Error) {
+ aid := session.MailAccountId(accountId)
+ logger = j.logger(aid, "GetIdentity", session, logger)
+ cmd, err := request(invocation(IdentityGet, IdentityGetCommand{AccountId: aid}, "0"))
if err != nil {
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, j.onSessionOutdated, cmd, func(body *Response) (IdentityGetResponse, Error) {
var response IdentityGetResponse
err = retrieveResponseMatchParameters(body, IdentityGet, "0", &response)
return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
@@ -143,13 +181,14 @@ func (j *Client) GetIdentity(session *Session, ctx context.Context, logger *log.
}
// https://jmap.io/spec-mail.html#vacationresponseget
-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.MailAccountId}, "0"))
+func (j *Client) GetVacationResponse(accountId string, session *Session, ctx context.Context, logger *log.Logger) (VacationResponseGetResponse, Error) {
+ aid := session.MailAccountId(accountId)
+ logger = j.logger(aid, "GetVacationResponse", session, logger)
+ cmd, err := request(invocation(VacationResponseGet, VacationResponseGetCommand{AccountId: aid}, "0"))
if err != nil {
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, j.onSessionOutdated, cmd, func(body *Response) (VacationResponseGetResponse, Error) {
var response VacationResponseGetResponse
err = retrieveResponseMatchParameters(body, VacationResponseGet, "0", &response)
return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
@@ -157,31 +196,33 @@ func (j *Client) GetVacationResponse(session *Session, ctx context.Context, logg
}
// https://jmap.io/spec-mail.html#mailboxget
-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.MailAccountId, Ids: ids}, "0"))
+func (j *Client) GetMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxGetResponse, Error) {
+ aid := session.MailAccountId(accountId)
+ logger = j.logger(aid, "GetMailbox", session, logger)
+ cmd, err := request(invocation(MailboxGet, MailboxGetCommand{AccountId: aid, Ids: ids}, "0"))
if err != nil {
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, j.onSessionOutdated, cmd, func(body *Response) (MailboxGetResponse, Error) {
var response MailboxGetResponse
err = retrieveResponseMatchParameters(body, MailboxGet, "0", &response)
return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
})
}
-func (j *Client) GetAllMailboxes(session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, Error) {
- return j.GetMailbox(session, ctx, logger, nil)
+func (j *Client) GetAllMailboxes(accountId string, session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, Error) {
+ return j.GetMailbox(accountId, 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) {
- logger = j.logger("QueryMailbox", session, logger)
- cmd, err := request(invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: session.MailAccountId, Filter: filter}, "0"))
+func (j *Client) QueryMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (MailboxQueryResponse, Error) {
+ aid := session.MailAccountId(accountId)
+ logger = j.logger(aid, "QueryMailbox", session, logger)
+ cmd, err := request(invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: aid, Filter: filter}, "0"))
if err != nil {
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, j.onSessionOutdated, cmd, func(body *Response) (MailboxQueryResponse, Error) {
var response MailboxQueryResponse
err = retrieveResponseMatchParameters(body, MailboxQuery, "0", &response)
return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
@@ -193,13 +234,14 @@ type Mailboxes struct {
State string `json:"state,omitempty"`
}
-func (j *Client) SearchMailboxes(session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (Mailboxes, Error) {
- logger = j.logger("SearchMailboxes", session, logger)
+func (j *Client) SearchMailboxes(accountId string, session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (Mailboxes, Error) {
+ aid := session.MailAccountId(accountId)
+ logger = j.logger(aid, "SearchMailboxes", session, logger)
cmd, err := request(
- invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: session.MailAccountId, Filter: filter}, "0"),
+ invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: aid, Filter: filter}, "0"),
invocation(MailboxGet, MailboxGetRefCommand{
- AccountId: session.MailAccountId,
+ AccountId: aid,
IdRef: &Ref{Name: MailboxQuery, Path: "/ids/*", ResultOf: "0"},
}, "1"),
)
@@ -207,7 +249,7 @@ func (j *Client) SearchMailboxes(session *Session, ctx context.Context, logger *
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, j.onSessionOutdated, cmd, func(body *Response) (Mailboxes, Error) {
var response MailboxGetResponse
err = retrieveResponseMatchParameters(body, MailboxGet, "1", &response)
if err != nil {
@@ -222,13 +264,37 @@ 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) {
- logger = j.loggerParams("GetEmails", session, logger, func(z zerolog.Context) zerolog.Context {
+func (j *Client) GetEmails(accountId string, session *Session, ctx context.Context, logger *log.Logger, ids []string, fetchBodies bool, maxBodyValueBytes int) (Emails, Error) {
+ aid := session.MailAccountId(accountId)
+ logger = j.logger(aid, "GetEmails", session, logger)
+
+ get := EmailGetCommand{AccountId: aid, Ids: ids, FetchAllBodyValues: fetchBodies}
+ if maxBodyValueBytes >= 0 {
+ get.MaxBodyValueBytes = maxBodyValueBytes
+ }
+
+ cmd, err := request(invocation(EmailGet, get, "0"))
+ if err != nil {
+ return Emails{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Emails, Error) {
+ var response EmailGetResponse
+ err = retrieveResponseMatchParameters(body, EmailGet, "0", &response)
+ if err != nil {
+ return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ return Emails{Emails: response.List, State: body.SessionState}, nil
+ })
+}
+
+func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Context, logger *log.Logger, mailboxId string, offset int, limit int, fetchBodies bool, maxBodyValueBytes int) (Emails, Error) {
+ aid := session.MailAccountId(accountId)
+ logger = j.loggerParams(aid, "GetAllEmails", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies).Int(logOffset, offset).Int(logLimit, limit)
})
query := EmailQueryCommand{
- AccountId: session.MailAccountId,
+ AccountId: aid,
Filter: &MessageFilter{InMailbox: mailboxId},
Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}},
CollapseThreads: true,
@@ -242,7 +308,7 @@ func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Lo
}
get := EmailGetRefCommand{
- AccountId: session.MailAccountId,
+ AccountId: aid,
FetchAllBodyValues: fetchBodies,
IdRef: &Ref{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"},
}
@@ -258,7 +324,7 @@ func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Lo
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, j.onSessionOutdated, cmd, func(body *Response) (Emails, Error) {
var response EmailGetResponse
err = retrieveResponseMatchParameters(body, EmailGet, "1", &response)
if err != nil {
diff --git a/pkg/jmap/jmap_error.go b/pkg/jmap/jmap_error.go
index 2a39d9481..df72d7fed 100644
--- a/pkg/jmap/jmap_error.go
+++ b/pkg/jmap/jmap_error.go
@@ -12,6 +12,7 @@ const (
JmapErrorInvalidSessionResponse
JmapErrorInvalidJmapRequestPayload
JmapErrorInvalidJmapResponsePayload
+ JmapErrorMethodLevel
)
type Error interface {
diff --git a/pkg/jmap/jmap_http.go b/pkg/jmap/jmap_http.go
index 34c24a02c..ba2fb23ee 100644
--- a/pkg/jmap/jmap_http.go
+++ b/pkg/jmap/jmap_http.go
@@ -7,7 +7,6 @@ import (
"fmt"
"io"
"net/http"
- "net/http/httputil"
"net/url"
"github.com/opencloud-eu/opencloud/pkg/log"
@@ -59,7 +58,7 @@ func (e AuthenticationError) Unwrap() error {
return e.Err
}
-func (h *HttpJmapApiClient) auth(username string, logger *log.Logger, req *http.Request) error {
+func (h *HttpJmapApiClient) auth(username string, _ *log.Logger, req *http.Request) error {
masterUsername := username + "%" + h.masterUser
req.SetBasicAuth(masterUsername, h.masterPassword)
return nil
@@ -128,15 +127,6 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses
req.Header.Add("User-Agent", h.userAgent)
h.auth(session.Username, logger, req)
- {
- if logger.Trace().Enabled() {
- safereq := req.Clone(ctx)
- safereq.Header.Set("Authorization", "***")
- bytes, _ := httputil.DumpRequest(safereq, false)
- logger.Info().Msgf("sending command: %s", string(bytes))
- }
- }
-
res, err := h.client.Do(req)
if err != nil {
logger.Error().Err(err).Msgf("failed to perform POST %v", jmapUrl)
diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go
index 808b52cbb..2dcd7ff1c 100644
--- a/pkg/jmap/jmap_model.go
+++ b/pkg/jmap/jmap_model.go
@@ -75,21 +75,35 @@ type SessionAccountCapabilities struct {
}
type SessionAccount struct {
- Name string `json:"name,omitempty"`
- IsPersonal bool `json:"isPersonal"`
+ // A user-friendly string to show when presenting content from this account, e.g., the email address representing the owner of the account.
+ Name string `json:"name,omitempty"`
+ // This is true if the account belongs to the authenticated user rather than a group account or a personal account of another user that has been shared with them.
+ IsPersonal bool `json:"isPersonal"`
+ // This is true if the entire account is read-only.
IsReadOnly bool `json:"isReadOnly"`
AccountCapabilities SessionAccountCapabilities `json:"accountCapabilities,omitempty"`
}
type SessionCoreCapabilities struct {
- MaxSizeUpload int `json:"maxSizeUpload"`
- MaxConcurrentUpload int `json:"maxConcurrentUpload"`
- MaxSizeRequest int `json:"maxSizeRequest"`
- MaxConcurrentRequests int `json:"maxConcurrentRequests"`
- MaxCallsInRequest int `json:"maxCallsInRequest"`
- MaxObjectsInGet int `json:"maxObjectsInGet"`
- MaxObjectsInSet int `json:"maxObjectsInSet"`
- CollationAlgorithms []string `json:"collationAlgorithms"`
+ // The maximum file size, in octets, that the server will accept for a single file upload (for any purpose)
+ MaxSizeUpload int `json:"maxSizeUpload"`
+ // The maximum number of concurrent requests the server will accept to the upload endpoint.
+ MaxConcurrentUpload int `json:"maxConcurrentUpload"`
+ // The maximum size, in octets, that the server will accept for a single request to the API endpoint.
+ MaxSizeRequest int `json:"maxSizeRequest"`
+ // The maximum number of concurrent requests the server will accept to the API endpoint.
+ MaxConcurrentRequests int `json:"maxConcurrentRequests"`
+ // The maximum number of method calls the server will accept in a single request to the API endpoint.
+ MaxCallsInRequest int `json:"maxCallsInRequest"`
+ // The maximum number of objects that the client may request in a single /get type method call.
+ MaxObjectsInGet int `json:"maxObjectsInGet"`
+ // The maximum number of objects the client may send to create, update, or destroy in a single /set type method call.
+ // This is the combined total, e.g., if the maximum is 10, you could not create 7 objects and destroy 6, as this would be 13 actions,
+ // which exceeds the limit.
+ MaxObjectsInSet int `json:"maxObjectsInSet"`
+ // A list of identifiers for algorithms registered in the collation registry, as defined in [@!RFC4790], that the server
+ // supports for sorting when querying records.
+ CollationAlgorithms []string `json:"collationAlgorithms"`
}
type SessionMailCapabilities struct {
@@ -138,15 +152,33 @@ type SessionPrimaryAccounts struct {
}
type SessionResponse struct {
- Capabilities SessionCapabilities `json:"capabilities,omitempty"`
- Accounts map[string]SessionAccount `json:"accounts,omitempty"`
- PrimaryAccounts SessionPrimaryAccounts `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"`
+ Capabilities SessionCapabilities `json:"capabilities"`
+ Accounts map[string]SessionAccount `json:"accounts,omitempty"`
+ // A map of capability URIs (as found in accountCapabilities) to the account id that is considered to be the user’s main or default
+ // account for data pertaining to that capability.
+ // If no account being returned belongs to the user, or in any other way there is no appropriate way to determine a default account,
+ // there MAY be no entry for a particular URI, even though that capability is supported by the server (and in the capabilities object).
+ // urn:ietf:params:jmap:core SHOULD NOT be present.
+ PrimaryAccounts SessionPrimaryAccounts `json:"primaryAccounts"`
+ // The username associated with the given credentials, or the empty string if none.
+ Username string `json:"username,omitempty"`
+ // The URL to use for JMAP API requests.
+ ApiUrl string `json:"apiUrl,omitempty"`
+ // The URL endpoint to use when downloading files, in URI Template (level 1) format [@!RFC6570].
+ // The URL MUST contain variables called accountId, blobId, type, and name.
+ DownloadUrl string `json:"downloadUrl,omitempty"`
+ // The URL endpoint to use when uploading files, in URI Template (level 1) format [@!RFC6570].
+ // The URL MUST contain a variable called accountId.
+ UploadUrl string `json:"uploadUrl,omitempty"`
+ // The URL to connect to for push events, as described in Section 7.3, in URI Template (level 1) format [@!RFC6570].
+ // The URL MUST contain variables called types, closeafter, and ping.
+ EventSourceUrl string `json:"eventSourceUrl,omitempty"`
+ // A (preferably short) string representing the state of this object on the server.
+ // If the value of any other property on the Session object changes, this string will change.
+ // The current value is also returned on the API Response object (see Section 3.4), allowing clients to quickly
+ // determine if the session information has changed (e.g., an account has been added or removed),
+ // so they need to refetch the object.
+ State string `json:"state,omitempty"`
}
type Mailbox struct {
@@ -154,12 +186,12 @@ type Mailbox struct {
Name string `json:"name,omitempty"`
ParentId string `json:"parentId,omitempty"`
Role string `json:"role,omitempty"`
- SortOrder int `json:"sortOrder,omitempty"`
- IsSubscribed bool `json:"isSubscribed,omitempty"`
- TotalEmails int `json:"totalEmails,omitempty"`
- UnreadEmails int `json:"unreadEmails,omitempty"`
- TotalThreads int `json:"totalThreads,omitempty"`
- UnreadThreads int `json:"unreadThreads,omitempty"`
+ SortOrder int `json:"sortOrder"`
+ IsSubscribed bool `json:"isSubscribed"`
+ TotalEmails int `json:"totalEmails"`
+ UnreadEmails int `json:"unreadEmails"`
+ TotalThreads int `json:"totalThreads"`
+ UnreadThreads int `json:"unreadThreads"`
MyRights map[string]bool `json:"myRights,omitempty"`
}
@@ -234,6 +266,13 @@ type EmailQueryCommand struct {
CalculateTotal bool `json:"calculateTotal,omitempty"`
}
+type EmailGetCommand struct {
+ AccountId string `json:"accountId"`
+ FetchAllBodyValues bool `json:"fetchAllBodyValues,omitempty"`
+ MaxBodyValueBytes int `json:"maxBodyValueBytes,omitempty"`
+ Ids []string `json:"ids,omitempty"`
+}
+
type Ref struct {
Name Command `json:"name"`
Path string `json:"path,omitempty"`
diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go
index d4f3a7970..ed531f404 100644
--- a/pkg/jmap/jmap_test.go
+++ b/pkg/jmap/jmap_test.go
@@ -109,13 +109,13 @@ func TestRequests(t *testing.T) {
jmapUrl, err := url.Parse("http://localhost/jmap")
require.NoError(err)
- session := Session{MailAccountId: "123", Username: "user123", JmapUrl: *jmapUrl}
+ session := Session{DefaultMailAccountId: "123", Username: "user123", JmapUrl: *jmapUrl}
- folders, err := client.GetAllMailboxes(&session, ctx, &logger)
+ folders, err := client.GetAllMailboxes("a", &session, ctx, &logger)
require.NoError(err)
require.Len(folders.List, 5)
- emails, err := client.GetEmails(&session, ctx, &logger, "Inbox", 0, 0, true, 0)
+ emails, err := client.GetAllEmails("a", &session, ctx, &logger, "Inbox", 0, 0, true, 0)
require.NoError(err)
require.Len(emails.Emails, 3)
diff --git a/pkg/jmap/jmap_tools.go b/pkg/jmap/jmap_tools.go
index 8556c2d8e..a975b7f09 100644
--- a/pkg/jmap/jmap_tools.go
+++ b/pkg/jmap/jmap_tools.go
@@ -5,16 +5,43 @@ import (
"encoding/json"
"fmt"
"reflect"
+ "sync"
"time"
"github.com/mitchellh/mapstructure"
"github.com/opencloud-eu/opencloud/pkg/log"
)
+type eventListeners[T any] struct {
+ listeners []T
+ m sync.Mutex
+}
+
+func (e *eventListeners[T]) add(listener T) {
+ e.m.Lock()
+ defer e.m.Unlock()
+ e.listeners = append(e.listeners, listener)
+}
+
+func (e *eventListeners[T]) signal(signal func(T)) {
+ e.m.Lock()
+ defer e.m.Unlock()
+ for _, listener := range e.listeners {
+ signal(listener)
+ }
+}
+
+func newEventListeners[T any]() *eventListeners[T] {
+ return &eventListeners[T]{
+ listeners: []T{},
+ }
+}
+
func command[T any](api ApiClient,
logger *log.Logger,
ctx context.Context,
session *Session,
+ sessionOutdatedHandler func(session *Session),
request Request,
mapper func(body *Response) (T, Error)) (T, Error) {
@@ -33,7 +60,24 @@ func command[T any](api ApiClient,
}
if data.SessionState != session.State {
- // TODO(pbleser-oc) handle session renewal
+ if sessionOutdatedHandler != nil {
+ sessionOutdatedHandler(session)
+ }
+ }
+
+ // search for an "error" response
+ // https://jmap.io/spec-core.html#method-level-errors
+ for _, mr := range data.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)
+ }
+ }
+ var zero T
+ return zero, SimpleError{code: JmapErrorMethodLevel, err: err}
+ }
}
return mapper(&data)
diff --git a/services/groupware/pkg/groupware/groupware_api_account.go b/services/groupware/pkg/groupware/groupware_api_account.go
index f1b62dfd6..82673e0b1 100644
--- a/services/groupware/pkg/groupware/groupware_api_account.go
+++ b/services/groupware/pkg/groupware/groupware_api_account.go
@@ -6,10 +6,16 @@ import (
func (g Groupware) GetAccount(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) (any, string, *Error) {
- account, ok := req.GetAccount()
- if !ok {
- return nil, "", nil
+ account, err := req.GetAccount()
+ if err != nil {
+ return nil, "", err
}
return account, req.session.State, nil
})
}
+
+func (g Groupware) GetAccounts(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(req Request) (any, string, *Error) {
+ return req.session.Accounts, req.session.State, nil
+ })
+}
diff --git a/services/groupware/pkg/groupware/groupware_api_identity.go b/services/groupware/pkg/groupware/groupware_api_identity.go
index a472813bb..2645e2acb 100644
--- a/services/groupware/pkg/groupware/groupware_api_identity.go
+++ b/services/groupware/pkg/groupware/groupware_api_identity.go
@@ -6,7 +6,7 @@ import (
func (g Groupware) GetIdentity(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) (any, string, *Error) {
- res, err := g.jmap.GetIdentity(req.session, req.ctx, req.logger)
- return res, res.State, apiErrorFromJmap(err)
+ res, err := g.jmap.GetIdentity(req.GetAccountId(), req.session, req.ctx, req.logger)
+ return res, res.State, req.apiErrorFromJmap(err)
})
}
diff --git a/services/groupware/pkg/groupware/groupware_api_index.go b/services/groupware/pkg/groupware/groupware_api_index.go
index 3bc8edba0..c37c1758e 100644
--- a/services/groupware/pkg/groupware/groupware_api_index.go
+++ b/services/groupware/pkg/groupware/groupware_api_index.go
@@ -5,6 +5,7 @@ import (
)
type IndexLimits struct {
+ // The maximum file size, in octets, that the server will accept for a single file upload (for any purpose).
MaxSizeUpload int `json:"maxSizeUpload"`
MaxConcurrentUpload int `json:"maxConcurrentUpload"`
MaxSizeRequest int `json:"maxSizeRequest"`
diff --git a/services/groupware/pkg/groupware/groupware_api_mailbox.go b/services/groupware/pkg/groupware/groupware_api_mailbox.go
index b2aa4d3e3..0ec91b522 100644
--- a/services/groupware/pkg/groupware/groupware_api_mailbox.go
+++ b/services/groupware/pkg/groupware/groupware_api_mailbox.go
@@ -7,7 +7,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/opencloud-eu/opencloud/pkg/jmap"
- "github.com/opencloud-eu/opencloud/pkg/log"
)
// When the request succeeds.
@@ -32,23 +31,23 @@ type SwaggerGetMailboxById200 struct {
// 400: ErrorResponse400
// 404: ErrorResponse404
// 500: ErrorResponse500
-func (g Groupware) GetMailboxById(w http.ResponseWriter, r *http.Request) {
- mailboxId := chi.URLParam(r, "mailbox")
+func (g Groupware) GetMailbox(w http.ResponseWriter, r *http.Request) {
+ mailboxId := chi.URLParam(r, UriParamMailboxId)
if mailboxId == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
g.respond(w, r, func(req Request) (any, string, *Error) {
- res, err := g.jmap.GetMailbox(req.session, req.ctx, req.logger, []string{mailboxId})
+ res, err := g.jmap.GetMailbox(req.GetAccountId(), req.session, req.ctx, req.logger, []string{mailboxId})
if err != nil {
- return res, "", apiErrorFromJmap(err)
+ return res, "", req.apiErrorFromJmap(err)
}
if len(res.List) == 1 {
- return res.List[0], res.State, apiErrorFromJmap(err)
+ return res.List[0], res.State, req.apiErrorFromJmap(err)
} else {
- return nil, res.State, apiErrorFromJmap(err)
+ return nil, res.State, req.apiErrorFromJmap(err)
}
})
}
@@ -116,45 +115,17 @@ func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) (any, string, *Error) {
if hasCriteria {
- mailboxes, err := g.jmap.SearchMailboxes(req.session, req.ctx, req.logger, filter)
+ mailboxes, err := g.jmap.SearchMailboxes(req.GetAccountId(), req.session, req.ctx, req.logger, filter)
if err != nil {
- return nil, "", apiErrorFromJmap(err)
+ return nil, "", req.apiErrorFromJmap(err)
}
return mailboxes.Mailboxes, mailboxes.State, nil
} else {
- mailboxes, err := g.jmap.GetAllMailboxes(req.session, req.ctx, req.logger)
+ mailboxes, err := g.jmap.GetAllMailboxes(req.GetAccountId(), req.session, req.ctx, req.logger)
if err != nil {
- return nil, "", apiErrorFromJmap(err)
+ return nil, "", req.apiErrorFromJmap(err)
}
return mailboxes.List, mailboxes.State, nil
}
})
}
-
-func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
- mailboxId := chi.URLParam(r, "mailbox")
- g.respond(w, r, func(req Request) (any, string, *Error) {
- page, ok, _ := ParseNumericParam(r, "page", -1)
- logger := req.logger
- if ok {
- logger = &log.Logger{Logger: logger.With().Int("page", page).Logger()}
- }
- size, ok, _ := ParseNumericParam(r, "size", -1)
- if ok {
- logger = &log.Logger{Logger: logger.With().Int("size", size).Logger()}
- }
-
- offset := page * size
- limit := size
- if limit < 0 {
- limit = g.defaultEmailLimit
- }
-
- emails, err := g.jmap.GetEmails(req.session, req.ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes)
- if err != nil {
- return nil, "", apiErrorFromJmap(err)
- }
-
- return emails, emails.State, nil
- })
-}
diff --git a/services/groupware/pkg/groupware/groupware_api_messages.go b/services/groupware/pkg/groupware/groupware_api_messages.go
new file mode 100644
index 000000000..d9451fcb3
--- /dev/null
+++ b/services/groupware/pkg/groupware/groupware_api_messages.go
@@ -0,0 +1,77 @@
+package groupware
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+)
+
+func (g Groupware) GetAllMessages(w http.ResponseWriter, r *http.Request) {
+ mailboxId := chi.URLParam(r, UriParamMailboxId)
+ g.respond(w, r, func(req Request) (any, string, *Error) {
+ if mailboxId == "" {
+ errorId := req.errorId()
+ msg := fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId)
+ return nil, "", apiError(errorId, ErrorInvalidRequestParameter,
+ withDetail(msg),
+ withSource(&ErrorSource{Parameter: UriParamMailboxId}),
+ )
+ }
+ page, ok, err := req.parseNumericParam(QueryParamPage, -1)
+ if err != nil {
+ return nil, "", err
+ }
+ logger := req.logger
+ if ok {
+ logger = &log.Logger{Logger: logger.With().Int(QueryParamPage, page).Logger()}
+ }
+
+ size, ok, err := req.parseNumericParam(QueryParamSize, -1)
+ if err != nil {
+ return nil, "", err
+ }
+ if ok {
+ logger = &log.Logger{Logger: logger.With().Int(QueryParamSize, size).Logger()}
+ }
+
+ offset := page * size
+ limit := size
+ if limit < 0 {
+ limit = g.defaultEmailLimit
+ }
+
+ emails, jerr := g.jmap.GetAllEmails(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes)
+ if jerr != nil {
+ return nil, "", req.apiErrorFromJmap(jerr)
+ }
+
+ return emails, emails.State, nil
+ })
+}
+
+func (g Groupware) GetMessagesById(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, UriParamMessagesId)
+ g.respond(w, r, func(req Request) (any, string, *Error) {
+ ids := strings.Split(id, ",")
+ if len(ids) < 1 {
+ errorId := req.errorId()
+ msg := fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamMessagesId, logstr(id), "empty list of mail ids")
+ return nil, "", apiError(errorId, ErrorInvalidRequestParameter,
+ withDetail(msg),
+ withSource(&ErrorSource{Parameter: UriParamMessagesId}),
+ )
+ }
+
+ logger := &log.Logger{Logger: req.logger.With().Str("id", logstr(id)).Logger()}
+ emails, jerr := g.jmap.GetEmails(req.GetAccountId(), req.session, req.ctx, logger, ids, true, g.maxBodyValueBytes)
+ if jerr != nil {
+ return nil, "", req.apiErrorFromJmap(jerr)
+ }
+
+ return emails, emails.State, nil
+ })
+}
diff --git a/services/groupware/pkg/groupware/groupware_api_vacation.go b/services/groupware/pkg/groupware/groupware_api_vacation.go
index a55d9864e..a1cbb72b3 100644
--- a/services/groupware/pkg/groupware/groupware_api_vacation.go
+++ b/services/groupware/pkg/groupware/groupware_api_vacation.go
@@ -30,7 +30,7 @@ type SwaggerVacationResponse200 struct {
// 500: ErrorResponse500
func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) (any, string, *Error) {
- res, err := g.jmap.GetVacationResponse(req.session, req.ctx, req.logger)
- return res, res.State, apiErrorFromJmap(err)
+ res, err := g.jmap.GetVacationResponse(req.GetAccountId(), req.session, req.ctx, req.logger)
+ return res, res.State, req.apiErrorFromJmap(err)
})
}
diff --git a/services/groupware/pkg/groupware/groupware_error.go b/services/groupware/pkg/groupware/groupware_error.go
index 0b096998e..f4fd01225 100644
--- a/services/groupware/pkg/groupware/groupware_error.go
+++ b/services/groupware/pkg/groupware/groupware_error.go
@@ -1,9 +1,11 @@
package groupware
import (
+ "context"
"net/http"
"strconv"
+ chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/opencloud-eu/opencloud/pkg/jmap"
@@ -11,6 +13,8 @@ import (
type Link struct {
// A string whose value is a URI-reference [RFC3986 Section 4.1] pointing to the link’s target.
+ //
+ // [RFC3986 Section 4.1]: https://datatracker.ietf.org/doc/html/rfc3986#section-4.1
Href string `json:"href"`
// A string indicating the link’s relation type. The string MUST be a valid link relation type.
// required: false
@@ -41,6 +45,8 @@ type ErrorSource struct {
// A JSON Pointer [RFC6901] to the value in the request document that caused the error
// (e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute).
// This MUST point to a value in the request document that exists; if it doesn’t, the client SHOULD simply ignore the pointer.
+ //
+ // [RFC6901]: https://datatracker.ietf.org/doc/html/rfc6901
Pointer string `json:"pointer,omitempty"`
// A string indicating which URI query parameter caused the error.
Parameter string `json:"parameter,omitempty"`
@@ -48,7 +54,9 @@ type ErrorSource struct {
Header string `json:"header,omitempty"`
}
-// [Error](https://jsonapi.org/format/#error-objects)
+// [Error] describes an error.
+//
+// [Error]: https://jsonapi.org/format/#error-objects
type Error struct {
// A unique identifier for this particular occurrence of the problem
Id string `json:"id"`
@@ -90,6 +98,9 @@ func (e ErrorResponse) Render(w http.ResponseWriter, r *http.Request) error {
}
const (
+ // The [JSON:API] Content Type for errors
+ //
+ // [JSON:API]: https://jsonapi.org/
ContentTypeJsonApi = "application/vnd.api+json"
)
@@ -134,6 +145,7 @@ func groupwareErrorFromJmap(j jmap.Error) *GroupwareError {
const (
ErrorCodeGeneric = "ERRGEN"
+ ErrorCodeInvalidAuthentication = "AUTINV"
ErrorCodeMissingAuthentication = "AUTMIS"
ErrorCodeForbiddenGeneric = "AUTFOR"
ErrorCodeInvalidRequest = "INVREQ"
@@ -146,6 +158,8 @@ const (
ErrorCodeInvalidSessionResponse = "INVSES"
ErrorCodeInvalidRequestPayload = "INVRQP"
ErrorCodeInvalidResponsePayload = "INVRSP"
+ ErrorCodeInvalidRequestParameter = "INVPAR"
+ ErrorCodeNonExistingAccount = "INVACC"
)
var (
@@ -155,6 +169,12 @@ var (
Title: "Unspecific Error",
Detail: "Error without a specific description.",
}
+ ErrorInvalidAuthentication = GroupwareError{
+ Status: http.StatusUnauthorized,
+ Code: ErrorCodeMissingAuthentication,
+ Title: "Invalid Authentication",
+ Detail: "Failed to determine the authentication credentials.",
+ }
ErrorMissingAuthentication = GroupwareError{
Status: http.StatusUnauthorized,
Code: ErrorCodeMissingAuthentication,
@@ -227,6 +247,18 @@ var (
Title: "Invalid Response Payload",
Detail: "The payload of the response received from the mail server is invalid.",
}
+ ErrorInvalidRequestParameter = GroupwareError{
+ Status: http.StatusBadRequest,
+ Code: ErrorCodeInvalidRequestParameter,
+ Title: "Invalid Request Parameter",
+ Detail: "At least one of the parameters in the request is invalid.",
+ }
+ ErrorNonExistingAccount = GroupwareError{
+ Status: http.StatusBadRequest,
+ Code: ErrorCodeNonExistingAccount,
+ Title: "Invalid Account Parameter",
+ Detail: "The account the request is for does not exist.",
+ }
)
type ErrorOpt interface {
@@ -241,6 +273,13 @@ func (o ErrorLinksOpt) apply(error *Error) {
error.Links = o.links
}
+var _ = withLinks // unused for now, but will be
+func withLinks(links *ErrorLinks) ErrorLinksOpt {
+ return ErrorLinksOpt{
+ links: links,
+ }
+}
+
type SourceLinksOpt struct {
source *ErrorSource
}
@@ -249,6 +288,12 @@ func (o SourceLinksOpt) apply(error *Error) {
error.Source = o.source
}
+func withSource(source *ErrorSource) SourceLinksOpt {
+ return SourceLinksOpt{
+ source: source,
+ }
+}
+
type MetaLinksOpt struct {
meta map[string]any
}
@@ -257,6 +302,13 @@ func (o MetaLinksOpt) apply(error *Error) {
error.Meta = o.meta
}
+var _ = withMeta // unused for now, but will be
+func withMeta(meta map[string]any) MetaLinksOpt {
+ return MetaLinksOpt{
+ meta: meta,
+ }
+}
+
type CodeOpt struct {
code string
}
@@ -265,6 +317,13 @@ func (o CodeOpt) apply(error *Error) {
error.Code = o.code
}
+var _ = withCode // unused for now, but will be
+func withCode(code string) CodeOpt {
+ return CodeOpt{
+ code: code,
+ }
+}
+
type TitleOpt struct {
title string
detail string
@@ -275,6 +334,29 @@ func (o TitleOpt) apply(error *Error) {
error.Detail = o.detail
}
+var _ = withTitle // unused for now, but will be
+func withTitle(title string, detail string) TitleOpt {
+ return TitleOpt{
+ title: title,
+ detail: detail,
+ }
+}
+
+type DetailOpt struct {
+ detail string
+}
+
+func (o DetailOpt) apply(error *Error) {
+ error.Detail = o.detail
+}
+
+func withDetail(detail string) DetailOpt {
+ return DetailOpt{
+ detail: detail,
+ }
+}
+
+/*
func errorResponse(id string, error GroupwareError, options ...ErrorOpt) ErrorResponse {
err := Error{
Id: id,
@@ -293,9 +375,24 @@ func errorResponse(id string, error GroupwareError, options ...ErrorOpt) ErrorRe
Errors: []Error{err},
}
}
+*/
-func apiError(id string, error GroupwareError, options ...ErrorOpt) Error {
- err := Error{
+func errorId(r *http.Request, ctx context.Context) string {
+ requestId := chimiddleware.GetReqID(ctx)
+ localId := uuid.NewString()
+ if requestId != "" {
+ return requestId + "." + localId
+ } else {
+ return localId
+ }
+}
+
+func (r Request) errorId() string {
+ return errorId(r.r, r.ctx)
+}
+
+func apiError(id string, error GroupwareError, options ...ErrorOpt) *Error {
+ err := &Error{
Id: id,
NumStatus: error.Status,
Status: strconv.Itoa(error.Status),
@@ -305,13 +402,13 @@ func apiError(id string, error GroupwareError, options ...ErrorOpt) Error {
}
for _, o := range options {
- o.apply(&err)
+ o.apply(err)
}
return err
}
-func apiErrorFromJmap(error jmap.Error) *Error {
+func (r Request) apiErrorFromJmap(error jmap.Error) *Error {
if error == nil {
return nil
}
@@ -319,8 +416,9 @@ func apiErrorFromJmap(error jmap.Error) *Error {
if gwe == nil {
return nil
}
- api := apiError(uuid.NewString(), *gwe)
- return &api
+
+ errorId := r.errorId()
+ return apiError(errorId, *gwe)
}
func errorResponses(errors ...Error) ErrorResponse {
diff --git a/services/groupware/pkg/groupware/groupware_framework.go b/services/groupware/pkg/groupware/groupware_framework.go
index a10d3c16d..a81153b46 100644
--- a/services/groupware/pkg/groupware/groupware_framework.go
+++ b/services/groupware/pkg/groupware/groupware_framework.go
@@ -6,9 +6,11 @@ import (
"fmt"
"net/http"
"net/url"
+ "strconv"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
+ "github.com/rs/zerolog"
"github.com/jellydator/ttlcache/v3"
@@ -18,8 +20,21 @@ import (
)
const (
- logFolderId = "folder-id"
- logQuery = "query"
+ logUsername = "username" // this should match jmap.logUsername to avoid having the field twice in the logs under different keys
+ logErrorId = "error-id"
+ logErrorCode = "code"
+ logErrorStatus = "status"
+ logErrorSourceHeader = "source-header"
+ logErrorSourceParameter = "source-parameter"
+ logErrorSourcePointer = "source-pointer"
+ logInvalidQueryParameter = "error-query-param"
+ logInvalidPathParameter = "error-path-param"
+ logFolderId = "folder-id"
+ logQuery = "query"
+)
+
+const (
+ logMaxStrLength = 512
)
type Groupware struct {
@@ -28,7 +43,7 @@ type Groupware struct {
defaultEmailLimit int
maxBodyValueBytes int
sessionCache *ttlcache.Cache[string, cachedSession]
- jmap jmap.Client
+ jmap *jmap.Client
usernameProvider UsernameProvider
}
@@ -48,6 +63,18 @@ func (e GroupwareInitializationError) Unwrap() error {
return e.Err
}
+type GroupwareSessionEventListener struct {
+ sessionCache *ttlcache.Cache[string, cachedSession]
+}
+
+func (l GroupwareSessionEventListener) OnSessionOutdated(session *jmap.Session) {
+ // it's enough to remove the session from the cache, as it will be fetched on-demand
+ // the next time an operation is performed on behalf of the user
+ l.sessionCache.Delete(session.Username)
+}
+
+var _ jmap.SessionEventListener = GroupwareSessionEventListener{}
+
func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux) (*Groupware, error) {
baseUrl, err := url.Parse(config.Mail.BaseUrl)
if err != nil {
@@ -95,7 +122,7 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux) (*Gro
{
sessionLoader := &sessionCacheLoader{
logger: logger,
- jmapClient: jmapClient,
+ jmapClient: &jmapClient,
errorTtl: sessionFailureCacheTtl,
}
@@ -108,12 +135,15 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux) (*Gro
go sessionCache.Start()
}
+ sessionEventListener := GroupwareSessionEventListener{sessionCache: sessionCache}
+ jmapClient.AddSessionEventListener(&sessionEventListener)
+
return &Groupware{
mux: mux,
logger: logger,
sessionCache: sessionCache,
usernameProvider: usernameProvider,
- jmap: jmapClient,
+ jmap: &jmapClient,
defaultEmailLimit: defaultEmailLimit,
maxBodyValueBytes: maxBodyValueBytes,
}, nil
@@ -123,17 +153,8 @@ func (g Groupware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
g.mux.ServeHTTP(w, r)
}
-func (g Groupware) session(req *http.Request, ctx context.Context, logger *log.Logger) (jmap.Session, bool, error) {
- username, ok, err := g.usernameProvider.GetUsername(req, ctx, logger)
- if err != nil {
- logger.Error().Err(err).Msg("failed to retrieve username")
- return jmap.Session{}, false, err
- }
- if !ok {
- logger.Debug().Msg("unauthenticated API access attempt")
- return jmap.Session{}, false, nil
- }
-
+// Provide a JMAP Session for the
+func (g Groupware) session(username string, req *http.Request, ctx context.Context, logger *log.Logger) (jmap.Session, bool, error) {
item := g.sessionCache.Get(username)
if item != nil {
value := item.Value()
@@ -159,23 +180,121 @@ type Request struct {
}
func (r Request) GetAccountId() string {
- return chi.URLParam(r.r, "account")
+ accountId := chi.URLParam(r.r, UriParamAccount)
+ return r.session.MailAccountId(accountId)
}
-func (r Request) GetAccount() (jmap.SessionAccount, bool) {
+func (r Request) GetAccount() (jmap.SessionAccount, *Error) {
accountId := r.GetAccountId()
+
account, ok := r.session.Accounts[accountId]
if !ok {
+ errorId := r.errorId()
r.logger.Debug().Msgf("failed to find account '%v'", accountId)
- return jmap.SessionAccount{}, false
+ return jmap.SessionAccount{}, apiError(errorId, ErrorNonExistingAccount,
+ withDetail(fmt.Sprintf("The account '%v' does not exist", logstr(accountId))),
+ withSource(&ErrorSource{Parameter: UriParamAccount}),
+ )
}
- return account, true
+ return account, nil
+}
+
+func (r Request) parseNumericParam(param string, defaultValue int) (int, bool, *Error) {
+ q := r.r.URL.Query()
+ if !q.Has(param) {
+ return defaultValue, false, nil
+ }
+
+ str := q.Get(param)
+ if str == "" {
+ return defaultValue, false, nil
+ }
+
+ value, err := strconv.ParseInt(str, 10, 0)
+ if err != nil {
+ errorId := r.errorId()
+ msg := fmt.Sprintf("Invalid value for query parameter '%v': '%s': %s", param, logstr(str), err.Error())
+ return defaultValue, true, apiError(errorId, ErrorInvalidRequestParameter,
+ withDetail(msg),
+ withSource(&ErrorSource{Parameter: param}),
+ )
+ }
+ return int(value), true, nil
+}
+
+// Safely caps a string to a given size to avoid log bombing.
+// Use this function to wrap strings that are user input (HTTP headers, path parameters, URI parameters, HTTP body, ...).
+func logstr(text string) string {
+ runes := []rune(text)
+
+ if len(runes) <= logMaxStrLength {
+ return text
+ } else {
+ return string(runes[0:logMaxStrLength-1]) + `\u2026` // hellip
+ }
+}
+
+func (g Groupware) log(error *Error) {
+ var level *zerolog.Event
+ if error.NumStatus < 300 {
+ // shouldn't land here, but just in case: 1xx and 2xx are "OK" and should be logged as debug
+ level = g.logger.Debug()
+ } else if error.NumStatus == http.StatusUnauthorized || error.NumStatus == http.StatusForbidden {
+ // security related errors are logged as warnings
+ level = g.logger.Warn()
+ } else if error.NumStatus >= 500 {
+ // internal errors are potentially cause for concerned: bugs or third party systems malfunctioning, log as errors
+ level = g.logger.Error()
+ } else {
+ // everything else should be 4xx which indicates mistakes from the client, log as debug
+ level = g.logger.Debug()
+ }
+ if !level.Enabled() {
+ return
+ }
+ l := level.Str(logErrorCode, error.Code).Str(logErrorId, error.Id).Int(logErrorStatus, error.NumStatus)
+ if error.Source != nil {
+ if error.Source.Header != "" {
+ l.Str(logErrorSourceHeader, logstr(error.Source.Header))
+ }
+ if error.Source.Parameter != "" {
+ l.Str(logErrorSourceParameter, logstr(error.Source.Parameter))
+ }
+ if error.Source.Pointer != "" {
+ l.Str(logErrorSourcePointer, logstr(error.Source.Pointer))
+ }
+ }
+ l.Msg(error.Title)
+}
+
+func (g Groupware) serveError(w http.ResponseWriter, r *http.Request, error *Error) {
+ if error == nil {
+ return
+ }
+ g.log(error)
+ w.Header().Add("Content-Type", ContentTypeJsonApi)
+ render.Status(r, error.NumStatus)
+ w.WriteHeader(error.NumStatus)
+ render.Render(w, r, errorResponses(*error))
}
func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(r Request) (any, string, *Error)) {
ctx := r.Context()
logger := g.logger.SubloggerWithRequestID(ctx)
- session, ok, err := g.session(r, ctx, &logger)
+
+ username, ok, err := g.usernameProvider.GetUsername(r, ctx, &logger)
+ if err != nil {
+ g.serveError(w, r, apiError(errorId(r, ctx), ErrorInvalidAuthentication))
+ return
+ }
+ if !ok {
+ g.serveError(w, r, apiError(errorId(r, ctx), ErrorMissingAuthentication))
+ return
+ }
+
+ logger = log.Logger{Logger: logger.With().Str(logUsername, logstr(username)).Logger()}
+
+ session, ok, err := g.session(username, r, ctx, &logger)
if err != nil {
logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg("failed to determine JMAP session")
render.Status(r, http.StatusInternalServerError)
@@ -198,7 +317,7 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(
response, state, apierr := handler(req)
if apierr != nil {
- logger.Warn().Interface("error", apierr).Msgf("API error: %v", apierr)
+ g.log(apierr)
w.Header().Add("Content-Type", ContentTypeJsonApi)
render.Status(r, apierr.NumStatus)
w.WriteHeader(apierr.NumStatus)
@@ -210,7 +329,6 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(
w.Header().Add("ETag", state)
}
if response == nil {
- logger.Debug().Msgf("respond: response is nil, 404")
render.Status(r, http.StatusNotFound)
w.WriteHeader(http.StatusNotFound)
} else {
@@ -249,3 +367,13 @@ func (g Groupware) withSession(w http.ResponseWriter, r *http.Request, handler f
return response, state, err
}
*/
+
+func (g Groupware) NotFound(w http.ResponseWriter, r *http.Request) {
+ level := g.logger.Debug()
+ if level.Enabled() {
+ path := logstr(r.URL.Path)
+ level.Str("path", path).Int(logErrorStatus, http.StatusNotFound).Msgf("unmatched path: '%v'", path)
+ }
+ render.Status(r, http.StatusNotFound)
+ w.WriteHeader(http.StatusNotFound)
+}
diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go
index 62f19f304..f00e02000 100644
--- a/services/groupware/pkg/groupware/groupware_route.go
+++ b/services/groupware/pkg/groupware/groupware_route.go
@@ -4,14 +4,25 @@ import (
"github.com/go-chi/chi/v5"
)
+const (
+ UriParamAccount = "account"
+ UriParamMailboxId = "mailbox"
+ QueryParamPage = "page"
+ QueryParamSize = "size"
+ UriParamMessagesId = "id"
+)
+
func (g Groupware) Route(r chi.Router) {
r.Get("/", g.Index)
+ r.Get("/accounts", g.GetAccounts)
r.Route("/accounts/{account}", func(r chi.Router) {
r.Get("/", g.GetAccount)
r.Get("/mailboxes", g.GetMailboxes) // ?name=&role=&subcribed=
- r.Get("/mailboxes/{id}", g.GetMailboxById)
- r.Get("/{mailbox}/messages", g.GetMessages)
+ r.Get("/mailboxes/{mailbox}", g.GetMailbox)
+ r.Get("/mailboxes/{mailbox}/messages", g.GetAllMessages)
+ r.Get("/messages/{id}", g.GetMessagesById)
r.Get("/identity", g.GetIdentity)
r.Get("/vacation", g.GetVacation)
})
+ r.NotFound(g.NotFound)
}
diff --git a/services/groupware/pkg/groupware/groupware_session.go b/services/groupware/pkg/groupware/groupware_session.go
index 4ee2ecf68..7a83bdda1 100644
--- a/services/groupware/pkg/groupware/groupware_session.go
+++ b/services/groupware/pkg/groupware/groupware_session.go
@@ -48,7 +48,7 @@ var _ cachedSession = failedSession{}
type sessionCacheLoader struct {
logger *log.Logger
- jmapClient jmap.Client
+ jmapClient *jmap.Client
errorTtl time.Duration
}
diff --git a/services/groupware/pkg/groupware/groupware_tools.go b/services/groupware/pkg/groupware/groupware_tools.go
deleted file mode 100644
index 6400742d9..000000000
--- a/services/groupware/pkg/groupware/groupware_tools.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package groupware
-
-import (
- "net/http"
- "strconv"
-
- "github.com/opencloud-eu/opencloud/pkg/jmap"
-)
-
-func ParseNumericParam(r *http.Request, param string, defaultValue int) (int, bool, error) {
- str := r.URL.Query().Get(param)
- if str == "" {
- return defaultValue, false, nil
- }
-
- value, err := strconv.ParseInt(str, 10, 0)
- if err != nil {
- return defaultValue, false, nil
- }
- return int(value), true, nil
-}
-
-func PickInbox(folders []jmap.Mailbox) string {
- for _, folder := range folders {
- if folder.Role == "inbox" {
- return folder.Id
- }
- }
- return ""
-}