mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-01-23 05:20:15 -05:00
groupware: further implementation and improvements
This commit is contained in:
174
pkg/jmap/jmap.go
174
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 {
|
||||
|
||||
@@ -12,6 +12,7 @@ const (
|
||||
JmapErrorInvalidSessionResponse
|
||||
JmapErrorInvalidJmapRequestPayload
|
||||
JmapErrorInvalidJmapResponsePayload
|
||||
JmapErrorMethodLevel
|
||||
)
|
||||
|
||||
type Error interface {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
77
services/groupware/pkg/groupware/groupware_api_messages.go
Normal file
77
services/groupware/pkg/groupware/groupware_api_messages.go
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ var _ cachedSession = failedSession{}
|
||||
|
||||
type sessionCacheLoader struct {
|
||||
logger *log.Logger
|
||||
jmapClient jmap.Client
|
||||
jmapClient *jmap.Client
|
||||
errorTtl time.Duration
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user