groupware: further implementation and improvements

This commit is contained in:
Pascal Bleser
2025-07-31 20:34:01 +02:00
parent 2573bf4a99
commit 4dcbb5d8e3
17 changed files with 605 additions and 203 deletions

View File

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

View File

@@ -12,6 +12,7 @@ const (
JmapErrorInvalidSessionResponse
JmapErrorInvalidJmapRequestPayload
JmapErrorInvalidJmapResponsePayload
JmapErrorMethodLevel
)
type Error interface {

View File

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

View File

@@ -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 users 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"`

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"`

View File

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

View File

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

View File

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

View File

@@ -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 links target.
//
// [RFC3986 Section 4.1]: https://datatracker.ietf.org/doc/html/rfc3986#section-4.1
Href string `json:"href"`
// A string indicating the links 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 doesnt, 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 {

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ var _ cachedSession = failedSession{}
type sessionCacheLoader struct {
logger *log.Logger
jmapClient jmap.Client
jmapClient *jmap.Client
errorTtl time.Duration
}

View File

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