From 8d9c3b0c4eb03ae5fccc85ef38c8efbbcfcf8121 Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Mon, 11 Aug 2025 15:38:39 +0200 Subject: [PATCH] Groupware improvements * ensure that all the jmap responses contain the SessionState * implement missing errors that were marked as TODO * moved common functions from pkg/jmap and pkg/services/groupware to pkg/log and pkg/structs to commonalize them across both source trees * implement error handling for SetError occurences * Email: replace anonymous map[string]bool for mailbox rights with a MailboxRights struct, as the keys are well-defined, which allows for properly documenting them * introduce ObjectType as an "enum" * fix JSON marshalling and unmarshalling of EmailBodyStructure * move the swagger documentation structs from groupware_api.go to groupware_docs.go * fix: change verb for /groupware/accounts/*/vacation from POST to PUT --- pkg/jmap/jmap_api_blob.go | 40 ++-- pkg/jmap/jmap_api_email.go | 109 ++++++----- pkg/jmap/jmap_api_identity.go | 37 ++-- pkg/jmap/jmap_api_mailbox.go | 58 ++++-- pkg/jmap/jmap_api_vacation.go | 106 +++++------ pkg/jmap/jmap_client.go | 12 +- pkg/jmap/jmap_error.go | 16 ++ pkg/jmap/jmap_model.go | 171 ++++++++++++++++-- pkg/jmap/jmap_session.go | 7 +- pkg/jmap/jmap_test.go | 5 +- pkg/jmap/jmap_tools.go | 64 +------ pkg/jmap/jmap_tools_test.go | 40 +++- pkg/log/log_safely.go | 43 +++++ pkg/structs/structs.go | 21 +++ pkg/structs/structs_test.go | 49 ++++- .../groupware/pkg/groupware/groupware_api.go | 30 --- .../pkg/groupware/groupware_api_blob.go | 6 +- .../pkg/groupware/groupware_api_index.go | 3 +- .../pkg/groupware/groupware_api_mailbox.go | 16 +- .../pkg/groupware/groupware_api_messages.go | 32 ++-- .../pkg/groupware/groupware_api_vacation.go | 2 +- .../groupware/pkg/groupware/groupware_docs.go | 30 +++ .../pkg/groupware/groupware_framework.go | 119 +++--------- .../pkg/groupware/groupware_route.go | 59 +++--- 24 files changed, 645 insertions(+), 430 deletions(-) create mode 100644 pkg/log/log_safely.go diff --git a/pkg/jmap/jmap_api_blob.go b/pkg/jmap/jmap_api_blob.go index e7406d5206..dc5203da14 100644 --- a/pkg/jmap/jmap_api_blob.go +++ b/pkg/jmap/jmap_api_blob.go @@ -9,7 +9,13 @@ import ( "github.com/opencloud-eu/opencloud/pkg/log" ) -func (j *Client) GetBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, id string) (*Blob, Error) { +type BlobResponse struct { + Blob *Blob `json:"blob,omitempty"` + State string `json:"state"` + SessionState string `json:"sessionState"` +} + +func (j *Client) GetBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, id string) (BlobResponse, Error) { aid := session.BlobAccountId(accountId) cmd, err := request( @@ -20,29 +26,31 @@ func (j *Client) GetBlob(accountId string, session *Session, ctx context.Context }, "0"), ) if err != nil { - return nil, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} + return BlobResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } - return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (*Blob, Error) { + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (BlobResponse, Error) { var response BlobGetResponse err = retrieveResponseMatchParameters(body, BlobGet, "0", &response) if err != nil { - return nil, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + return BlobResponse{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } if len(response.List) != 1 { - return nil, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + return BlobResponse{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } get := response.List[0] - return &get, nil + return BlobResponse{Blob: &get, State: response.State, SessionState: body.SessionState}, nil }) } type UploadedBlob struct { - Id string `json:"id"` - Size int `json:"size"` - Type string `json:"type"` - Sha512 string `json:"sha:512"` + Id string `json:"id"` + Size int `json:"size"` + Type string `json:"type"` + Sha512 string `json:"sha:512"` + State string `json:"state"` + SessionState string `json:"sessionState"` } func (j *Client) UploadBlobStream(accountId string, session *Session, ctx context.Context, logger *log.Logger, contentType string, body io.Reader) (UploadedBlob, Error) { @@ -60,7 +68,7 @@ func (j *Client) DownloadBlobStream(accountId string, blobId string, name string downloadUrl = strings.ReplaceAll(downloadUrl, "{blobId}", blobId) downloadUrl = strings.ReplaceAll(downloadUrl, "{name}", name) downloadUrl = strings.ReplaceAll(downloadUrl, "{type}", typ) - logger = &log.Logger{Logger: logger.With().Str(logDownloadUrl, downloadUrl).Str(logBlobId, blobId).Str(logAccountId, aid).Logger()} + logger = log.From(logger.With().Str(logDownloadUrl, downloadUrl).Str(logBlobId, blobId).Str(logAccountId, aid)) return j.blob.DownloadBinary(ctx, logger, session, downloadUrl) } @@ -126,10 +134,12 @@ func (j *Client) UploadBlob(accountId string, session *Session, ctx context.Cont get := getResponse.List[0] return UploadedBlob{ - Id: upload.Id, - Size: upload.Size, - Type: upload.Type, - Sha512: get.DigestSha512, + Id: upload.Id, + Size: upload.Size, + Type: upload.Type, + Sha512: get.DigestSha512, + State: getResponse.State, + SessionState: body.SessionState, }, nil }) diff --git a/pkg/jmap/jmap_api_email.go b/pkg/jmap/jmap_api_email.go index bd99d37a7b..88748e560a 100644 --- a/pkg/jmap/jmap_api_email.go +++ b/pkg/jmap/jmap_api_email.go @@ -3,6 +3,7 @@ package jmap import ( "context" "encoding/base64" + "fmt" "time" "github.com/opencloud-eu/opencloud/pkg/log" @@ -22,11 +23,12 @@ const ( ) type Emails struct { - Emails []Email `json:"emails,omitempty"` - Total int `json:"total,omitzero"` - Limit int `json:"limit,omitzero"` - Offset int `json:"offset,omitzero"` - State string `json:"state,omitempty"` + Emails []Email `json:"emails,omitempty"` + Total int `json:"total,omitzero"` + Limit int `json:"limit,omitzero"` + Offset int `json:"offset,omitzero"` + State string `json:"state,omitempty"` + SessionState string `json:"sessionState"` } func (j *Client) GetEmails(accountId string, session *Session, ctx context.Context, logger *log.Logger, ids []string, fetchBodies bool, maxBodyValueBytes int) (Emails, Error) { @@ -48,7 +50,7 @@ func (j *Client) GetEmails(accountId string, session *Session, ctx context.Conte if err != nil { return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } - return Emails{Emails: response.List, State: body.SessionState}, nil + return Emails{Emails: response.List, State: response.State, SessionState: body.SessionState}, nil }) } @@ -102,11 +104,12 @@ func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Co } return Emails{ - Emails: getResponse.List, - State: body.SessionState, - Total: queryResponse.Total, - Limit: queryResponse.Limit, - Offset: queryResponse.Position, + Emails: getResponse.List, + Total: queryResponse.Total, + Limit: queryResponse.Limit, + Offset: queryResponse.Position, + SessionState: body.SessionState, + State: getResponse.State, }, nil }) } @@ -118,6 +121,7 @@ type EmailsSince struct { Created []Email `json:"created,omitempty"` Updated []Email `json:"updated,omitempty"` State string `json:"state,omitempty"` + SessionState string `json:"sessionState"` } func (j *Client) GetEmailsInMailboxSince(accountId string, session *Session, ctx context.Context, logger *log.Logger, mailboxId string, since string, fetchBodies bool, maxBodyValueBytes int, maxChanges int) (EmailsSince, Error) { @@ -185,7 +189,8 @@ func (j *Client) GetEmailsInMailboxSince(accountId string, session *Session, ctx NewState: mailboxResponse.NewState, Created: createdResponse.List, Updated: createdResponse.List, - State: body.SessionState, + State: createdResponse.State, + SessionState: body.SessionState, }, nil }) } @@ -255,7 +260,8 @@ func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context. NewState: changesResponse.NewState, Created: createdResponse.List, Updated: createdResponse.List, - State: body.SessionState, + State: updatedResponse.State, + SessionState: body.SessionState, }, nil }) } @@ -323,10 +329,10 @@ func (j *Client) QueryEmailSnippets(accountId string, filter EmailFilterElement, return EmailSnippetQueryResult{ Snippets: snippetResponse.List, - QueryState: queryResponse.QueryState, Total: queryResponse.Total, Limit: queryResponse.Limit, Position: queryResponse.Position, + QueryState: queryResponse.QueryState, SessionState: body.SessionState, }, nil }) @@ -340,10 +346,10 @@ type EmailWithSnippets struct { type EmailQueryResult struct { Results []EmailWithSnippets `json:"results"` - QueryState string `json:"queryState"` Total int `json:"total"` Limit int `json:"limit,omitzero"` Position int `json:"position,omitzero"` + QueryState string `json:"queryState"` SessionState string `json:"sessionState,omitempty"` } @@ -440,10 +446,10 @@ func (j *Client) QueryEmails(accountId string, filter EmailFilterElement, sessio return EmailQueryResult{ Results: results, - QueryState: queryResponse.QueryState, Total: queryResponse.Total, Limit: queryResponse.Limit, Position: queryResponse.Position, + QueryState: queryResponse.QueryState, SessionState: body.SessionState, }, nil }) @@ -451,10 +457,11 @@ func (j *Client) QueryEmails(accountId string, filter EmailFilterElement, sessio } type UploadedEmail struct { - Id string `json:"id"` - Size int `json:"size"` - Type string `json:"type"` - Sha512 string `json:"sha:512"` + Id string `json:"id"` + Size int `json:"size"` + Type string `json:"type"` + Sha512 string `json:"sha:512"` + SessionState string `json:"sessionState"` } func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte) (UploadedEmail, Error) { @@ -519,18 +526,20 @@ func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Con get := getResponse.List[0] return UploadedEmail{ - Id: upload.Id, - Size: upload.Size, - Type: upload.Type, - Sha512: get.DigestSha512, + Id: upload.Id, + Size: upload.Size, + Type: upload.Type, + Sha512: get.DigestSha512, + SessionState: body.SessionState, }, nil }) } type CreatedEmail struct { - Email Email `json:"email"` - State string `json:"state"` + Email Email `json:"email"` + State string `json:"state"` + SessionState string `json:"sessionState"` } func (j *Client) CreateEmail(accountId string, email EmailCreate, session *Session, ctx context.Context, logger *log.Logger) (CreatedEmail, Error) { @@ -560,22 +569,29 @@ func (j *Client) CreateEmail(accountId string, email EmailCreate, session *Sessi // TODO(pbleser-oc) handle submission errors } + setErr, notok := setResponse.NotCreated["c"] + if notok { + return CreatedEmail{}, setErrorError(setErr, EmailType) + } + created, ok := setResponse.Created["c"] if !ok { - // failed to create? - // TODO(pbleser-oc) handle email creation failure + err = fmt.Errorf("failed to find %s in %s response", string(EmailType), string(EmailSet)) + return CreatedEmail{}, simpleError(err, JmapErrorInvalidJmapResponsePayload) } return CreatedEmail{ - Email: created, - State: setResponse.NewState, + Email: created, + State: setResponse.NewState, + SessionState: body.SessionState, }, nil }) } type UpdatedEmails struct { - Updated map[string]Email `json:"email"` - State string `json:"state"` + Updated map[string]Email `json:"email"` + State string `json:"state"` + SessionState string `json:"sessionState"` } // The Email/set method encompasses: @@ -610,14 +626,16 @@ func (j *Client) UpdateEmails(accountId string, updates map[string]EmailUpdate, // TODO(pbleser-oc) handle submission errors } return UpdatedEmails{ - Updated: setResponse.Updated, - State: setResponse.NewState, + Updated: setResponse.Updated, + State: setResponse.NewState, + SessionState: body.SessionState, }, nil }) } type DeletedEmails struct { - State string `json:"state"` + State string `json:"state"` + SessionState string `json:"sessionState"` } func (j *Client) DeleteEmails(accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger) (DeletedEmails, Error) { @@ -643,7 +661,7 @@ func (j *Client) DeleteEmails(accountId string, destroy []string, session *Sessi // error occured // TODO(pbleser-oc) handle submission errors } - return DeletedEmails{State: setResponse.NewState}, nil + return DeletedEmails{State: setResponse.NewState, SessionState: body.SessionState}, nil }) } @@ -670,6 +688,8 @@ type SubmittedEmail struct { // // [RFC8098]: https://datatracker.ietf.org/doc/html/rfc8098 MdnBlobIds []string `json:"mdnBlobIds,omitempty"` + + SessionState string `json:"sessionState"` } func (j *Client) SubmitEmail(accountId string, identityId string, emailId string, session *Session, ctx context.Context, logger *log.Logger, data []byte) (SubmittedEmail, Error) { @@ -744,14 +764,15 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string submission := getResponse.List[0] return SubmittedEmail{ - Id: submission.Id, - State: setResponse.NewState, - SendAt: submission.SendAt, - ThreadId: submission.ThreadId, - UndoStatus: submission.UndoStatus, - Envelope: *submission.Envelope, - DsnBlobIds: submission.DsnBlobIds, - MdnBlobIds: submission.MdnBlobIds, + Id: submission.Id, + State: setResponse.NewState, + SendAt: submission.SendAt, + ThreadId: submission.ThreadId, + UndoStatus: submission.UndoStatus, + Envelope: *submission.Envelope, + DsnBlobIds: submission.DsnBlobIds, + MdnBlobIds: submission.MdnBlobIds, + SessionState: body.SessionState, }, nil }) diff --git a/pkg/jmap/jmap_api_identity.go b/pkg/jmap/jmap_api_identity.go index 5e6a7b18c5..17ebcf526c 100644 --- a/pkg/jmap/jmap_api_identity.go +++ b/pkg/jmap/jmap_api_identity.go @@ -5,35 +5,47 @@ import ( "strconv" "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/pkg/structs" "github.com/rs/zerolog" ) +type Identities struct { + Identities []Identity `json:"identities"` + State string `json:"state"` + SessionState string `json:"sessionState"` +} + // https://jmap.io/spec-mail.html#identityget -func (j *Client) GetIdentity(accountId string, session *Session, ctx context.Context, logger *log.Logger) (IdentityGetResponse, Error) { +func (j *Client) GetIdentity(accountId string, session *Session, ctx context.Context, logger *log.Logger) (Identities, 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 Identities{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } - return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (IdentityGetResponse, Error) { + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Identities, Error) { var response IdentityGetResponse err = retrieveResponseMatchParameters(body, IdentityGet, "0", &response) - return response, simpleError(err, JmapErrorInvalidJmapResponsePayload) + return Identities{ + Identities: response.List, + State: response.State, + SessionState: body.SessionState, + }, simpleError(err, JmapErrorInvalidJmapResponsePayload) }) } type IdentitiesGetResponse struct { - State string `json:"state"` - Identities map[string][]Identity `json:"identities,omitempty"` - NotFound []string `json:"notFound,omitempty"` + Identities map[string][]Identity `json:"identities,omitempty"` + NotFound []string `json:"notFound,omitempty"` + State string `json:"state"` + SessionState string `json:"sessionState"` } func (j *Client) GetIdentities(accountIds []string, session *Session, ctx context.Context, logger *log.Logger) (IdentitiesGetResponse, Error) { - uniqueAccountIds := uniq(accountIds) + uniqueAccountIds := structs.Uniq(accountIds) logger = j.loggerParams("", "GetIdentities", session, logger, func(l zerolog.Context) zerolog.Context { - return l.Array(logAccountId, logstrarray(uniqueAccountIds)) + return l.Array(logAccountId, log.SafeStringArray(uniqueAccountIds)) }) calls := make([]Invocation, len(uniqueAccountIds)) @@ -62,9 +74,10 @@ func (j *Client) GetIdentities(accountIds []string, session *Session, ctx contex } return IdentitiesGetResponse{ - Identities: identities, - State: lastState, - NotFound: uniq(notFound), + Identities: identities, + NotFound: structs.Uniq(notFound), + State: lastState, + SessionState: body.SessionState, }, nil }) } diff --git a/pkg/jmap/jmap_api_mailbox.go b/pkg/jmap/jmap_api_mailbox.go index 34efb0a231..58ec89cde8 100644 --- a/pkg/jmap/jmap_api_mailbox.go +++ b/pkg/jmap/jmap_api_mailbox.go @@ -6,43 +6,63 @@ import ( "github.com/opencloud-eu/opencloud/pkg/log" ) +type MailboxesResponse struct { + Mailboxes []Mailbox `json:"mailboxes"` + NotFound []any `json:"notFound"` + State string `json:"state"` + SessionState string `json:"sessionState"` +} + // https://jmap.io/spec-mail.html#mailboxget -func (j *Client) GetMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxGetResponse, Error) { +func (j *Client) GetMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxesResponse, 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 MailboxesResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } - return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (MailboxGetResponse, Error) { + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (MailboxesResponse, Error) { var response MailboxGetResponse err = retrieveResponseMatchParameters(body, MailboxGet, "0", &response) - return response, simpleError(err, JmapErrorInvalidJmapResponsePayload) + if err != nil { + return MailboxesResponse{}, simpleError(err, JmapErrorInvalidJmapResponsePayload) + } + + return MailboxesResponse{ + Mailboxes: response.List, + NotFound: response.NotFound, + State: response.State, + SessionState: body.SessionState, + }, simpleError(err, JmapErrorInvalidJmapResponsePayload) }) } -func (j *Client) GetAllMailboxes(accountId string, session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, Error) { - return j.GetMailbox(accountId, session, ctx, logger, nil) +type AllMailboxesResponse struct { + Mailboxes []Mailbox `json:"mailboxes"` + State string `json:"state"` + SessionState string `json:"sessionState"` } -// https://jmap.io/spec-mail.html#mailboxquery -func (j *Client) QueryMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterElement) (MailboxQueryResponse, Error) { - aid := session.MailAccountId(accountId) - logger = j.logger(aid, "QueryMailbox", session, logger) - cmd, err := request(invocation(MailboxQuery, MailboxQueryCommand{AccountId: aid, Filter: filter}, "0")) +func (j *Client) GetAllMailboxes(accountId string, session *Session, ctx context.Context, logger *log.Logger) (AllMailboxesResponse, Error) { + resp, err := j.GetMailbox(accountId, session, ctx, logger, nil) if err != nil { - return MailboxQueryResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} + return AllMailboxesResponse{}, err } - 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) - }) + return AllMailboxesResponse{ + Mailboxes: resp.Mailboxes, + State: resp.State, + SessionState: resp.SessionState, + }, nil } type Mailboxes struct { + // The list of mailboxes that were found using the specified search criteria. Mailboxes []Mailbox `json:"mailboxes,omitempty"` - State string `json:"state,omitempty"` + // The state of the search. + State string `json:"state,omitempty"` + // The state of the Session. + SessionState string `json:"sessionState,omitempty"` } func (j *Client) SearchMailboxes(accountId string, session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterElement) (Mailboxes, Error) { @@ -66,6 +86,6 @@ func (j *Client) SearchMailboxes(accountId string, session *Session, ctx context if err != nil { return Mailboxes{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } - return Mailboxes{Mailboxes: response.List, State: body.SessionState}, nil + return Mailboxes{Mailboxes: response.List, State: response.State, SessionState: body.SessionState}, nil }) } diff --git a/pkg/jmap/jmap_api_vacation.go b/pkg/jmap/jmap_api_vacation.go index c26523470b..fe020cfaf6 100644 --- a/pkg/jmap/jmap_api_vacation.go +++ b/pkg/jmap/jmap_api_vacation.go @@ -2,6 +2,7 @@ package jmap import ( "context" + "fmt" "time" "github.com/opencloud-eu/opencloud/pkg/log" @@ -26,48 +27,8 @@ func (j *Client) GetVacationResponse(accountId string, session *Session, ctx con }) } -type VacationResponseStatusChange struct { - VacationResponse VacationResponse `json:"vacationResponse"` - ResponseState string `json:"state"` - SessionState string `json:"sessionState"` -} - -func (j *Client) SetVacationResponseStatus(accountId string, enabled bool, session *Session, ctx context.Context, logger *log.Logger) (VacationResponseStatusChange, Error) { - aid := session.MailAccountId(accountId) - logger = j.logger(aid, "EnableVacationResponse", session, logger) - - cmd, err := request(invocation(VacationResponseSet, VacationResponseSetRequest{ - AccountId: aid, - Update: map[string]PatchObject{ - "u": { - "/isEnabled": enabled, - }, - }, - }, "0")) - - if err != nil { - return VacationResponseStatusChange{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} - } - return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (VacationResponseStatusChange, Error) { - var response VacationResponseSetResponse - err = retrieveResponseMatchParameters(body, VacationResponseSet, "0", &response) - if err != nil { - return VacationResponseStatusChange{}, simpleError(err, JmapErrorInvalidJmapResponsePayload) - } - updated, ok := response.Updated["u"] - if !ok { - // TODO implement error when not updated - } - - return VacationResponseStatusChange{ - VacationResponse: updated, - ResponseState: response.NewState, - SessionState: response.State, - }, nil - }) -} - -type VacationResponseBody struct { +// Same as VacationResponse but without the id. +type VacationResponsePayload struct { // Should a vacation response be sent if a message arrives between the "fromDate" and "toDate"? IsEnabled bool `json:"isEnabled"` // If "isEnabled" is true, messages that arrive on or after this date-time (but before the "toDate" if defined) should receive the @@ -96,44 +57,59 @@ type VacationResponseChange struct { SessionState string `json:"sessionState"` } -func (j *Client) SetVacationResponse(accountId string, vacation VacationResponseBody, session *Session, ctx context.Context, logger *log.Logger) (VacationResponseChange, Error) { +func (j *Client) SetVacationResponse(accountId string, vacation VacationResponsePayload, session *Session, ctx context.Context, logger *log.Logger) (VacationResponseChange, Error) { aid := session.MailAccountId(accountId) logger = j.logger(aid, "SetVacationResponse", session, logger) - set := VacationResponseSetRequest{ - AccountId: aid, - Create: map[string]VacationResponse{ - vacationResponseId: { - IsEnabled: vacation.IsEnabled, - FromDate: vacation.FromDate, - ToDate: vacation.ToDate, - Subject: vacation.Subject, - TextBody: vacation.TextBody, - HtmlBody: vacation.HtmlBody, + cmd, err := request( + invocation(VacationResponseSet, VacationResponseSetCommand{ + AccountId: aid, + Create: map[string]VacationResponse{ + vacationResponseId: { + IsEnabled: vacation.IsEnabled, + FromDate: vacation.FromDate, + ToDate: vacation.ToDate, + Subject: vacation.Subject, + TextBody: vacation.TextBody, + HtmlBody: vacation.HtmlBody, + }, }, - }, - } - - cmd, err := request(invocation(VacationResponseSet, set, "0")) + }, "0"), + // chain a second request to get the current complete VacationResponse object + // after performing the changes, as that makes for a better API + invocation(VacationResponseGet, VacationResponseGetCommand{AccountId: aid}, "1"), + ) if err != nil { return VacationResponseChange{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (VacationResponseChange, Error) { - var response VacationResponseSetResponse - err = retrieveResponseMatchParameters(body, VacationResponseSet, "0", &response) + var setResponse VacationResponseSetResponse + err = retrieveResponseMatchParameters(body, VacationResponseSet, "0", &setResponse) if err != nil { return VacationResponseChange{}, simpleError(err, JmapErrorInvalidJmapResponsePayload) } - created, ok := response.Created[vacationResponseId] - if !ok { - // TODO handle case where created is missing + setErr, notok := setResponse.NotCreated[vacationResponseId] + if notok { + // this means that the VacationResponse was not updated + return VacationResponseChange{}, setErrorError(setErr, VacationResponseType) + } + + var getResponse VacationResponseGetResponse + err = retrieveResponseMatchParameters(body, VacationResponseGet, "1", &getResponse) + if err != nil { + return VacationResponseChange{}, simpleError(err, JmapErrorInvalidJmapResponsePayload) + } + + if len(getResponse.List) != 1 { + err = fmt.Errorf("failed to find %s in %s response", string(VacationResponseType), string(VacationResponseGet)) + return VacationResponseChange{}, simpleError(err, JmapErrorInvalidJmapResponsePayload) } return VacationResponseChange{ - VacationResponse: created, - ResponseState: response.NewState, - SessionState: response.State, + VacationResponse: getResponse.List[0], + ResponseState: setResponse.NewState, + SessionState: body.SessionState, }, nil }) } diff --git a/pkg/jmap/jmap_client.go b/pkg/jmap/jmap_client.go index d47d69ea94..8b8c83c7a0 100644 --- a/pkg/jmap/jmap_client.go +++ b/pkg/jmap/jmap_client.go @@ -48,17 +48,17 @@ func (j *Client) FetchSession(username string, logger *log.Logger) (Session, Err } 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) + l := logger.With().Str(logOperation, operation).Str(logUsername, session.Username) if accountId != "" { - zc = zc.Str(logAccountId, accountId) + l = l.Str(logAccountId, accountId) } - return &log.Logger{Logger: zc.Logger()} + return log.From(l) } 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) + l := logger.With().Str(logOperation, operation).Str(logUsername, session.Username) if accountId != "" { - zc = zc.Str(logAccountId, accountId) + l = l.Str(logAccountId, accountId) } - return &log.Logger{Logger: params(zc).Logger()} + return log.From(l) } diff --git a/pkg/jmap/jmap_error.go b/pkg/jmap/jmap_error.go index df72d7fed6..425260d535 100644 --- a/pkg/jmap/jmap_error.go +++ b/pkg/jmap/jmap_error.go @@ -1,5 +1,10 @@ package jmap +import ( + "fmt" + "strings" +) + const ( JmapErrorAuthenticationFailed = iota JmapErrorInvalidHttpRequest @@ -13,6 +18,7 @@ const ( JmapErrorInvalidJmapRequestPayload JmapErrorInvalidJmapResponsePayload JmapErrorMethodLevel + JmapErrorSetError ) type Error interface { @@ -44,3 +50,13 @@ func simpleError(err error, code int) Error { return nil } } + +func setErrorError(err SetError, objectType ObjectType) Error { + var e error + if len(err.Properties) > 0 { + e = fmt.Errorf("failed to modify %s due to %s error in properties [%s]: %s", objectType, err.Type, strings.Join(err.Properties, ", "), err.Description) + } else { + e = fmt.Errorf("failed to modify %s due to %s error: %s", objectType, err.Type, err.Description) + } + return SimpleError{code: JmapErrorSetError, err: e} +} diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index 2c35380d4e..8e349fad46 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -303,18 +303,152 @@ const ( Not FilterOperatorTerm = "NOT" ) +type MailboxRights struct { + // If true, the user may use this Mailbox as part of a filter in an Email/query call, + // and the Mailbox may be included in the mailboxIds property of Email objects. + // + // Email objects may be fetched if they are in at least one Mailbox with this permission. + // + // If a sub-Mailbox is shared but not the parent Mailbox, this may be false. + // + // Corresponds to IMAP ACLs lr (if mapping from IMAP, both are required for this to be true). + MayReadItems bool `json:"mayReadItems"` + + // The user may add mail to this Mailbox (by either creating a new Email or moving an existing one). + // + // Corresponds to IMAP ACL i. + MayAddItems bool `json:"mayAddItems"` + + // The user may remove mail from this Mailbox (by either changing the Mailboxes of an Email or + // destroying the Email). + // + // Corresponds to IMAP ACLs te (if mapping from IMAP, both are required for this to be true). + MayRemoveItems bool `json:"mayRemoveItems"` + + // The user may add or remove the $seen keyword to/from an Email. + // + // If an Email belongs to multiple Mailboxes, the user may only modify $seen if they have this + // permission for all of the Mailboxes. + // + // Corresponds to IMAP ACL s. + MaySetSeen bool `json:"maySetSeen"` + + // The user may add or remove any keyword other than $seen to/from an Email. + // + // If an Email belongs to multiple Mailboxes, the user may only modify keywords if they have this + // permission for all of the Mailboxes. + // + // Corresponds to IMAP ACL w. + MaySetKeywords bool `json:"maySetKeywords"` + + // The user may create a Mailbox with this Mailbox as its parent. + // + // Corresponds to IMAP ACL k. + MayCreateChild bool `json:"mayCreateChild"` + + // The user may rename the Mailbox or make it a child of another Mailbox. + // + // Corresponds to IMAP ACL x (although this covers both rename and delete permissions). + MayRename bool `json:"mayRename"` + + // The user may delete the Mailbox itself. + // + // Corresponds to IMAP ACL x (although this covers both rename and delete permissions). + MayDelete bool `json:"mayDelete"` + + // Messages may be submitted directly to this Mailbox. + // + // Corresponds to IMAP ACL p. + MaySubmit bool `json:"maySubmit"` +} + type Mailbox struct { - Id string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - ParentId string `json:"parentId,omitempty"` - Role string `json:"role,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"` + // The id of the Mailbox. + Id string `json:"id,omitempty"` + + // User-visible name for the Mailbox, e.g., “Inbox”. + // + // This MUST be a Net-Unicode string [@!RFC5198] of at least 1 character in length, subject to the maximum size + // given in the capability object. + // + // There MUST NOT be two sibling Mailboxes with both the same parent and the same name. + // + // Servers MAY reject names that violate server policy (e.g., names containing a slash (/) or control characters). + Name string `json:"name,omitempty"` + + // The Mailbox id for the parent of this Mailbox, or null if this Mailbox is at the top level. + // + // Mailboxes form acyclic graphs (forests) directed by the child-to-parent relationship. There MUST NOT be a loop. + ParentId string `json:"parentId,omitempty"` + + // Identifies Mailboxes that have a particular common purpose (e.g., the “inbox”), regardless of the name property + // (which may be localised). + // + // This value is shared with IMAP (exposed in IMAP via the SPECIAL-USE extension [RFC6154]). + // However, unlike in IMAP, a Mailbox MUST only have a single role, and there MUST NOT be two Mailboxes in the same + // account with the same role. + // + // Servers providing IMAP access to the same data are encouraged to enforce these extra restrictions in IMAP as well. + // Otherwise, modifying the IMAP attributes to ensure compliance when exposing the data over JMAP is implementation dependent. + // + // The value MUST be one of the Mailbox attribute names listed in the IANA IMAP Mailbox Name Attributes registry, + // as established in [RFC8457], converted to lowercase. New roles may be established here in the future. + // + // An account is not required to have Mailboxes with any particular roles. + // + // [RFC6154]: https://www.rfc-editor.org/rfc/rfc6154.html + // [RFC8457]: https://www.rfc-editor.org/rfc/rfc8457.html + Role string `json:"role,omitempty"` + + // (default: 0) Defines the sort order of Mailboxes when presented in the client’s UI, so it is consistent between devices. + // + // The number MUST be an integer in the range 0 <= sortOrder < 2^31. + // + // A Mailbox with a lower order should be displayed before a Mailbox with a higher order + // (that has the same parent) in any Mailbox listing in the client’s UI. + // Mailboxes with equal order SHOULD be sorted in alphabetical order by name. + // The sorting should take into account locale-specific character order convention. + SortOrder int `json:"sortOrder,omitzero"` + + // The number of Emails in this Mailbox. + TotalEmails int `json:"totalEmails"` + + // The number of Emails in this Mailbox that have neither the $seen keyword nor the $draft keyword. + UnreadEmails int `json:"unreadEmails"` + + // The number of Threads where at least one Email in the Thread is in this Mailbox. + TotalThreads int `json:"totalThreads"` + + // An indication of the number of “unread” Threads in the Mailbox. + UnreadThreads int `json:"unreadThreads"` + + // The set of rights (Access Control Lists (ACLs)) the user has in relation to this Mailbox. + // + // These are backwards compatible with IMAP ACLs, as defined in [RFC4314]. + // + // [RFC4314]: https://www.rfc-editor.org/rfc/rfc4314.html + MyRights MailboxRights `json:"myRights,omitempty"` + + // Has the user indicated they wish to see this Mailbox in their client? + // + // This SHOULD default to false for Mailboxes in shared accounts the user has access to and true + // for any new Mailboxes created by the user themself. + // + // This MUST be stored separately per user where multiple users have access to a shared Mailbox. + // + // A user may have permission to access a large number of shared accounts, or a shared account with a very + // large set of Mailboxes, but only be interested in the contents of a few of these. + // + // Clients may choose to only display Mailboxes where the isSubscribed property is set to true, and offer + // a separate UI to allow the user to see and subscribe/unsubscribe from the full set of Mailboxes. + // + // However, clients MAY choose to ignore this property, either entirely for ease of implementation or just + // for an account where isPersonal is true (indicating it is the user’s own rather than a shared account). + // + // This property corresponds to IMAP [RFC3501] Mailbox subscriptions. + // + // [RFC3501]: https://www.rfc-editor.org/rfc/rfc3501.html + IsSubscribed bool `json:"isSubscribed"` } type MailboxGetCommand struct { @@ -1199,6 +1333,13 @@ type EmailSubmissionSetResponse struct { // TODO(pbleser-oc) add updated and destroyed when they are needed } +type ObjectType string + +const ( + VacationResponseType ObjectType = "VacationResponse" + EmailType ObjectType = "Email" +) + type Command string type Invocation struct { @@ -1424,8 +1565,8 @@ type MailboxQueryResponse struct { } type EmailBodyStructure struct { - Type string //`json:"type"` - PartId string //`json:"partId"` + Type string `json:"type"` + PartId string `json:"partId"` Other map[string]any `mapstructure:",remain"` } @@ -1579,7 +1720,7 @@ type VacationResponseGetCommand struct { type VacationResponse struct { // The id of the object. // There is only ever one VacationResponse object, and its id is "singleton" - Id string `json:"id"` + Id string `json:"id,omitempty"` // Should a vacation response be sent if a message arrives between the "fromDate" and "toDate"? IsEnabled bool `json:"isEnabled"` // If "isEnabled" is true, messages that arrive on or after this date-time (but before the "toDate" if defined) should receive the @@ -1616,7 +1757,7 @@ type VacationResponseGetResponse struct { NotFound []any `json:"notFound,omitempty"` } -type VacationResponseSetRequest struct { +type VacationResponseSetCommand struct { AccountId string `json:"accountId"` IfInState string `json:"ifInState,omitempty"` Create map[string]VacationResponse `json:"create,omitempty"` diff --git a/pkg/jmap/jmap_session.go b/pkg/jmap/jmap_session.go index 4600f0bc54..605a696ff3 100644 --- a/pkg/jmap/jmap_session.go +++ b/pkg/jmap/jmap_session.go @@ -111,10 +111,9 @@ func (s *Session) SubmissionAccountId(accountId string) string { } // 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(). +func (s Session) DecorateLogger(l log.Logger) *log.Logger { + return log.From(l.With(). Str(logUsername, s.Username). Str(logApiUrl, s.ApiUrl). - Str(logSessionState, s.State). - Logger()} + Str(logSessionState, s.State)) } diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go index c04e777c69..c4bb1d870c 100644 --- a/pkg/jmap/jmap_test.go +++ b/pkg/jmap/jmap_test.go @@ -107,8 +107,9 @@ func serveTestFile(t *testing.T, name string) ([]byte, Error) { err = json.Unmarshal(bytes, &target) if err != nil { t.Errorf("failed to parse JSON test data file '%v': %v", p, err) + return nil, SimpleError{code: 0, err: err} } - return bytes, SimpleError{code: 0, err: err} + return bytes, nil } func (t *TestJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, Error) { @@ -152,7 +153,7 @@ func TestRequests(t *testing.T) { folders, err := client.GetAllMailboxes("a", &session, ctx, &logger) require.NoError(err) - require.Len(folders.List, 5) + require.Len(folders.Mailboxes, 5) emails, err := client.GetAllEmails("a", &session, ctx, &logger, "Inbox", 0, 0, true, 0) require.NoError(err) diff --git a/pkg/jmap/jmap_tools.go b/pkg/jmap/jmap_tools.go index b02f3246b4..93c1308a4a 100644 --- a/pkg/jmap/jmap_tools.go +++ b/pkg/jmap/jmap_tools.go @@ -4,13 +4,13 @@ import ( "context" "encoding/json" "fmt" + "maps" "reflect" "sync" "time" "github.com/mitchellh/mapstructure" "github.com/opencloud-eu/opencloud/pkg/log" - "github.com/rs/zerolog" ) type eventListeners[T any] struct { @@ -154,6 +154,14 @@ func retrieveResponseMatchParameters[T any](data *Response, command Command, tag return nil } +func (e EmailBodyStructure) MarshalJSON() ([]byte, error) { + m := map[string]any{} + maps.Copy(m, e.Other) // do this first to avoid overwriting type and partId + m["type"] = e.Type + m["partId"] = e.PartId + return json.Marshal(m) +} + func (e *EmailBodyStructure) UnmarshalJSON(bs []byte) error { m := map[string]any{} err := json.Unmarshal(bs, &m) @@ -163,16 +171,6 @@ func (e *EmailBodyStructure) UnmarshalJSON(bs []byte) error { return decodeMap(m, e) } -func (e *EmailBodyStructure) MarshalJSON() ([]byte, error) { - m := map[string]any{} - m["type"] = e.Type - m["partId"] = e.PartId - for k, v := range e.Other { - m[k] = v - } - return json.Marshal(m) -} - func (i *Invocation) MarshalJSON() ([]byte, error) { // JMAP requests have a slightly unusual structure since they are not a JSON object // but, instead, a three-element array composed of @@ -223,47 +221,3 @@ func (i *Invocation) UnmarshalJSON(bs []byte) error { i.Parameters = params return nil } - -const logMaxStrLength = 1024 - -// 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 - } -} - -type SafeLogStringArrayMarshaller struct { - array []string -} - -func (m SafeLogStringArrayMarshaller) MarshalZerologArray(a *zerolog.Array) { - for _, elem := range m.array { - a.Str(logstr(elem)) - } -} - -var _ zerolog.LogArrayMarshaler = SafeLogStringArrayMarshaller{} - -func logstrarray(array []string) SafeLogStringArrayMarshaller { - return SafeLogStringArrayMarshaller{array: array} -} - -func uniq[T comparable](ary []T) []T { - m := map[T]bool{} - for _, v := range ary { - m[v] = true - } - set := make([]T, len(m)) - i := 0 - for v := range m { - set[i] = v - i++ - } - return set -} diff --git a/pkg/jmap/jmap_tools_test.go b/pkg/jmap/jmap_tools_test.go index 3734bc21ec..e2ba0eab1b 100644 --- a/pkg/jmap/jmap_tools_test.go +++ b/pkg/jmap/jmap_tools_test.go @@ -26,7 +26,6 @@ func TestDeserializeMailboxGetResponse(t *testing.T) { require.Len(mgr.List, 5) require.Equal("n", mgr.State) require.Empty(mgr.NotFound) - var rights = []string{"mayReadItems", "mayAddItems", "mayRemoveItems", "maySetSeen", "maySetKeywords", "mayCreateChild", "mayRename", "mayDelete", "maySubmit"} var folders = []struct { id string name string @@ -53,10 +52,15 @@ func TestDeserializeMailboxGetResponse(t *testing.T) { require.Zero(folder.SortOrder) require.True(folder.IsSubscribed) - for _, right := range rights { - require.Contains(folder.MyRights, right) - require.True(folder.MyRights[right]) - } + require.True(folder.MyRights.MayReadItems) + require.True(folder.MyRights.MayAddItems) + require.True(folder.MyRights.MayRemoveItems) + require.True(folder.MyRights.MaySetSeen) + require.True(folder.MyRights.MaySetKeywords) + require.True(folder.MyRights.MayCreateChild) + require.True(folder.MyRights.MayRename) + require.True(folder.MyRights.MayDelete) + require.True(folder.MyRights.MaySubmit) } } @@ -84,7 +88,7 @@ func TestDeserializeEmailGetResponse(t *testing.T) { require.Equal("cbejozsk1fgcviw7thwzsvtgmf1ep0a3izjoimj02jmtsunpeuwmsaya1yma", email.BlobId) } -func TestUnknown(t *testing.T) { +func TestUnmarshallingUnknown(t *testing.T) { require := require.New(t) const text = `{ @@ -109,8 +113,24 @@ func TestUnknown(t *testing.T) { require.Equal(bs.Other["header:x"], "yz") require.Contains(bs.Other, "header:a") require.Equal(bs.Other["header:a"], "bc") - - result, err := json.Marshal(target) - require.NoError(err) - require.Equal(`{"subject":"aaa","bodyStructure":{"type":"a","partId":"b","header:a":"bc","header:x":"yz"}}`, string(result)) +} + +func TestMarshallingUnknown(t *testing.T) { + require := require.New(t) + + source := EmailCreate{ + Subject: "aaa", + BodyStructure: EmailBodyStructure{ + Type: "a", + PartId: "b", + Other: map[string]any{ + "header:x": "yz", + "header:a": "bc", + }, + }, + } + + result, err := json.Marshal(source) + require.NoError(err) + require.Equal(`{"subject":"aaa","bodyStructure":{"header:a":"bc","header:x":"yz","partId":"b","type":"a"}}`, string(result)) } diff --git a/pkg/log/log_safely.go b/pkg/log/log_safely.go new file mode 100644 index 0000000000..fb134ac3ef --- /dev/null +++ b/pkg/log/log_safely.go @@ -0,0 +1,43 @@ +package log + +import "github.com/rs/zerolog" + +const ( + logMaxStrLength = 512 + logMaxStrArrayLength = 16 // 8kb +) + +// 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 SafeString(text string) string { + runes := []rune(text) + + if len(runes) <= logMaxStrLength { + return text + } else { + return string(runes[0:logMaxStrLength-1]) + `\u2026` // hellip + } +} + +type SafeLogStringArrayMarshaller struct { + array []string +} + +func (m SafeLogStringArrayMarshaller) MarshalZerologArray(a *zerolog.Array) { + for i, elem := range m.array { + if i >= logMaxStrArrayLength { + return + } + a.Str(SafeString(elem)) + } +} + +var _ zerolog.LogArrayMarshaler = SafeLogStringArrayMarshaller{} + +func SafeStringArray(array []string) SafeLogStringArrayMarshaller { + return SafeLogStringArrayMarshaller{array: array} +} + +func From(context zerolog.Context) *Logger { + return &Logger{Logger: context.Logger()} +} diff --git a/pkg/structs/structs.go b/pkg/structs/structs.go index b6545d048a..471a8d10ad 100644 --- a/pkg/structs/structs.go +++ b/pkg/structs/structs.go @@ -1,6 +1,10 @@ // Package structs provides some utility functions for dealing with structs. package structs +import ( + orderedmap "github.com/wk8/go-ordered-map" +) + // CopyOrZeroValue returns a copy of s if s is not nil otherwise the zero value of T will be returned. func CopyOrZeroValue[T any](s *T) *T { cp := new(T) @@ -9,3 +13,20 @@ func CopyOrZeroValue[T any](s *T) *T { } return cp } + +// Returns a copy of an array with a unique set of elements. +// +// Element order is retained. +func Uniq[T comparable](source []T) []T { + m := orderedmap.New() + for _, v := range source { + m.Set(v, true) + } + set := make([]T, m.Len()) + i := 0 + for pair := m.Oldest(); pair != nil; pair = pair.Next() { + set[i] = pair.Key.(T) + i++ + } + return set +} diff --git a/pkg/structs/structs_test.go b/pkg/structs/structs_test.go index 1e8f4096cd..2df2281c1d 100644 --- a/pkg/structs/structs_test.go +++ b/pkg/structs/structs_test.go @@ -1,6 +1,11 @@ package structs -import "testing" +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) type example struct { Attribute1 string @@ -36,3 +41,45 @@ func TestCopyOrZeroValue(t *testing.T) { t.Error("CopyOrZeroValue didn't correctly copy attributes") } } + +func TestUniqWithInts(t *testing.T) { + tests := []struct { + input []int + expected []int + }{ + {[]int{5, 1, 3, 1, 4}, []int{5, 1, 3, 4}}, + {[]int{1, 1, 1}, []int{1}}, + } + for i, tt := range tests { + t.Run(fmt.Sprintf("%d: testing %v", i+1, tt.input), func(t *testing.T) { + result := Uniq(tt.input) + assert.EqualValues(t, tt.expected, result) + }) + } +} + +type u struct { + x int + y string +} + +var ( + u1 = u{x: 1, y: "un"} + u2 = u{x: 2, y: "deux"} + u3 = u{x: 3, y: "trois"} +) + +func TestUniqWithStructs(t *testing.T) { + tests := []struct { + input []u + expected []u + }{ + {[]u{u3, u1, u2, u3, u2, u1}, []u{u3, u1, u2}}, + } + for i, tt := range tests { + t.Run(fmt.Sprintf("%d: testing %v", i+1, tt.input), func(t *testing.T) { + result := Uniq(tt.input) + assert.EqualValues(t, tt.expected, result) + }) + } +} diff --git a/services/groupware/pkg/groupware/groupware_api.go b/services/groupware/pkg/groupware/groupware_api.go index c01dfb0e25..fa685dd8c7 100644 --- a/services/groupware/pkg/groupware/groupware_api.go +++ b/services/groupware/pkg/groupware/groupware_api.go @@ -11,33 +11,3 @@ const ( var Capabilities = []string{ CapMail_1, } - -// When the request contains invalid parameters. -// swagger:response ErrorResponse400 -type SwaggerErrorResponse400 struct { - // in: body - Body struct { - *ErrorResponse - } -} - -// When the requested object does not exist. -// swagger:response ErrorResponse404 -type SwaggerErrorResponse404 struct { -} - -// When the server was unable to complete the request. -// swagger:response ErrorResponse500 -type SwaggerErrorResponse500 struct { - // in: body - Body struct { - *ErrorResponse - } -} - -// swagger:parameters vacation mailboxes -type SwaggerAccountParams struct { - // The identifier of the account. - // in: path - Account string `json:"account"` -} diff --git a/services/groupware/pkg/groupware/groupware_api_blob.go b/services/groupware/pkg/groupware/groupware_api_blob.go index f5cd8572ba..92265c1f61 100644 --- a/services/groupware/pkg/groupware/groupware_api_blob.go +++ b/services/groupware/pkg/groupware/groupware_api_blob.go @@ -29,7 +29,11 @@ func (g Groupware) GetBlob(w http.ResponseWriter, r *http.Request) { if err != nil { return req.errorResponseFromJmap(err) } - return etagOnlyResponse(res, res.Digest()) + blob := res.Blob + if blob == nil { + return notFoundResponse("") + } + return etagOnlyResponse(res, blob.Digest()) }) } diff --git a/services/groupware/pkg/groupware/groupware_api_index.go b/services/groupware/pkg/groupware/groupware_api_index.go index 584f8a05a8..02cfef14cd 100644 --- a/services/groupware/pkg/groupware/groupware_api_index.go +++ b/services/groupware/pkg/groupware/groupware_api_index.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/opencloud-eu/opencloud/pkg/jmap" + "github.com/opencloud-eu/opencloud/pkg/structs" ) type IndexLimits struct { @@ -79,7 +80,7 @@ func (g Groupware) Index(w http.ResponseWriter, r *http.Request) { accountIds[i] = k i++ } - accountIds = uniq(accountIds) + accountIds = structs.Uniq(accountIds) identitiesResponse, err := g.jmap.GetIdentities(accountIds, req.session, req.ctx, req.logger) if err != nil { diff --git a/services/groupware/pkg/groupware/groupware_api_mailbox.go b/services/groupware/pkg/groupware/groupware_api_mailbox.go index 3fab4d99e1..9dbf883d35 100644 --- a/services/groupware/pkg/groupware/groupware_api_mailbox.go +++ b/services/groupware/pkg/groupware/groupware_api_mailbox.go @@ -44,10 +44,10 @@ func (g Groupware) GetMailbox(w http.ResponseWriter, r *http.Request) { return req.errorResponseFromJmap(err) } - if len(res.List) == 1 { - return response(res.List[0], res.State) + if len(res.Mailboxes) == 1 { + return etagResponse(res.Mailboxes[0], res.SessionState, res.State) } else { - return notFoundResponse(res.State) + return notFoundResponse(res.SessionState) } }) } @@ -92,17 +92,17 @@ func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) { var filter jmap.MailboxFilterCondition hasCriteria := false - name := q.Get("name") + name := q.Get(QueryParamMailboxSearchName) if name != "" { filter.Name = name hasCriteria = true } - role := q.Get("role") + role := q.Get(QueryParamMailboxSearchRole) if role != "" { filter.Role = role hasCriteria = true } - subscribed := q.Get("subscribed") + subscribed := q.Get(QueryParamMailboxSearchSubscribed) if subscribed != "" { b, err := strconv.ParseBool(subscribed) if err != nil { @@ -120,13 +120,13 @@ func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) { if err != nil { return req.errorResponseFromJmap(err) } - return response(mailboxes.Mailboxes, mailboxes.State) + return etagResponse(mailboxes.Mailboxes, mailboxes.SessionState, mailboxes.State) } else { mailboxes, err := g.jmap.GetAllMailboxes(req.GetAccountId(), req.session, req.ctx, req.logger) if err != nil { return req.errorResponseFromJmap(err) } - return response(mailboxes.List, mailboxes.State) + return etagResponse(mailboxes.Mailboxes, mailboxes.SessionState, mailboxes.State) } }) } diff --git a/services/groupware/pkg/groupware/groupware_api_messages.go b/services/groupware/pkg/groupware/groupware_api_messages.go index 8e910f3980..201ec25b0d 100644 --- a/services/groupware/pkg/groupware/groupware_api_messages.go +++ b/services/groupware/pkg/groupware/groupware_api_messages.go @@ -28,7 +28,7 @@ func (g Groupware) GetAllMessagesInMailbox(w http.ResponseWriter, r *http.Reques withSource(&ErrorSource{Parameter: UriParamMailboxId}), )) } - logger := &log.Logger{Logger: req.logger.With().Str(HeaderSince, since).Logger()} + logger := log.From(req.logger.With().Str(HeaderSince, since)) emails, jerr := g.jmap.GetEmailsInMailboxSince(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, since, true, g.maxBodyValueBytes, maxChanges) if jerr != nil { @@ -64,7 +64,7 @@ func (g Groupware) GetAllMessagesInMailbox(w http.ResponseWriter, r *http.Reques l = l.Int(QueryParamLimit, limit) } - logger := &log.Logger{Logger: l.Logger()} + logger := log.From(l) emails, jerr := g.jmap.GetAllEmails(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes) if jerr != nil { @@ -82,14 +82,14 @@ func (g Groupware) GetMessagesById(w http.ResponseWriter, r *http.Request) { ids := strings.Split(id, ",") if len(ids) < 1 { errorId := req.errorId() - msg := fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamMessageId, logstr(id), "empty list of mail ids") + msg := fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamMessageId, log.SafeString(id), "empty list of mail ids") return errorResponse(apiError(errorId, ErrorInvalidRequestParameter, withDetail(msg), withSource(&ErrorSource{Parameter: UriParamMessageId}), )) } - logger := &log.Logger{Logger: req.logger.With().Str("id", logstr(id)).Logger()} + logger := log.From(req.logger.With().Str("id", log.SafeString(id))) emails, jerr := g.jmap.GetEmails(req.GetAccountId(), req.session, req.ctx, logger, ids, true, g.maxBodyValueBytes) if jerr != nil { return req.errorResponseFromJmap(jerr) @@ -109,7 +109,7 @@ func (g Groupware) getMessagesSince(w http.ResponseWriter, r *http.Request, sinc if ok { l = l.Int(QueryParamMaxChanges, maxChanges) } - logger := &log.Logger{Logger: l.Logger()} + logger := log.From(l) emails, jerr := g.jmap.GetEmailsSince(req.GetAccountId(), req.session, req.ctx, logger, since, true, g.maxBodyValueBytes, maxChanges) if jerr != nil { @@ -192,31 +192,31 @@ func (g Groupware) buildQuery(req Request) (bool, jmap.EmailFilterElement, int, } if mailboxId != "" { - l = l.Str(QueryParamMailboxId, logstr(mailboxId)) + l = l.Str(QueryParamMailboxId, log.SafeString(mailboxId)) } if len(notInMailboxIds) > 0 { - l = l.Array(QueryParamNotInMailboxId, logstrarray(notInMailboxIds)) + l = l.Array(QueryParamNotInMailboxId, log.SafeStringArray(notInMailboxIds)) } if text != "" { - l = l.Str(QueryParamSearchText, logstr(text)) + l = l.Str(QueryParamSearchText, log.SafeString(text)) } if from != "" { - l = l.Str(QueryParamSearchFrom, logstr(from)) + l = l.Str(QueryParamSearchFrom, log.SafeString(from)) } if to != "" { - l = l.Str(QueryParamSearchTo, logstr(to)) + l = l.Str(QueryParamSearchTo, log.SafeString(to)) } if cc != "" { - l = l.Str(QueryParamSearchCc, logstr(cc)) + l = l.Str(QueryParamSearchCc, log.SafeString(cc)) } if bcc != "" { - l = l.Str(QueryParamSearchBcc, logstr(bcc)) + l = l.Str(QueryParamSearchBcc, log.SafeString(bcc)) } if subject != "" { - l = l.Str(QueryParamSearchSubject, logstr(subject)) + l = l.Str(QueryParamSearchSubject, log.SafeString(subject)) } if body != "" { - l = l.Str(QueryParamSearchBody, logstr(body)) + l = l.Str(QueryParamSearchBody, log.SafeString(body)) } minSize, ok, err := req.parseNumericParam(QueryParamSearchMinSize, 0) @@ -235,7 +235,7 @@ func (g Groupware) buildQuery(req Request) (bool, jmap.EmailFilterElement, int, l = l.Int(QueryParamSearchMaxSize, maxSize) } - logger := &log.Logger{Logger: l.Logger()} + logger := log.From(l) var filter jmap.EmailFilterElement @@ -379,7 +379,7 @@ func (g Groupware) CreateMessage(w http.ResponseWriter, r *http.Request) { l := req.logger.With() l.Str(UriParamMessageId, messageId) - logger := &log.Logger{Logger: l.Logger()} + logger := log.From(l) var body MessageCreation err := req.body(&body) diff --git a/services/groupware/pkg/groupware/groupware_api_vacation.go b/services/groupware/pkg/groupware/groupware_api_vacation.go index e845fbfbc3..a5c1a2e852 100644 --- a/services/groupware/pkg/groupware/groupware_api_vacation.go +++ b/services/groupware/pkg/groupware/groupware_api_vacation.go @@ -40,7 +40,7 @@ func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) { func (g Groupware) SetVacation(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { - var body jmap.VacationResponseBody + var body jmap.VacationResponsePayload err := req.body(&body) if err != nil { return errorResponse(err) diff --git a/services/groupware/pkg/groupware/groupware_docs.go b/services/groupware/pkg/groupware/groupware_docs.go index 66845cb441..6ac1b75fc7 100644 --- a/services/groupware/pkg/groupware/groupware_docs.go +++ b/services/groupware/pkg/groupware/groupware_docs.go @@ -24,3 +24,33 @@ // // swagger:meta package groupware + +// When the request contains invalid parameters. +// swagger:response ErrorResponse400 +type SwaggerErrorResponse400 struct { + // in: body + Body struct { + *ErrorResponse + } +} + +// When the requested object does not exist. +// swagger:response ErrorResponse404 +type SwaggerErrorResponse404 struct { +} + +// When the server was unable to complete the request. +// swagger:response ErrorResponse500 +type SwaggerErrorResponse500 struct { + // in: body + Body struct { + *ErrorResponse + } +} + +// swagger:parameters vacation mailboxes +type SwaggerAccountParams struct { + // The identifier of the account. + // in: path + Account string `json:"account"` +} diff --git a/services/groupware/pkg/groupware/groupware_framework.go b/services/groupware/pkg/groupware/groupware_framework.go index 5570d893ff..e6c3651b8a 100644 --- a/services/groupware/pkg/groupware/groupware_framework.go +++ b/services/groupware/pkg/groupware/groupware_framework.go @@ -36,10 +36,6 @@ const ( logQuery = "query" ) -const ( - logMaxStrLength = 512 -) - type Groupware struct { mux *chi.Mux logger *log.Logger @@ -256,7 +252,7 @@ func (r Request) GetAccount() (jmap.SessionAccount, *Error) { errorId := r.errorId() r.logger.Debug().Msgf("failed to find account '%v'", accountId) return jmap.SessionAccount{}, apiError(errorId, ErrorNonExistingAccount, - withDetail(fmt.Sprintf("The account '%v' does not exist", logstr(accountId))), + withDetail(fmt.Sprintf("The account '%v' does not exist", log.SafeString(accountId))), withSource(&ErrorSource{Parameter: UriParamAccount}), ) } @@ -277,7 +273,7 @@ func (r Request) parseNumericParam(param string, defaultValue int) (int, bool, * 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()) + msg := fmt.Sprintf("Invalid value for query parameter '%v': '%s': %s", param, log.SafeString(str), err.Error()) return defaultValue, true, apiError(errorId, ErrorInvalidRequestParameter, withDetail(msg), withSource(&ErrorSource{Parameter: param}), @@ -300,7 +296,7 @@ func (r Request) parseDateParam(param string) (time.Time, bool, *Error) { t, err := time.Parse(time.RFC3339, str) if err != nil { errorId := r.errorId() - msg := fmt.Sprintf("Invalid RFC3339 value for query parameter '%v': '%s': %s", param, logstr(str), err.Error()) + msg := fmt.Sprintf("Invalid RFC3339 value for query parameter '%v': '%s': %s", param, log.SafeString(str), err.Error()) return time.Time{}, true, apiError(errorId, ErrorInvalidRequestParameter, withDetail(msg), withSource(&ErrorSource{Parameter: param}), @@ -323,7 +319,7 @@ func (r Request) parseBoolParam(param string, defaultValue bool) (bool, bool, *E b, err := strconv.ParseBool(str) if err != nil { errorId := r.errorId() - msg := fmt.Sprintf("Invalid boolean value for query parameter '%v': '%s': %s", param, logstr(str), err.Error()) + msg := fmt.Sprintf("Invalid boolean value for query parameter '%v': '%s': %s", param, log.SafeString(str), err.Error()) return defaultValue, true, apiError(errorId, ErrorInvalidRequestParameter, withDetail(msg), withSource(&ErrorSource{Parameter: param}), @@ -348,34 +344,6 @@ func (r Request) body(target any) *Error { return 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 - } -} - -type SafeLogStringArrayMarshaller struct { - array []string -} - -func (m SafeLogStringArrayMarshaller) MarshalZerologArray(a *zerolog.Array) { - for _, elem := range m.array { - a.Str(logstr(elem)) - } -} - -var _ zerolog.LogArrayMarshaler = SafeLogStringArrayMarshaller{} - -func logstrarray(array []string) SafeLogStringArrayMarshaller { - return SafeLogStringArrayMarshaller{array: array} -} - func (g Groupware) log(error *Error) { var level *zerolog.Event if error.NumStatus < 300 { @@ -397,13 +365,13 @@ func (g Groupware) log(error *Error) { 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)) + l.Str(logErrorSourceHeader, log.SafeString(error.Source.Header)) } if error.Source.Parameter != "" { - l.Str(logErrorSourceParameter, logstr(error.Source.Parameter)) + l.Str(logErrorSourceParameter, log.SafeString(error.Source.Parameter)) } if error.Source.Pointer != "" { - l.Str(logErrorSourcePointer, logstr(error.Source.Pointer)) + l.Str(logErrorSourcePointer, log.SafeString(error.Source.Pointer)) } } l.Msg(error.Title) @@ -422,9 +390,10 @@ func (g Groupware) serveError(w http.ResponseWriter, r *http.Request, error *Err func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(r Request) Response) { ctx := r.Context() - logger := g.logger.SubloggerWithRequestID(ctx) + sl := g.logger.SubloggerWithRequestID(ctx) + logger := &sl - username, ok, err := g.usernameProvider.GetUsername(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 @@ -434,9 +403,9 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func( return } - logger = log.Logger{Logger: logger.With().Str(logUsername, logstr(username)).Logger()} + logger = log.From(logger.With().Str(logUsername, log.SafeString(username))) - session, ok, err := g.session(username, r, ctx, &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) @@ -448,12 +417,12 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func( render.Status(r, http.StatusForbidden) return } - logger = session.DecorateLogger(logger) + decoratedLogger := session.DecorateLogger(*logger) req := Request{ r: r, ctx: ctx, - logger: &logger, + logger: decoratedLogger, session: &session, } @@ -489,9 +458,10 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func( func (g Groupware) stream(w http.ResponseWriter, r *http.Request, handler func(r Request, w http.ResponseWriter) *Error) { ctx := r.Context() - logger := g.logger.SubloggerWithRequestID(ctx) + sl := g.logger.SubloggerWithRequestID(ctx) + logger := &sl - username, ok, err := g.usernameProvider.GetUsername(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 @@ -501,9 +471,9 @@ func (g Groupware) stream(w http.ResponseWriter, r *http.Request, handler func(r return } - logger = log.Logger{Logger: logger.With().Str(logUsername, logstr(username)).Logger()} + logger = log.From(logger.With().Str(logUsername, log.SafeString(username))) - session, ok, err := g.session(username, r, ctx, &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) @@ -515,12 +485,12 @@ func (g Groupware) stream(w http.ResponseWriter, r *http.Request, handler func(r render.Status(r, http.StatusForbidden) return } - logger = session.DecorateLogger(logger) + decoratedLogger := session.DecorateLogger(*logger) req := Request{ r: r, ctx: ctx, - logger: &logger, + logger: decoratedLogger, session: &session, } @@ -534,57 +504,12 @@ func (g Groupware) stream(w http.ResponseWriter, r *http.Request, handler func(r } } -/* -func (g Groupware) withSession(w http.ResponseWriter, r *http.Request, handler func(r Request) (any, string, error)) (any, string, error) { - ctx := r.Context() - logger := g.logger.SubloggerWithRequestID(ctx) - session, ok, err := g.session(r, ctx, &logger) - if err != nil { - logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg("failed to determine JMAP session") - return nil, "", err - } - if !ok { - // no session = authentication failed - logger.Warn().Err(err).Interface(logQuery, r.URL.Query()).Msg("could not authenticate") - return nil, "", err - } - logger = session.DecorateLogger(logger) - - req := Request{ - r: r, - ctx: ctx, - logger: &logger, - session: &session, - } - - response, state, err := handler(req) - if err != nil { - logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg(err.Error()) - } - 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) + path := log.SafeString(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) } - -func uniq[T comparable](ary []T) []T { - m := map[T]bool{} - for _, v := range ary { - m[v] = true - } - set := make([]T, len(m)) - i := 0 - for v := range m { - set[i] = v - i++ - } - return set -} diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go index 3c860eac04..d84c294df4 100644 --- a/services/groupware/pkg/groupware/groupware_route.go +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -5,33 +5,36 @@ import ( ) const ( - UriParamAccount = "accountid" - UriParamMailboxId = "mailbox" - UriParamMessageId = "messageid" - UriParamBlobId = "blobid" - UriParamBlobName = "blobname" - QueryParamBlobType = "type" - QueryParamSince = "since" - QueryParamMaxChanges = "maxchanges" - QueryParamMailboxId = "mailbox" - QueryParamNotInMailboxId = "notmailbox" - QueryParamSearchText = "text" - QueryParamSearchFrom = "from" - QueryParamSearchTo = "to" - QueryParamSearchCc = "cc" - QueryParamSearchBcc = "bcc" - QueryParamSearchSubject = "subject" - QueryParamSearchBody = "body" - QueryParamSearchBefore = "before" - QueryParamSearchAfter = "after" - QueryParamSearchMinSize = "minsize" - QueryParamSearchMaxSize = "maxsize" - QueryParamSearchKeyword = "keyword" - QueryParamSearchFetchBodies = "fetchbodies" - QueryParamSearchFetchEmails = "fetchemails" - QueryParamOffset = "offset" - QueryParamLimit = "limit" - HeaderSince = "if-none-match" + UriParamAccount = "accountid" + UriParamMailboxId = "mailbox" + UriParamMessageId = "messageid" + UriParamBlobId = "blobid" + UriParamBlobName = "blobname" + QueryParamMailboxSearchName = "name" + QueryParamMailboxSearchRole = "role" + QueryParamMailboxSearchSubscribed = "subscribed" + QueryParamBlobType = "type" + QueryParamSince = "since" + QueryParamMaxChanges = "maxchanges" + QueryParamMailboxId = "mailbox" + QueryParamNotInMailboxId = "notmailbox" + QueryParamSearchText = "text" + QueryParamSearchFrom = "from" + QueryParamSearchTo = "to" + QueryParamSearchCc = "cc" + QueryParamSearchBcc = "bcc" + QueryParamSearchSubject = "subject" + QueryParamSearchBody = "body" + QueryParamSearchBefore = "before" + QueryParamSearchAfter = "after" + QueryParamSearchMinSize = "minsize" + QueryParamSearchMaxSize = "maxsize" + QueryParamSearchKeyword = "keyword" + QueryParamSearchFetchBodies = "fetchbodies" + QueryParamSearchFetchEmails = "fetchemails" + QueryParamOffset = "offset" + QueryParamLimit = "limit" + HeaderSince = "if-none-match" ) func (g Groupware) Route(r chi.Router) { @@ -41,7 +44,7 @@ func (g Groupware) Route(r chi.Router) { r.Get("/", g.GetAccount) r.Get("/identities", g.GetIdentities) r.Get("/vacation", g.GetVacation) - r.Post("/vacation", g.SetVacation) + r.Put("/vacation", g.SetVacation) r.Route("/mailboxes", func(r chi.Router) { r.Get("/", g.GetMailboxes) // ?name=&role=&subcribed= r.Get("/{mailbox}", g.GetMailbox)