From e6441e58d42f1d3e33c8d4652a8a0dcebdd0cc62 Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Wed, 6 Aug 2025 17:31:05 +0200 Subject: [PATCH] Groupware: refactor jmap package, implement Email/set, EmailSubmission * refactor the jmap package to split it into several files as the jmap.api.go file was becoming too unwieldy * refactor the Groupware handler function response to be a Response object, to be more future-proof and avoid adding more and more return parameters while handling "no content" response as well * more godoc for the JMAP model * add Email creation, updating, deleting (Email/set, EmailSubmission/set) * add endpoints - POST /accounts/{accountid}/messages - PATCH|PUT /accounts/{accountid}/messages/{messageid} - DELETE /accounts/{accountid}/messages/{messageid} --- pkg/jmap/jmap.go | 787 ------------------ pkg/jmap/jmap_api.go | 18 + pkg/jmap/jmap_api_blob.go | 136 +++ pkg/jmap/jmap_api_email.go | 626 ++++++++++++++ pkg/jmap/jmap_api_identity.go | 22 + pkg/jmap/jmap_api_mailbox.go | 71 ++ pkg/jmap/jmap_api_vacation.go | 22 + pkg/jmap/jmap_client.go | 64 ++ pkg/jmap/jmap_model.go | 373 ++++++++- pkg/jmap/jmap_session.go | 120 +++ .../pkg/groupware/groupware_api_account.go | 10 +- .../pkg/groupware/groupware_api_blob.go | 17 +- .../pkg/groupware/groupware_api_identity.go | 7 +- .../pkg/groupware/groupware_api_index.go | 6 +- .../pkg/groupware/groupware_api_mailbox.go | 18 +- .../pkg/groupware/groupware_api_messages.go | 175 +++- .../pkg/groupware/groupware_api_vacation.go | 7 +- .../pkg/groupware/groupware_error.go | 25 +- .../pkg/groupware/groupware_framework.go | 112 ++- .../pkg/groupware/groupware_route.go | 12 +- 20 files changed, 1751 insertions(+), 877 deletions(-) delete mode 100644 pkg/jmap/jmap.go create mode 100644 pkg/jmap/jmap_api_blob.go create mode 100644 pkg/jmap/jmap_api_email.go create mode 100644 pkg/jmap/jmap_api_identity.go create mode 100644 pkg/jmap/jmap_api_mailbox.go create mode 100644 pkg/jmap/jmap_api_vacation.go create mode 100644 pkg/jmap/jmap_client.go create mode 100644 pkg/jmap/jmap_session.go diff --git a/pkg/jmap/jmap.go b/pkg/jmap/jmap.go deleted file mode 100644 index 933ec12bc5..0000000000 --- a/pkg/jmap/jmap.go +++ /dev/null @@ -1,787 +0,0 @@ -package jmap - -import ( - "context" - "encoding/base64" - "fmt" - "io" - "net/url" - "strings" - - "github.com/opencloud-eu/opencloud/pkg/log" - "github.com/rs/zerolog" -) - -type SessionEventListener interface { - OnSessionOutdated(session *Session) -} - -type Client struct { - wellKnown SessionClient - api ApiClient - blob BlobClient - sessionEventListeners *eventListeners[SessionEventListener] - io.Closer -} - -func (j *Client) Close() error { - return j.api.Close() -} - -func NewClient(wellKnown SessionClient, api ApiClient, blob BlobClient) Client { - return Client{ - wellKnown: wellKnown, - api: api, - blob: blob, - sessionEventListeners: newEventListeners[SessionEventListener](), - } -} - -// Cached user related information -// -// This information is typically retrieved once (or at least for a certain period of time) from the -// JMAP well-known endpoint of Stalwart and then kept in cache to avoid the performance cost of -// retrieving it over and over again. -// -// This is really only needed due to the Graph API limitations, since ideally, the account ID should -// be passed as a request parameter by the UI, in order to support a user having multiple accounts. -// -// Keeping track of the JMAP URL might be useful though, in case of Stalwart sharding strategies making -// use of that, by providing different URLs for JMAP on a per-user basis, and that is not something -// we would want to query before every single JMAP request. On the other hand, that then also creates -// a risk of going out-of-sync, e.g. if a node is down and the user is reassigned to a different node. -// There might be webhooks to subscribe to in Stalwart to be notified of such situations, in which case -// the Session needs to be removed from the cache. -// -// The Username is only here for convenience, it could just as well be passed as a separate parameter -// instead of being part of the Session, since the username is always part of the request (typically in -// the authentication token payload.) -type Session struct { - // The name of the user to use to authenticate against Stalwart - Username string - - // The base URL to use for JMAP operations towards Stalwart - JmapUrl url.URL - - // The upload URL template - UploadUrlTemplate string - - // The upload URL template - DownloadUrlTemplate string - - // TODO - DefaultMailAccountId string - - SessionResponse -} - -// Create a new Session from a SessionResponse. -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")} - } - mailAccountId := sessionResponse.PrimaryAccounts.Mail - if mailAccountId == "" { - return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a primary mail account")} - } - apiStr := sessionResponse.ApiUrl - if apiStr == "" { - return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an API URL")} - } - apiUrl, err := url.Parse(apiStr) - if err != nil { - return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response provides an invalid API URL")} - } - uploadUrl := sessionResponse.UploadUrl - if uploadUrl == "" { - return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an upload URL")} - } - downloadUrl := sessionResponse.DownloadUrl - if downloadUrl == "" { - return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an download URL")} - } - - return Session{ - Username: username, - DefaultMailAccountId: mailAccountId, - JmapUrl: *apiUrl, - UploadUrlTemplate: uploadUrl, - DownloadUrlTemplate: downloadUrl, - SessionResponse: sessionResponse, - }, nil -} - -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 -} - -func (s *Session) BlobAccountId(accountId string) string { - if accountId != "" && accountId != defaultAccountId { - return accountId - } - // TODO(pbleser-oc) handle case where there is no default blob account - return s.PrimaryAccounts.Blob -} - -const ( - logOperation = "operation" - logUsername = "username" - logAccountId = "account-id" - logMailboxId = "mailbox-id" - logFetchBodies = "fetch-bodies" - logOffset = "offset" - logLimit = "limit" - logApiUrl = "apiurl" - logDownloadUrl = "downloadurl" - logBlobId = "blobId" - logUploadUrl = "downloadurl" - logSessionState = "session-state" - logSince = "since" - - defaultAccountId = "*" - - emailSortByReceivedAt = "receivedAt" - emailSortBySize = "size" - emailSortByFrom = "from" - emailSortByTo = "to" - emailSortBySubject = "subject" - emailSortBySentAt = "sentAt" - emailSortByHasKeyword = "hasKeyword" - emailSortByAllInThreadHaveKeyword = "allInThreadHaveKeyword" - emailSortBySomeInThreadHaveKeyword = "someInThreadHaveKeyword" -) - -// 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(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) - }) -} - -// Retrieve JMAP well-known data from the Stalwart server and create a Session from that. -func (j *Client) FetchSession(username string, logger *log.Logger) (Session, Error) { - wk, err := j.wellKnown.GetSession(username, logger) - if err != nil { - return Session{}, err - } - return newSession(wk) -} - -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(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(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, j.onSessionOutdated, cmd, func(body *Response) (IdentityGetResponse, Error) { - var response IdentityGetResponse - err = retrieveResponseMatchParameters(body, IdentityGet, "0", &response) - return response, simpleError(err, JmapErrorInvalidJmapResponsePayload) - }) -} - -// https://jmap.io/spec-mail.html#vacationresponseget -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, j.onSessionOutdated, cmd, func(body *Response) (VacationResponseGetResponse, Error) { - var response VacationResponseGetResponse - err = retrieveResponseMatchParameters(body, VacationResponseGet, "0", &response) - return response, simpleError(err, JmapErrorInvalidJmapResponsePayload) - }) -} - -// 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) { - 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, 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(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(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")) - if err != nil { - return MailboxQueryResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: 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) - }) -} - -type Mailboxes struct { - Mailboxes []Mailbox `json:"mailboxes,omitempty"` - State string `json:"state,omitempty"` -} - -func (j *Client) SearchMailboxes(accountId string, session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterElement) (Mailboxes, Error) { - aid := session.MailAccountId(accountId) - logger = j.logger(aid, "SearchMailboxes", session, logger) - - cmd, err := request( - invocation(MailboxQuery, MailboxQueryCommand{AccountId: aid, Filter: filter}, "0"), - invocation(MailboxGet, MailboxGetRefCommand{ - AccountId: aid, - IdRef: &ResultReference{Name: MailboxQuery, Path: "/ids/*", ResultOf: "0"}, - }, "1"), - ) - if err != nil { - return Mailboxes{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} - } - - 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 { - return Mailboxes{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - return Mailboxes{Mailboxes: response.List, State: body.SessionState}, nil - }) -} - -type Emails struct { - Emails []Email `json:"emails,omitempty"` - State string `json:"state,omitempty"` -} - -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: aid, - Filter: &EmailFilterCondition{InMailbox: mailboxId}, - Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}}, - CollapseThreads: true, - CalculateTotal: false, - } - if offset >= 0 { - query.Position = offset - } - if limit >= 0 { - query.Limit = limit - } - - get := EmailGetRefCommand{ - AccountId: aid, - FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"}, - } - if maxBodyValueBytes >= 0 { - get.MaxBodyValueBytes = maxBodyValueBytes - } - - cmd, err := request( - invocation(EmailQuery, query, "0"), - invocation(EmailGet, get, "1"), - ) - 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, "1", &response) - if err != nil { - return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - return Emails{Emails: response.List, State: body.SessionState}, nil - }) -} - -type EmailsSince struct { - Destroyed []string `json:"destroyed,omitzero"` - HasMoreChanges bool `json:"hasMoreChanges,omitzero"` - NewState string `json:"newState"` - Created []Email `json:"created,omitempty"` - Updated []Email `json:"updated,omitempty"` - State string `json:"state,omitempty"` -} - -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) { - aid := session.MailAccountId(accountId) - logger = j.loggerParams(aid, "GetEmailsInMailboxSince", session, logger, func(z zerolog.Context) zerolog.Context { - return z.Bool(logFetchBodies, fetchBodies).Str(logSince, since) - }) - - changes := MailboxChangesCommand{ - AccountId: aid, - SinceState: since, - } - if maxChanges >= 0 { - changes.MaxChanges = maxChanges - } - - getCreated := EmailGetRefCommand{ - AccountId: aid, - FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: MailboxChanges, Path: "/created", ResultOf: "0"}, - } - if maxBodyValueBytes >= 0 { - getCreated.MaxBodyValueBytes = maxBodyValueBytes - } - getUpdated := EmailGetRefCommand{ - AccountId: aid, - FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: MailboxChanges, Path: "/updated", ResultOf: "0"}, - } - if maxBodyValueBytes >= 0 { - getUpdated.MaxBodyValueBytes = maxBodyValueBytes - } - - cmd, err := request( - invocation(MailboxChanges, changes, "0"), - invocation(EmailGet, getCreated, "1"), - invocation(EmailGet, getUpdated, "2"), - ) - if err != nil { - return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} - } - - return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailsSince, Error) { - var mailboxResponse MailboxChangesResponse - err = retrieveResponseMatchParameters(body, MailboxChanges, "0", &mailboxResponse) - if err != nil { - return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - - var createdResponse EmailGetResponse - err = retrieveResponseMatchParameters(body, EmailGet, "1", &createdResponse) - if err != nil { - return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - - var updatedResponse EmailGetResponse - err = retrieveResponseMatchParameters(body, EmailGet, "2", &updatedResponse) - if err != nil { - return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - - return EmailsSince{ - Destroyed: mailboxResponse.Destroyed, - HasMoreChanges: mailboxResponse.HasMoreChanges, - NewState: mailboxResponse.NewState, - Created: createdResponse.List, - Updated: createdResponse.List, - State: body.SessionState, - }, nil - }) -} - -func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context.Context, logger *log.Logger, since string, fetchBodies bool, maxBodyValueBytes int, maxChanges int) (EmailsSince, Error) { - aid := session.MailAccountId(accountId) - logger = j.loggerParams(aid, "GetEmailsSince", session, logger, func(z zerolog.Context) zerolog.Context { - return z.Bool(logFetchBodies, fetchBodies).Str(logSince, since) - }) - - changes := EmailChangesCommand{ - AccountId: aid, - SinceState: since, - } - if maxChanges >= 0 { - changes.MaxChanges = maxChanges - } - - getCreated := EmailGetRefCommand{ - AccountId: aid, - FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: EmailChanges, Path: "/created", ResultOf: "0"}, - } - if maxBodyValueBytes >= 0 { - getCreated.MaxBodyValueBytes = maxBodyValueBytes - } - getUpdated := EmailGetRefCommand{ - AccountId: aid, - FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: EmailChanges, Path: "/updated", ResultOf: "0"}, - } - if maxBodyValueBytes >= 0 { - getUpdated.MaxBodyValueBytes = maxBodyValueBytes - } - - cmd, err := request( - invocation(EmailChanges, changes, "0"), - invocation(EmailGet, getCreated, "1"), - invocation(EmailGet, getUpdated, "2"), - ) - if err != nil { - return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} - } - - return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailsSince, Error) { - var changesResponse EmailChangesResponse - err = retrieveResponseMatchParameters(body, EmailChanges, "0", &changesResponse) - if err != nil { - return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - - var createdResponse EmailGetResponse - err = retrieveResponseMatchParameters(body, EmailGet, "1", &createdResponse) - if err != nil { - return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - - var updatedResponse EmailGetResponse - err = retrieveResponseMatchParameters(body, EmailGet, "2", &updatedResponse) - if err != nil { - return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - - return EmailsSince{ - Destroyed: changesResponse.Destroyed, - HasMoreChanges: changesResponse.HasMoreChanges, - NewState: changesResponse.NewState, - Created: createdResponse.List, - Updated: createdResponse.List, - State: body.SessionState, - }, nil - }) -} - -type EmailQueryResult struct { - Snippets []SearchSnippet `json:"snippets,omitempty"` - QueryState string `json:"queryState"` - Total int `json:"total"` - Limit int `json:"limit,omitzero"` - Position int `json:"position,omitzero"` -} - -func (j *Client) QueryEmails(accountId string, filter EmailFilterElement, session *Session, ctx context.Context, logger *log.Logger, offset int, limit int, fetchBodies bool, maxBodyValueBytes int) (EmailQueryResult, Error) { - aid := session.MailAccountId(accountId) - logger = j.loggerParams(aid, "QueryEmails", session, logger, func(z zerolog.Context) zerolog.Context { - return z.Bool(logFetchBodies, fetchBodies) - }) - - query := EmailQueryCommand{ - AccountId: aid, - Filter: filter, - Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}}, - CollapseThreads: true, - CalculateTotal: true, - } - if offset >= 0 { - query.Position = offset - } - if limit >= 0 { - query.Limit = limit - } - - snippet := SearchSnippetRefCommand{ - AccountId: aid, - Filter: filter, - EmailIdRef: &ResultReference{ - ResultOf: "0", - Name: EmailQuery, - Path: "/ids/*", - }, - } - - cmd, err := request( - invocation(EmailQuery, query, "0"), - invocation(SearchSnippetGet, snippet, "1"), - ) - - if err != nil { - return EmailQueryResult{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} - } - - return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailQueryResult, Error) { - var queryResponse EmailQueryResponse - err = retrieveResponseMatchParameters(body, EmailQuery, "0", &queryResponse) - if err != nil { - return EmailQueryResult{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - - var snippetResponse SearchSnippetGetResponse - err = retrieveResponseMatchParameters(body, SearchSnippetGet, "1", &snippetResponse) - if err != nil { - return EmailQueryResult{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - - return EmailQueryResult{ - Snippets: snippetResponse.List, - QueryState: queryResponse.QueryState, - Total: queryResponse.Total, - Limit: queryResponse.Limit, - Position: queryResponse.Position, - }, nil - }) - -} - -func (j *Client) GetBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, id string) (*Blob, Error) { - aid := session.BlobAccountId(accountId) - - cmd, err := request( - invocation(BlobUpload, BlobGetCommand{ - AccountId: aid, - Ids: []string{id}, - Properties: []string{BlobPropertyData, BlobPropertyDigestSha512, BlobPropertySize}, - }, "0"), - ) - if err != nil { - return nil, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} - } - - return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (*Blob, Error) { - var response BlobGetResponse - err = retrieveResponseMatchParameters(body, BlobGet, "0", &response) - if err != nil { - return nil, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - - if len(response.List) != 1 { - return nil, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - get := response.List[0] - return &get, nil - }) -} - -type UploadedBlob struct { - Id string `json:"id"` - Size int `json:"size"` - Type string `json:"type"` - Sha512 string `json:"sha:512"` -} - -func (j *Client) UploadBlobStream(accountId string, session *Session, ctx context.Context, logger *log.Logger, contentType string, body io.Reader) (UploadedBlob, Error) { - aid := session.BlobAccountId(accountId) - // TODO(pbleser-oc) use a library for proper URL template parsing - uploadUrl := strings.ReplaceAll(session.UploadUrlTemplate, "{accountId}", aid) - return j.blob.UploadBinary(ctx, logger, session, uploadUrl, contentType, body) -} - -func (j *Client) DownloadBlobStream(accountId string, blobId string, name string, typ string, session *Session, ctx context.Context, logger *log.Logger) (*BlobDownload, Error) { - aid := session.BlobAccountId(accountId) - // TODO(pbleser-oc) use a library for proper URL template parsing - downloadUrl := session.DownloadUrlTemplate - downloadUrl = strings.ReplaceAll(downloadUrl, "{accountId}", aid) - 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()} - return j.blob.DownloadBinary(ctx, logger, session, downloadUrl) -} - -func (j *Client) UploadBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte, contentType string) (UploadedBlob, Error) { - aid := session.MailAccountId(accountId) - - encoded := base64.StdEncoding.EncodeToString(data) - - upload := BlobUploadCommand{ - AccountId: aid, - Create: map[string]UploadObject{ - "0": { - Data: []DataSourceObject{{ - DataAsBase64: encoded, - }}, - Type: contentType, - }, - }, - } - - getHash := BlobGetRefCommand{ - AccountId: aid, - IdRef: &ResultReference{ - ResultOf: "0", - Name: BlobUpload, - Path: "/ids", - }, - Properties: []string{BlobPropertyDigestSha512}, - } - - cmd, err := request( - invocation(BlobUpload, upload, "0"), - invocation(BlobGet, getHash, "1"), - ) - if err != nil { - return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} - } - - return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UploadedBlob, Error) { - var uploadResponse BlobUploadResponse - err = retrieveResponseMatchParameters(body, BlobUpload, "0", &uploadResponse) - if err != nil { - return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - - var getResponse BlobGetResponse - err = retrieveResponseMatchParameters(body, BlobGet, "1", &getResponse) - if err != nil { - return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - - if len(uploadResponse.Created) != 1 { - return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - upload, ok := uploadResponse.Created["0"] - if !ok { - return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - - if len(getResponse.List) != 1 { - return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - get := getResponse.List[0] - - return UploadedBlob{ - Id: upload.Id, - Size: upload.Size, - Type: upload.Type, - Sha512: get.DigestSha512, - }, nil - }) - -} - -func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte) (UploadedBlob, Error) { - aid := session.MailAccountId(accountId) - - encoded := base64.StdEncoding.EncodeToString(data) - - upload := BlobUploadCommand{ - AccountId: aid, - Create: map[string]UploadObject{ - "0": { - Data: []DataSourceObject{{ - DataAsBase64: encoded, - }}, - Type: EmailMimeType, - }, - }, - } - - getHash := BlobGetRefCommand{ - AccountId: aid, - IdRef: &ResultReference{ - ResultOf: "0", - Name: BlobUpload, - Path: "/ids", - }, - Properties: []string{BlobPropertyDigestSha512}, - } - - cmd, err := request( - invocation(BlobUpload, upload, "0"), - invocation(BlobGet, getHash, "1"), - ) - if err != nil { - return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} - } - - return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UploadedBlob, Error) { - var uploadResponse BlobUploadResponse - err = retrieveResponseMatchParameters(body, BlobUpload, "0", &uploadResponse) - if err != nil { - return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - - var getResponse BlobGetResponse - err = retrieveResponseMatchParameters(body, BlobGet, "1", &getResponse) - if err != nil { - return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - - if len(uploadResponse.Created) != 1 { - return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - upload, ok := uploadResponse.Created["0"] - if !ok { - return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - - if len(getResponse.List) != 1 { - return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} - } - get := getResponse.List[0] - - return UploadedBlob{ - Id: upload.Id, - Size: upload.Size, - Type: upload.Type, - Sha512: get.DigestSha512, - }, nil - }) - -} diff --git a/pkg/jmap/jmap_api.go b/pkg/jmap/jmap_api.go index e9d93618df..bf996e1dc1 100644 --- a/pkg/jmap/jmap_api.go +++ b/pkg/jmap/jmap_api.go @@ -20,3 +20,21 @@ type BlobClient interface { UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, contentType string, content io.Reader) (UploadedBlob, Error) DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string) (*BlobDownload, Error) } + +const ( + logOperation = "operation" + logUsername = "username" + logAccountId = "account-id" + logMailboxId = "mailbox-id" + logFetchBodies = "fetch-bodies" + logOffset = "offset" + logLimit = "limit" + logApiUrl = "apiurl" + logDownloadUrl = "downloadurl" + logBlobId = "blobId" + logUploadUrl = "downloadurl" + logSessionState = "session-state" + logSince = "since" + + defaultAccountId = "*" +) diff --git a/pkg/jmap/jmap_api_blob.go b/pkg/jmap/jmap_api_blob.go new file mode 100644 index 0000000000..e7406d5206 --- /dev/null +++ b/pkg/jmap/jmap_api_blob.go @@ -0,0 +1,136 @@ +package jmap + +import ( + "context" + "encoding/base64" + "io" + "strings" + + "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) { + aid := session.BlobAccountId(accountId) + + cmd, err := request( + invocation(BlobUpload, BlobGetCommand{ + AccountId: aid, + Ids: []string{id}, + Properties: []string{BlobPropertyData, BlobPropertyDigestSha512, BlobPropertySize}, + }, "0"), + ) + if err != nil { + return nil, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} + } + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (*Blob, Error) { + var response BlobGetResponse + err = retrieveResponseMatchParameters(body, BlobGet, "0", &response) + if err != nil { + return nil, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + if len(response.List) != 1 { + return nil, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + get := response.List[0] + return &get, nil + }) +} + +type UploadedBlob struct { + Id string `json:"id"` + Size int `json:"size"` + Type string `json:"type"` + Sha512 string `json:"sha:512"` +} + +func (j *Client) UploadBlobStream(accountId string, session *Session, ctx context.Context, logger *log.Logger, contentType string, body io.Reader) (UploadedBlob, Error) { + aid := session.BlobAccountId(accountId) + // TODO(pbleser-oc) use a library for proper URL template parsing + uploadUrl := strings.ReplaceAll(session.UploadUrlTemplate, "{accountId}", aid) + return j.blob.UploadBinary(ctx, logger, session, uploadUrl, contentType, body) +} + +func (j *Client) DownloadBlobStream(accountId string, blobId string, name string, typ string, session *Session, ctx context.Context, logger *log.Logger) (*BlobDownload, Error) { + aid := session.BlobAccountId(accountId) + // TODO(pbleser-oc) use a library for proper URL template parsing + downloadUrl := session.DownloadUrlTemplate + downloadUrl = strings.ReplaceAll(downloadUrl, "{accountId}", aid) + 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()} + return j.blob.DownloadBinary(ctx, logger, session, downloadUrl) +} + +func (j *Client) UploadBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte, contentType string) (UploadedBlob, Error) { + aid := session.MailAccountId(accountId) + + encoded := base64.StdEncoding.EncodeToString(data) + + upload := BlobUploadCommand{ + AccountId: aid, + Create: map[string]UploadObject{ + "0": { + Data: []DataSourceObject{{ + DataAsBase64: encoded, + }}, + Type: contentType, + }, + }, + } + + getHash := BlobGetRefCommand{ + AccountId: aid, + IdRef: &ResultReference{ + ResultOf: "0", + Name: BlobUpload, + Path: "/ids", + }, + Properties: []string{BlobPropertyDigestSha512}, + } + + cmd, err := request( + invocation(BlobUpload, upload, "0"), + invocation(BlobGet, getHash, "1"), + ) + if err != nil { + return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} + } + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UploadedBlob, Error) { + var uploadResponse BlobUploadResponse + err = retrieveResponseMatchParameters(body, BlobUpload, "0", &uploadResponse) + if err != nil { + return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + var getResponse BlobGetResponse + err = retrieveResponseMatchParameters(body, BlobGet, "1", &getResponse) + if err != nil { + return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + if len(uploadResponse.Created) != 1 { + return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + upload, ok := uploadResponse.Created["0"] + if !ok { + return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + if len(getResponse.List) != 1 { + return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + get := getResponse.List[0] + + return UploadedBlob{ + Id: upload.Id, + Size: upload.Size, + Type: upload.Type, + Sha512: get.DigestSha512, + }, nil + }) + +} diff --git a/pkg/jmap/jmap_api_email.go b/pkg/jmap/jmap_api_email.go new file mode 100644 index 0000000000..b3a4d98eb4 --- /dev/null +++ b/pkg/jmap/jmap_api_email.go @@ -0,0 +1,626 @@ +package jmap + +import ( + "context" + "encoding/base64" + "time" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/rs/zerolog" +) + +const ( + emailSortByReceivedAt = "receivedAt" + emailSortBySize = "size" + emailSortByFrom = "from" + emailSortByTo = "to" + emailSortBySubject = "subject" + emailSortBySentAt = "sentAt" + emailSortByHasKeyword = "hasKeyword" + emailSortByAllInThreadHaveKeyword = "allInThreadHaveKeyword" + emailSortBySomeInThreadHaveKeyword = "someInThreadHaveKeyword" +) + +type Emails struct { + Emails []Email `json:"emails,omitempty"` + State string `json:"state,omitempty"` +} + +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: aid, + Filter: &EmailFilterCondition{InMailbox: mailboxId}, + Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}}, + CollapseThreads: true, + CalculateTotal: false, + } + if offset >= 0 { + query.Position = offset + } + if limit >= 0 { + query.Limit = limit + } + + get := EmailGetRefCommand{ + AccountId: aid, + FetchAllBodyValues: fetchBodies, + IdRef: &ResultReference{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"}, + } + if maxBodyValueBytes >= 0 { + get.MaxBodyValueBytes = maxBodyValueBytes + } + + cmd, err := request( + invocation(EmailQuery, query, "0"), + invocation(EmailGet, get, "1"), + ) + 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, "1", &response) + if err != nil { + return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + return Emails{Emails: response.List, State: body.SessionState}, nil + }) +} + +type EmailsSince struct { + Destroyed []string `json:"destroyed,omitzero"` + HasMoreChanges bool `json:"hasMoreChanges,omitzero"` + NewState string `json:"newState"` + Created []Email `json:"created,omitempty"` + Updated []Email `json:"updated,omitempty"` + State string `json:"state,omitempty"` +} + +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) { + aid := session.MailAccountId(accountId) + logger = j.loggerParams(aid, "GetEmailsInMailboxSince", session, logger, func(z zerolog.Context) zerolog.Context { + return z.Bool(logFetchBodies, fetchBodies).Str(logSince, since) + }) + + changes := MailboxChangesCommand{ + AccountId: aid, + SinceState: since, + } + if maxChanges >= 0 { + changes.MaxChanges = maxChanges + } + + getCreated := EmailGetRefCommand{ + AccountId: aid, + FetchAllBodyValues: fetchBodies, + IdRef: &ResultReference{Name: MailboxChanges, Path: "/created", ResultOf: "0"}, + } + if maxBodyValueBytes >= 0 { + getCreated.MaxBodyValueBytes = maxBodyValueBytes + } + getUpdated := EmailGetRefCommand{ + AccountId: aid, + FetchAllBodyValues: fetchBodies, + IdRef: &ResultReference{Name: MailboxChanges, Path: "/updated", ResultOf: "0"}, + } + if maxBodyValueBytes >= 0 { + getUpdated.MaxBodyValueBytes = maxBodyValueBytes + } + + cmd, err := request( + invocation(MailboxChanges, changes, "0"), + invocation(EmailGet, getCreated, "1"), + invocation(EmailGet, getUpdated, "2"), + ) + if err != nil { + return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} + } + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailsSince, Error) { + var mailboxResponse MailboxChangesResponse + err = retrieveResponseMatchParameters(body, MailboxChanges, "0", &mailboxResponse) + if err != nil { + return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + var createdResponse EmailGetResponse + err = retrieveResponseMatchParameters(body, EmailGet, "1", &createdResponse) + if err != nil { + return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + var updatedResponse EmailGetResponse + err = retrieveResponseMatchParameters(body, EmailGet, "2", &updatedResponse) + if err != nil { + return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + return EmailsSince{ + Destroyed: mailboxResponse.Destroyed, + HasMoreChanges: mailboxResponse.HasMoreChanges, + NewState: mailboxResponse.NewState, + Created: createdResponse.List, + Updated: createdResponse.List, + State: body.SessionState, + }, nil + }) +} + +func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context.Context, logger *log.Logger, since string, fetchBodies bool, maxBodyValueBytes int, maxChanges int) (EmailsSince, Error) { + aid := session.MailAccountId(accountId) + logger = j.loggerParams(aid, "GetEmailsSince", session, logger, func(z zerolog.Context) zerolog.Context { + return z.Bool(logFetchBodies, fetchBodies).Str(logSince, since) + }) + + changes := EmailChangesCommand{ + AccountId: aid, + SinceState: since, + } + if maxChanges >= 0 { + changes.MaxChanges = maxChanges + } + + getCreated := EmailGetRefCommand{ + AccountId: aid, + FetchAllBodyValues: fetchBodies, + IdRef: &ResultReference{Name: EmailChanges, Path: "/created", ResultOf: "0"}, + } + if maxBodyValueBytes >= 0 { + getCreated.MaxBodyValueBytes = maxBodyValueBytes + } + getUpdated := EmailGetRefCommand{ + AccountId: aid, + FetchAllBodyValues: fetchBodies, + IdRef: &ResultReference{Name: EmailChanges, Path: "/updated", ResultOf: "0"}, + } + if maxBodyValueBytes >= 0 { + getUpdated.MaxBodyValueBytes = maxBodyValueBytes + } + + cmd, err := request( + invocation(EmailChanges, changes, "0"), + invocation(EmailGet, getCreated, "1"), + invocation(EmailGet, getUpdated, "2"), + ) + if err != nil { + return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} + } + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailsSince, Error) { + var changesResponse EmailChangesResponse + err = retrieveResponseMatchParameters(body, EmailChanges, "0", &changesResponse) + if err != nil { + return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + var createdResponse EmailGetResponse + err = retrieveResponseMatchParameters(body, EmailGet, "1", &createdResponse) + if err != nil { + return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + var updatedResponse EmailGetResponse + err = retrieveResponseMatchParameters(body, EmailGet, "2", &updatedResponse) + if err != nil { + return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + return EmailsSince{ + Destroyed: changesResponse.Destroyed, + HasMoreChanges: changesResponse.HasMoreChanges, + NewState: changesResponse.NewState, + Created: createdResponse.List, + Updated: createdResponse.List, + State: body.SessionState, + }, nil + }) +} + +type EmailQueryResult struct { + Snippets []SearchSnippet `json:"snippets,omitempty"` + QueryState string `json:"queryState"` + Total int `json:"total"` + Limit int `json:"limit,omitzero"` + Position int `json:"position,omitzero"` + SessionState string `json:"sessionState,omitempty"` +} + +func (j *Client) QueryEmails(accountId string, filter EmailFilterElement, session *Session, ctx context.Context, logger *log.Logger, offset int, limit int, fetchBodies bool, maxBodyValueBytes int) (EmailQueryResult, Error) { + aid := session.MailAccountId(accountId) + logger = j.loggerParams(aid, "QueryEmails", session, logger, func(z zerolog.Context) zerolog.Context { + return z.Bool(logFetchBodies, fetchBodies) + }) + + query := EmailQueryCommand{ + AccountId: aid, + Filter: filter, + Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}}, + CollapseThreads: true, + CalculateTotal: true, + } + if offset >= 0 { + query.Position = offset + } + if limit >= 0 { + query.Limit = limit + } + + snippet := SearchSnippetRefCommand{ + AccountId: aid, + Filter: filter, + EmailIdRef: &ResultReference{ + ResultOf: "0", + Name: EmailQuery, + Path: "/ids/*", + }, + } + + cmd, err := request( + invocation(EmailQuery, query, "0"), + invocation(SearchSnippetGet, snippet, "1"), + ) + + if err != nil { + return EmailQueryResult{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} + } + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailQueryResult, Error) { + var queryResponse EmailQueryResponse + err = retrieveResponseMatchParameters(body, EmailQuery, "0", &queryResponse) + if err != nil { + return EmailQueryResult{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + var snippetResponse SearchSnippetGetResponse + err = retrieveResponseMatchParameters(body, SearchSnippetGet, "1", &snippetResponse) + if err != nil { + return EmailQueryResult{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + return EmailQueryResult{ + Snippets: snippetResponse.List, + QueryState: queryResponse.QueryState, + Total: queryResponse.Total, + Limit: queryResponse.Limit, + Position: queryResponse.Position, + SessionState: body.SessionState, + }, nil + }) + +} + +type UploadedEmail struct { + Id string `json:"id"` + Size int `json:"size"` + Type string `json:"type"` + Sha512 string `json:"sha:512"` +} + +func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte) (UploadedEmail, Error) { + aid := session.MailAccountId(accountId) + + encoded := base64.StdEncoding.EncodeToString(data) + + upload := BlobUploadCommand{ + AccountId: aid, + Create: map[string]UploadObject{ + "0": { + Data: []DataSourceObject{{ + DataAsBase64: encoded, + }}, + Type: EmailMimeType, + }, + }, + } + + getHash := BlobGetRefCommand{ + AccountId: aid, + IdRef: &ResultReference{ + ResultOf: "0", + Name: BlobUpload, + Path: "/ids", + }, + Properties: []string{BlobPropertyDigestSha512}, + } + + cmd, err := request( + invocation(BlobUpload, upload, "0"), + invocation(BlobGet, getHash, "1"), + ) + if err != nil { + return UploadedEmail{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} + } + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UploadedEmail, Error) { + var uploadResponse BlobUploadResponse + err = retrieveResponseMatchParameters(body, BlobUpload, "0", &uploadResponse) + if err != nil { + return UploadedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + var getResponse BlobGetResponse + err = retrieveResponseMatchParameters(body, BlobGet, "1", &getResponse) + if err != nil { + return UploadedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + if len(uploadResponse.Created) != 1 { + return UploadedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + upload, ok := uploadResponse.Created["0"] + if !ok { + return UploadedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + if len(getResponse.List) != 1 { + return UploadedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + get := getResponse.List[0] + + return UploadedEmail{ + Id: upload.Id, + Size: upload.Size, + Type: upload.Type, + Sha512: get.DigestSha512, + }, nil + }) + +} + +type CreatedEmail struct { + Email Email `json:"email"` + State string `json:"state"` +} + +func (j *Client) CreateEmail(accountId string, email EmailCreate, session *Session, ctx context.Context, logger *log.Logger) (CreatedEmail, Error) { + aid := session.MailAccountId(accountId) + + cmd, err := request( + invocation(EmailSubmissionSet, EmailSetCommand{ + AccountId: aid, + Create: map[string]EmailCreate{ + "c": email, + }, + }, "0"), + ) + if err != nil { + return CreatedEmail{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} + } + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (CreatedEmail, Error) { + var setResponse EmailSetResponse + err = retrieveResponseMatchParameters(body, EmailSet, "0", &setResponse) + if err != nil { + return CreatedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + if len(setResponse.NotCreated) > 0 { + // error occured + // TODO(pbleser-oc) handle submission errors + } + + created, ok := setResponse.Created["c"] + if !ok { + // failed to create? + // TODO(pbleser-oc) handle email creation failure + } + + return CreatedEmail{ + Email: created, + State: setResponse.NewState, + }, nil + }) +} + +type UpdatedEmails struct { + Updated map[string]Email `json:"email"` + State string `json:"state"` +} + +// The Email/set method encompasses: +// - Changing the keywords of an Email (e.g., unread/flagged status) +// - Adding/removing an Email to/from Mailboxes (moving a message) +// - Deleting Emails +// +// To create drafts, use the CreateEmail function instead. +// +// To delete mails, use the DeleteEmails function instead. +func (j *Client) UpdateEmails(accountId string, updates map[string]EmailUpdate, session *Session, ctx context.Context, logger *log.Logger) (UpdatedEmails, Error) { + aid := session.MailAccountId(accountId) + + cmd, err := request( + invocation(EmailSet, EmailSetCommand{ + AccountId: aid, + Update: updates, + }, "0"), + ) + if err != nil { + return UpdatedEmails{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} + } + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UpdatedEmails, Error) { + var setResponse EmailSetResponse + err = retrieveResponseMatchParameters(body, EmailSet, "0", &setResponse) + if err != nil { + return UpdatedEmails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + if len(setResponse.NotUpdated) != len(updates) { + // error occured + // TODO(pbleser-oc) handle submission errors + } + return UpdatedEmails{ + Updated: setResponse.Updated, + State: setResponse.NewState, + }, nil + }) +} + +type DeletedEmails struct { + State string `json:"state"` +} + +func (j *Client) DeleteEmails(accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger) (DeletedEmails, Error) { + aid := session.MailAccountId(accountId) + + cmd, err := request( + invocation(EmailSet, EmailSetCommand{ + AccountId: aid, + Destroy: destroy, + }, "0"), + ) + if err != nil { + return DeletedEmails{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} + } + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (DeletedEmails, Error) { + var setResponse EmailSetResponse + err = retrieveResponseMatchParameters(body, EmailSet, "0", &setResponse) + if err != nil { + return DeletedEmails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + if len(setResponse.NotDestroyed) != len(destroy) { + // error occured + // TODO(pbleser-oc) handle submission errors + } + return DeletedEmails{State: setResponse.NewState}, nil + }) +} + +type SubmittedEmail struct { + Id string `json:"id"` + State string `json:"state"` + SendAt time.Time `json:"sendAt,omitzero"` + ThreadId string `json:"threadId,omitempty"` + UndoStatus EmailSubmissionUndoStatus `json:"undoStatus,omitempty"` + Envelope Envelope `json:"envelope,omitempty"` + + // A list of blob ids for DSNs [RFC3464] received for this submission, + // in order of receipt, oldest first. + // + // The blob is the whole MIME message (with a top-level content-type of multipart/report), as received. + // + // [RFC3464]: https://datatracker.ietf.org/doc/html/rfc3464 + DsnBlobIds []string `json:"dsnBlobIds,omitempty"` + + // A list of blob ids for MDNs [RFC8098] received for this submission, + // in order of receipt, oldest first. + // + // The blob is the whole MIME message (with a top-level content-type of multipart/report), as received. + // + // [RFC8098]: https://datatracker.ietf.org/doc/html/rfc8098 + MdnBlobIds []string `json:"mdnBlobIds,omitempty"` +} + +func (j *Client) SubmitEmail(accountId string, identityId string, emailId string, session *Session, ctx context.Context, logger *log.Logger, data []byte) (SubmittedEmail, Error) { + aid := session.SubmissionAccountId(accountId) + + set := EmailSubmissionSetCommand{ + AccountId: aid, + Create: map[string]EmailSubmissionCreate{ + "s0": { + IdentityId: identityId, + EmailId: emailId, + }, + }, + OnSuccessUpdateEmail: map[string]PatchObject{ + "#s0": { + "keywords/" + JmapKeywordDraft: nil, + }, + }, + } + + get := EmailSubmissionGetRefCommand{ + AccountId: aid, + IdRef: &ResultReference{ + ResultOf: "0", + Name: EmailSubmissionSet, + Path: "/created/s0/id", + }, + } + + cmd, err := request( + invocation(EmailSubmissionSet, set, "0"), + invocation(EmailSubmissionGet, get, "1"), + ) + if err != nil { + return SubmittedEmail{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} + } + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (SubmittedEmail, Error) { + var submissionResponse EmailSubmissionSetResponse + err = retrieveResponseMatchParameters(body, EmailSubmissionSet, "0", &submissionResponse) + if err != nil { + return SubmittedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + if len(submissionResponse.NotCreated) > 0 { + // error occured + // TODO(pbleser-oc) handle submission errors + } + + // there is an implicit Email/set response: + // "After all create/update/destroy items in the EmailSubmission/set invocation have been processed, + // a single implicit Email/set call MUST be made to perform any changes requested in these two arguments. + // The response to this MUST be returned after the EmailSubmission/set response." + // from an example in the spec, it has the same tag as the EmailSubmission/set command ("0" in this case) + var setResponse EmailSetResponse + err = retrieveResponseMatchParameters(body, EmailSet, "0", &setResponse) + if err != nil { + return SubmittedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + var getResponse EmailSubmissionGetResponse + err = retrieveResponseMatchParameters(body, EmailSubmissionGet, "1", &getResponse) + if err != nil { + return SubmittedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + + if len(getResponse.List) != 1 { + // for some reason (error?)... + // TODO(pbleser-oc) handle absence of emailsubmission + } + + 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, + }, nil + }) + +} diff --git a/pkg/jmap/jmap_api_identity.go b/pkg/jmap/jmap_api_identity.go new file mode 100644 index 0000000000..84c0b342f1 --- /dev/null +++ b/pkg/jmap/jmap_api_identity.go @@ -0,0 +1,22 @@ +package jmap + +import ( + "context" + + "github.com/opencloud-eu/opencloud/pkg/log" +) + +// https://jmap.io/spec-mail.html#identityget +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, j.onSessionOutdated, cmd, func(body *Response) (IdentityGetResponse, Error) { + var response IdentityGetResponse + err = retrieveResponseMatchParameters(body, IdentityGet, "0", &response) + return response, simpleError(err, JmapErrorInvalidJmapResponsePayload) + }) +} diff --git a/pkg/jmap/jmap_api_mailbox.go b/pkg/jmap/jmap_api_mailbox.go new file mode 100644 index 0000000000..34efb0a231 --- /dev/null +++ b/pkg/jmap/jmap_api_mailbox.go @@ -0,0 +1,71 @@ +package jmap + +import ( + "context" + + "github.com/opencloud-eu/opencloud/pkg/log" +) + +// 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) { + 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, 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(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(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")) + if err != nil { + return MailboxQueryResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: 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) + }) +} + +type Mailboxes struct { + Mailboxes []Mailbox `json:"mailboxes,omitempty"` + State string `json:"state,omitempty"` +} + +func (j *Client) SearchMailboxes(accountId string, session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterElement) (Mailboxes, Error) { + aid := session.MailAccountId(accountId) + logger = j.logger(aid, "SearchMailboxes", session, logger) + + cmd, err := request( + invocation(MailboxQuery, MailboxQueryCommand{AccountId: aid, Filter: filter}, "0"), + invocation(MailboxGet, MailboxGetRefCommand{ + AccountId: aid, + IdRef: &ResultReference{Name: MailboxQuery, Path: "/ids/*", ResultOf: "0"}, + }, "1"), + ) + if err != nil { + return Mailboxes{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} + } + + 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 { + return Mailboxes{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} + } + return Mailboxes{Mailboxes: response.List, State: body.SessionState}, nil + }) +} diff --git a/pkg/jmap/jmap_api_vacation.go b/pkg/jmap/jmap_api_vacation.go new file mode 100644 index 0000000000..a9e3936058 --- /dev/null +++ b/pkg/jmap/jmap_api_vacation.go @@ -0,0 +1,22 @@ +package jmap + +import ( + "context" + + "github.com/opencloud-eu/opencloud/pkg/log" +) + +// https://jmap.io/spec-mail.html#vacationresponseget +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, j.onSessionOutdated, cmd, func(body *Response) (VacationResponseGetResponse, Error) { + var response VacationResponseGetResponse + err = retrieveResponseMatchParameters(body, VacationResponseGet, "0", &response) + return response, simpleError(err, JmapErrorInvalidJmapResponsePayload) + }) +} diff --git a/pkg/jmap/jmap_client.go b/pkg/jmap/jmap_client.go new file mode 100644 index 0000000000..d47d69ea94 --- /dev/null +++ b/pkg/jmap/jmap_client.go @@ -0,0 +1,64 @@ +package jmap + +import ( + "io" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/rs/zerolog" +) + +type Client struct { + wellKnown SessionClient + api ApiClient + blob BlobClient + sessionEventListeners *eventListeners[SessionEventListener] + io.Closer +} + +func (j *Client) Close() error { + return j.api.Close() +} + +func NewClient(wellKnown SessionClient, api ApiClient, blob BlobClient) Client { + return Client{ + wellKnown: wellKnown, + api: api, + blob: blob, + sessionEventListeners: newEventListeners[SessionEventListener](), + } +} + +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) + }) +} + +// Retrieve JMAP well-known data from the Stalwart server and create a Session from that. +func (j *Client) FetchSession(username string, logger *log.Logger) (Session, Error) { + wk, err := j.wellKnown.GetSession(username, logger) + if err != nil { + return Session{}, err + } + return newSession(wk) +} + +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(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()} +} diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index 9299af6093..44da27eb31 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -232,6 +232,42 @@ const ( // An ifInState argument was supplied, and it does not match the current state. SetErrorTypeStateMismatch = "stateMismatch" + + // The Email to be sent is invalid in some way. + // + // The SetError SHOULD contain a property called properties of type String[] that lists all the properties + // of the Email that were invalid. + SetErrorInvalidEmail = "invalidEmail" + + // The envelope (supplied or generated) has more recipients than the server allows. + // + // A maxRecipients UnsignedInt property MUST also be present on the SetError specifying + // the maximum number of allowed recipients. + SetErrorTooManyRecipients = "tooManyRecipients" + + // The envelope (supplied or generated) does not have any rcptTo email addresses. + SetErrorNoRecipients = "noRecipients" + + // The rcptTo property of the envelope (supplied or generated) contains at least one rcptTo value which + // is not a valid email address for sending to. + // + // An invalidRecipients String[] property MUST also be present on the SetError, which is a list of the invalid addresses. + SetErrorInvalidRecipients = "invalidRecipients" + + // The server does not permit the user to send a message with this envelope From address [RFC5321]. + // + // [RFC5321]: https://datatracker.ietf.org/doc/html/rfc5321 + SetErrorForbiddenMailFrom = "forbiddenMailFrom" + + // The server does not permit the user to send a message with the From header field [RFC5322] of the message to be sent. + // + // [RFC5322]: https://datatracker.ietf.org/doc/html/rfc5322 + SetErrorForbiddenFrom = "forbiddenFrom" + + // The user does not have permission to send at all right now for some reason. + // + // A description String property MAY be present on the SetError object to display to the user why they are not permitted. + SetErrorForbiddenToSend = "forbiddenToSend" ) type SetError struct { @@ -242,6 +278,21 @@ type SetError struct { // // This is a non-localised string and is not intended to be shown directly to end users. Description string `json:"description,omitempty"` + + // Lists all the properties of the Email that were invalid. + // + // Only set for the invalidEmail error after a failed EmailSubmission/set errors. + Properties []string `json:"properties,omitempty"` + + // Specifies the maximum number of allowed recipients. + // + // Only set for the tooManyRecipients error after a failed EmailSubmission/set errors. + MaxRecipients int `json:"maxRecipients,omitzero"` + + // List of invalid addresses. + // + // Only set for the invalidRecipients error after a failed EmailSubmission/set errors. + InvalidRecipients []string `json:"invalidRecipients,omitempty"` } type FilterOperatorTerm string @@ -607,6 +658,19 @@ type EmailHeader struct { Value string `json:"value"` } +// Email body part. +// +// The client may specify a partId OR a blobId, but not both. +// If a partId is given, this partId MUST be present in the bodyValues property. +// +// The charset property MUST be omitted if a partId is given (the part’s content is included +// in bodyValues, and the server may choose any appropriate encoding). +// +// The size property MUST be omitted if a partId is given. If a blobId is given, it may be +// included but is ignored by the server (the size is actually calculated from the blob content +// itself). +// +// A Content-Transfer-Encoding header field MUST NOT be given. type EmailBodyPart struct { // Identifies this part uniquely within the Email. // @@ -895,6 +959,242 @@ type Email struct { Preview string `json:"preview,omitempty"` } +type Address struct { + // The email address being represented by the object. + // + // This is a “Mailbox” as used in the Reverse-path or Forward-path of the MAIL FROM or RCPT TO command in [RFC5321]. + // + // [RFC5321]: https://datatracker.ietf.org/doc/html/rfc5321 + Email string `json:"email,omitempty"` + + // Any parameters to send with the email address (either mail-parameter or rcpt-parameter as appropriate, + // as specified in [RFC5321]). + // + // If supplied, each key in the object is a parameter name, and the value is either the parameter value (type String) + // or null if the parameter does not take a value. + // + // [RFC5321]: https://datatracker.ietf.org/doc/html/rfc5321 + Parameters map[string]any `json:"parameters,omitempty"` // TODO RFC5321 +} + +// Information for use when sending via SMTP. +type Envelope struct { + // The email address to use as the return address in the SMTP submission, + // plus any parameters to pass with the MAIL FROM address. + MailFrom Address `json:"mailFrom"` + + // The email addresses to send the message to, and any RCPT TO parameters to pass with the recipient. + RcptTo []Address `json:"rcptTo"` +} + +type EmailSubmissionUndoStatus string + +const ( + UndoStatusPending EmailSubmissionUndoStatus = "pending" + UndoStatusFinal EmailSubmissionUndoStatus = "final" + UndoStatusCanceled EmailSubmissionUndoStatus = "canceled" +) + +type DeliveryStatusDelivered string + +const ( + DeliveredQueued DeliveryStatusDelivered = "queued" + DeliveredYes DeliveryStatusDelivered = "yes" + DeliveredNo DeliveryStatusDelivered = "no" + DeliveredUnknown DeliveryStatusDelivered = "unknown" +) + +type DeliveryStatusDisplayed string + +const ( + DisplayedUnknown DeliveryStatusDisplayed = "unknown" + DisplayedYes DeliveryStatusDisplayed = "yes" +) + +type DeliveryStatus struct { + // The SMTP reply string returned for this recipient when the server last tried to + // relay the message, or in a later Delivery Status Notification (DSN, as defined in + // [RFC3464]) response for the message. + // + // This SHOULD be the response to the RCPT TO stage, unless this was accepted and the + // message as a whole was rejected at the end of the DATA stage, in which case the + // DATA stage reply SHOULD be used instead. + // + // [RFC3464]: https://datatracker.ietf.org/doc/html/rfc3464 + SmtpReply string `json:"smtpReply"` + + // Represents whether the message has been successfully delivered to the recipient. + // + // This MUST be one of the following values: + // - queued: The message is in a local mail queue and status will change once it exits + // the local mail queues. The smtpReply property may still change. + // - yes: The message was successfully delivered to the mail store of the recipient. + // The smtpReply property is final. + // - no: Delivery to the recipient permanently failed. The smtpReply property is final. + // - unknown: The final delivery status is unknown, (e.g., it was relayed to an external + // machine and no further information is available). + // The smtpReply property may still change if a DSN arrives. + Delivered DeliveryStatusDelivered `json:"delivered"` + + // Represents whether the message has been displayed to the recipient. + // + // This MUST be one of the following values: + // - unknown: The display status is unknown. This is the initial value. + // - yes: The recipient’s system claims the message content has been displayed to the recipient. + // Note that there is no guarantee that the recipient has noticed, read, or understood the content. + Displayed DeliveryStatusDisplayed `json:"displayed"` +} + +type EmailSubmission struct { + // (server-set) The id of the EmailSubmission. + Id string `json:"id"` + + // The id of the Identity to associate with this submission. + IdentityId string `json:"identityId"` + + // The id of the Email to send. + // + // The Email being sent does not have to be a draft, for example, when “redirecting” an existing Email + // to a different address. + EmailId string `json:"emailId"` + + // (server-set) The Thread id of the Email to send. + // + // This is set by the server to the threadId property of the Email referenced by the emailId. + ThreadId string `json:"threadId"` + + // Information for use when sending via SMTP. + // + // If the envelope property is null or omitted on creation, the server MUST generate this from the + // referenced Email as follows: + // + // - mailFrom: The email address in the Sender header field, if present; otherwise, + // it’s the email address in the From header field, if present. + // In either case, no parameters are added. + // - rcptTo: The deduplicated set of email addresses from the To, Cc, and Bcc header fields, + // if present, with no parameters for any of them. + Envelope *Envelope `json:"envelope,omitempty"` + + // (server-set) The date the submission was/will be released for delivery. + SendAt time.Time `json:"sendAt,omitzero"` + + // (server-set) This represents whether the submission may be canceled. + // + // This is server set on create and MUST be one of the following values: + // + // - pending: It may be possible to cancel this submission. + // - final: The message has been relayed to at least one recipient in a manner that cannot be + // recalled. It is no longer possible to cancel this submission. + // - canceled: The submission was canceled and will not be delivered to any recipient. + UndoStatus EmailSubmissionUndoStatus `json:"undoStatus"` + + // (server-set) This represents the delivery status for each of the submission’s recipients, if known. + // + // This property MAY not be supported by all servers, in which case it will remain null. + // + // Servers that support it SHOULD update the EmailSubmission object each time the status of any of + // the recipients changes, even if some recipients are still being retried. + // + // This value is a map from the email address of each recipient to a DeliveryStatus object. + DeliveryStatus map[string]DeliveryStatus `json:"deliveryStatus"` + + // (server-set) A list of blob ids for DSNs [RFC3464] received for this submission, + // in order of receipt, oldest first. + // + // The blob is the whole MIME message (with a top-level content-type of multipart/report), as received. + // + // [RFC3464]: https://datatracker.ietf.org/doc/html/rfc3464 + DsnBlobIds []string `json:"dsnBlobIds,omitempty"` + + // (server-set) A list of blob ids for MDNs [RFC8098] received for this submission, + // in order of receipt, oldest first. + // + // The blob is the whole MIME message (with a top-level content-type of multipart/report), as received. + // + // [RFC8098]: https://datatracker.ietf.org/doc/html/rfc8098 + MdnBlobIds []string `json:"mdnBlobIds,omitempty"` +} + +type EmailSubmissionGetRefCommand struct { + // The id of the account to use. + AccountId string `json:"accountId"` + + IdRef *ResultReference `json:"#ids,omitempty"` + + Properties []string `json:"properties,omitempty"` +} + +type EmailSubmissionGetResponse struct { + AccountId string `json:"accountId"` + State string `json:"state"` + List []EmailSubmission `json:"list,omitempty"` + NotFound []string `json:"notFound,omitempty"` +} + +// Patch Object. +// +// Example: +// +// - moves it from the drafts folder (which has Mailbox id “7cb4e8ee-df87-4757-b9c4-2ea1ca41b38e”) +// to the sent folder (which we presume has Mailbox id “73dbcb4b-bffc-48bd-8c2a-a2e91ca672f6”) +// +// - removes the $draft flag and +// +// { +// "mailboxIds/7cb4e8ee-df87-4757-b9c4-2ea1ca41b38e": null, +// "mailboxIds/73dbcb4b-bffc-48bd-8c2a-a2e91ca672f6": true, +// "keywords/$draft": null +// } +type PatchObject map[string]any + +// same as EmailSubmission but without the server-set attributes +type EmailSubmissionCreate struct { + // The id of the Identity to associate with this submission. + IdentityId string `json:"identityId"` + // The id of the Email to send. + // + // The Email being sent does not have to be a draft, for example, when “redirecting” an existing + // Email to a different address. + EmailId string `json:"emailId"` + + // Information for use when sending via SMTP. + Envelope *Envelope `json:"envelope,omitempty"` +} + +type EmailSubmissionSetCommand struct { + AccountId string `json:"accountId"` + Create map[string]EmailSubmissionCreate `json:"create,omitempty"` + OldState string `json:"oldState,omitempty"` + NewState string `json:"newState,omitempty"` + + // A map of EmailSubmission id to an object containing properties to update on the Email object + // referenced by the EmailSubmission if the create/update/destroy succeeds. + // + // (For references to EmailSubmissions created in the same “/set” invocation, this is equivalent + // to a creation-reference, so the id will be the creation id prefixed with a #.) + OnSuccessUpdateEmail map[string]PatchObject `json:"onSuccessUpdateEmail,omitempty"` + + // A list of EmailSubmission ids for which the Email with the corresponding emailId should be destroyed + // if the create/update/destroy succeeds. + // + // (For references to EmailSubmission creations, this is equivalent to a creation-reference so the + // id will be the creation id prefixed with a #.) + OnSuccessDestroyEmail []string `json:"onSuccessDestroyEmail,omitempty"` +} + +type CreatedEmailSubmission struct { + Id string `json:"id"` +} + +type EmailSubmissionSetResponse struct { + AccountId string `json:"accountId"` + OldState string `json:"oldState"` + NewState string `json:"newState"` + Created map[string]CreatedEmailSubmission `json:"created,omitempty"` + NotCreated map[string]SetError `json:"notCreated,omitempty"` + // TODO(pbleser-oc) add updated and destroyed when they are needed +} + type Command string type Invocation struct { @@ -1126,21 +1426,78 @@ type EmailBodyStructure struct { } type EmailCreate struct { - MailboxIds map[string]bool `json:"mailboxIds,omitempty"` - Keywords map[string]bool `json:"keywords,omitempty"` - From []EmailAddress `json:"from,omitempty"` - Subject string `json:"subject,omitempty"` - ReceivedAt time.Time `json:"receivedAt,omitzero"` - SentAt time.Time `json:"sentAt,omitzero"` + // The set of Mailbox ids this Email belongs to. + // + // An Email in the mail store MUST belong to one or more Mailboxes at all times + // (until it is destroyed). + // + // The set is represented as an object, with each key being a Mailbox id. + // The value for each key in the object MUST be true. + MailboxIds map[string]bool `json:"mailboxIds,omitempty"` + + // A set of keywords that apply to the Email. + // + // The set is represented as an object, with the keys being the keywords. + // The value for each key in the object MUST be true. + Keywords map[string]bool `json:"keywords,omitempty"` + + // The ["From:" field] specifies the author(s) of the message, that is, the mailbox(es) + // of the person(s) or system(s) responsible for the writing of the message + // + // ["From:" field]: https://www.rfc-editor.org/rfc/rfc5322.html#section-3.6.2 + From []EmailAddress `json:"from,omitempty"` + + // The "Subject:" field contains a short string identifying the topic of the message. + Subject string `json:"subject,omitempty"` + + // The date the Email was received by the message store. + // + // (default: time of most recent Received header, or time of import on server if none). + ReceivedAt time.Time `json:"receivedAt,omitzero"` + + // The origination date specifies the date and time at which the creator of the message indicated that + // the message was complete and ready to enter the mail delivery system. + // + // For instance, this might be the time that a user pushes the "send" or "submit" button in an + // application program. + // + // In any case, it is specifically not intended to convey the time that the message is actually transported, + // but rather the time at which the human or other creator of the message has put the message into its final + // form, ready for transport. + // + // (For example, a portable computer user who is not connected to a network might queue a message for delivery. + // The origination date is intended to contain the date and time that the user queued the message, not the time + // when the user connected to the network to send the message.) + SentAt time.Time `json:"sentAt,omitzero"` + + // This is the full MIME structure of the message body, without recursing into message/rfc822 or message/global parts. + // + // Note that EmailBodyParts may have subParts if they are of type multipart/*. BodyStructure EmailBodyStructure `json:"bodyStructure"` + + // This is a map of partId to an EmailBodyValue object for none, some, or all text/* parts. + BodyValues map[string]EmailBodyValue `json:"bodyValues,omitempty"` } +type EmailUpdate map[string]any + type EmailSetCommand struct { AccountId string `json:"accountId"` Create map[string]EmailCreate `json:"create,omitempty"` + Update map[string]EmailUpdate `json:"update,omitempty"` + Destroy []string `json:"destroy,omitempty"` } type EmailSetResponse struct { + AccountId string `json:"accountId"` + OldState string `json:"oldState,omitempty"` + NewState string `json:"newState"` + Created map[string]Email `json:"created,omitempty"` + Updated map[string]Email `json:"updated,omitempty"` + Destroyed []string `json:"destroyed,omitempty"` + NotCreated map[string]SetError `json:"notCreated,omitempty"` + NotUpdated map[string]SetError `json:"notUpdated,omitempty"` + NotDestroyed map[string]SetError `json:"notDestroyed,omitempty"` } const ( @@ -1372,6 +1729,8 @@ const ( EmailChanges Command = "Email/changes" EmailSet Command = "Email/set" EmailImport Command = "Email/import" + EmailSubmissionGet Command = "EmailSubmission/get" + EmailSubmissionSet Command = "EmailSubmission/set" ThreadGet Command = "Thread/get" MailboxGet Command = "Mailbox/get" MailboxQuery Command = "Mailbox/query" @@ -1390,6 +1749,8 @@ var CommandResponseTypeMap = map[Command]func() any{ EmailQuery: func() any { return EmailQueryResponse{} }, EmailChanges: func() any { return EmailChangesResponse{} }, EmailGet: func() any { return EmailGetResponse{} }, + EmailSubmissionGet: func() any { return EmailSubmissionGetResponse{} }, + EmailSubmissionSet: func() any { return EmailSubmissionSetResponse{} }, ThreadGet: func() any { return ThreadGetResponse{} }, IdentityGet: func() any { return IdentityGetResponse{} }, VacationResponseGet: func() any { return VacationResponseGetResponse{} }, diff --git a/pkg/jmap/jmap_session.go b/pkg/jmap/jmap_session.go new file mode 100644 index 0000000000..4600f0bc54 --- /dev/null +++ b/pkg/jmap/jmap_session.go @@ -0,0 +1,120 @@ +package jmap + +import ( + "fmt" + "net/url" + + "github.com/opencloud-eu/opencloud/pkg/log" +) + +type SessionEventListener interface { + OnSessionOutdated(session *Session) +} + +// Cached user related information +// +// This information is typically retrieved once (or at least for a certain period of time) from the +// JMAP well-known endpoint of Stalwart and then kept in cache to avoid the performance cost of +// retrieving it over and over again. +// +// This is really only needed due to the Graph API limitations, since ideally, the account ID should +// be passed as a request parameter by the UI, in order to support a user having multiple accounts. +// +// Keeping track of the JMAP URL might be useful though, in case of Stalwart sharding strategies making +// use of that, by providing different URLs for JMAP on a per-user basis, and that is not something +// we would want to query before every single JMAP request. On the other hand, that then also creates +// a risk of going out-of-sync, e.g. if a node is down and the user is reassigned to a different node. +// There might be webhooks to subscribe to in Stalwart to be notified of such situations, in which case +// the Session needs to be removed from the cache. +// +// The Username is only here for convenience, it could just as well be passed as a separate parameter +// instead of being part of the Session, since the username is always part of the request (typically in +// the authentication token payload.) +type Session struct { + // The name of the user to use to authenticate against Stalwart + Username string + + // The base URL to use for JMAP operations towards Stalwart + JmapUrl url.URL + + // The upload URL template + UploadUrlTemplate string + + // The upload URL template + DownloadUrlTemplate string + + // TODO + DefaultMailAccountId string + + SessionResponse +} + +// Create a new Session from a SessionResponse. +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")} + } + mailAccountId := sessionResponse.PrimaryAccounts.Mail + if mailAccountId == "" { + return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a primary mail account")} + } + apiStr := sessionResponse.ApiUrl + if apiStr == "" { + return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an API URL")} + } + apiUrl, err := url.Parse(apiStr) + if err != nil { + return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response provides an invalid API URL")} + } + uploadUrl := sessionResponse.UploadUrl + if uploadUrl == "" { + return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an upload URL")} + } + downloadUrl := sessionResponse.DownloadUrl + if downloadUrl == "" { + return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an download URL")} + } + + return Session{ + Username: username, + DefaultMailAccountId: mailAccountId, + JmapUrl: *apiUrl, + UploadUrlTemplate: uploadUrl, + DownloadUrlTemplate: downloadUrl, + SessionResponse: sessionResponse, + }, nil +} + +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 +} + +func (s *Session) BlobAccountId(accountId string) string { + if accountId != "" && accountId != defaultAccountId { + return accountId + } + // TODO(pbleser-oc) handle case where there is no default blob account + return s.PrimaryAccounts.Blob +} + +func (s *Session) SubmissionAccountId(accountId string) string { + if accountId != "" && accountId != defaultAccountId { + return accountId + } + // TODO(pbleser-oc) handle case where there is no default submission account + return s.PrimaryAccounts.Submission +} + +// 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(logApiUrl, s.ApiUrl). + Str(logSessionState, s.State). + Logger()} +} diff --git a/services/groupware/pkg/groupware/groupware_api_account.go b/services/groupware/pkg/groupware/groupware_api_account.go index 82673e0b12..93be5e3468 100644 --- a/services/groupware/pkg/groupware/groupware_api_account.go +++ b/services/groupware/pkg/groupware/groupware_api_account.go @@ -5,17 +5,17 @@ import ( ) func (g Groupware) GetAccount(w http.ResponseWriter, r *http.Request) { - g.respond(w, r, func(req Request) (any, string, *Error) { + g.respond(w, r, func(req Request) Response { account, err := req.GetAccount() if err != nil { - return nil, "", err + return errorResponse(err) } - return account, req.session.State, nil + return response(account, req.session.State) }) } 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 + g.respond(w, r, func(req Request) Response { + return response(req.session.Accounts, req.session.State) }) } diff --git a/services/groupware/pkg/groupware/groupware_api_blob.go b/services/groupware/pkg/groupware/groupware_api_blob.go index b205b82e89..f5cd8572ba 100644 --- a/services/groupware/pkg/groupware/groupware_api_blob.go +++ b/services/groupware/pkg/groupware/groupware_api_blob.go @@ -14,24 +14,27 @@ const ( ) func (g Groupware) GetBlob(w http.ResponseWriter, r *http.Request) { - g.respond(w, r, func(req Request) (any, string, *Error) { + g.respond(w, r, func(req Request) Response { blobId := chi.URLParam(req.r, UriParamBlobId) if blobId == "" { errorId := req.errorId() msg := fmt.Sprintf("Invalid value for path parameter '%v': empty", UriParamBlobId) - return nil, "", apiError(errorId, ErrorInvalidRequestParameter, + return errorResponse(apiError(errorId, ErrorInvalidRequestParameter, withDetail(msg), withSource(&ErrorSource{Parameter: UriParamBlobId}), - ) + )) } res, err := g.jmap.GetBlob(req.GetAccountId(), req.session, req.ctx, req.logger, blobId) - return res, res.Digest(), req.apiErrorFromJmap(err) + if err != nil { + return req.errorResponseFromJmap(err) + } + return etagOnlyResponse(res, res.Digest()) }) } func (g Groupware) UploadBlob(w http.ResponseWriter, r *http.Request) { - g.respond(w, r, func(req Request) (any, string, *Error) { + g.respond(w, r, func(req Request) Response { contentType := r.Header.Get("Content-Type") body := r.Body if body != nil { @@ -45,10 +48,10 @@ func (g Groupware) UploadBlob(w http.ResponseWriter, r *http.Request) { resp, err := g.jmap.UploadBlobStream(req.GetAccountId(), req.session, req.ctx, req.logger, contentType, body) if err != nil { - return resp, "", req.apiErrorFromJmap(err) + return req.errorResponseFromJmap(err) } - return resp, resp.Sha512, nil + return etagOnlyResponse(resp, resp.Sha512) }) } diff --git a/services/groupware/pkg/groupware/groupware_api_identity.go b/services/groupware/pkg/groupware/groupware_api_identity.go index 2645e2acb7..9a7f812184 100644 --- a/services/groupware/pkg/groupware/groupware_api_identity.go +++ b/services/groupware/pkg/groupware/groupware_api_identity.go @@ -5,8 +5,11 @@ import ( ) func (g Groupware) GetIdentity(w http.ResponseWriter, r *http.Request) { - g.respond(w, r, func(req Request) (any, string, *Error) { + g.respond(w, r, func(req Request) Response { res, err := g.jmap.GetIdentity(req.GetAccountId(), req.session, req.ctx, req.logger) - return res, res.State, req.apiErrorFromJmap(err) + if err != nil { + return req.errorResponseFromJmap(err) + } + return response(res, res.State) }) } diff --git a/services/groupware/pkg/groupware/groupware_api_index.go b/services/groupware/pkg/groupware/groupware_api_index.go index c37c1758ea..b89c8f1f27 100644 --- a/services/groupware/pkg/groupware/groupware_api_index.go +++ b/services/groupware/pkg/groupware/groupware_api_index.go @@ -68,7 +68,7 @@ type SwaggerIndexResponse struct { // // 200: IndexResponse func (g Groupware) Index(w http.ResponseWriter, r *http.Request) { - g.respond(w, r, func(req Request) (any, string, *Error) { + g.respond(w, r, func(req Request) Response { accounts := make(map[string]IndexAccount, len(req.session.Accounts)) for i, a := range req.session.Accounts { accounts[i] = IndexAccount{ @@ -93,7 +93,7 @@ func (g Groupware) Index(w http.ResponseWriter, r *http.Request) { } } - return IndexResponse{ + return response(IndexResponse{ Version: Version, Capabilities: Capabilities, Limits: IndexLimits{ @@ -107,6 +107,6 @@ func (g Groupware) Index(w http.ResponseWriter, r *http.Request) { Mail: req.session.PrimaryAccounts.Mail, Submission: req.session.PrimaryAccounts.Submission, }, - }, req.session.State, nil + }, req.session.State) }) } diff --git a/services/groupware/pkg/groupware/groupware_api_mailbox.go b/services/groupware/pkg/groupware/groupware_api_mailbox.go index 941ba1b43c..3fab4d99e1 100644 --- a/services/groupware/pkg/groupware/groupware_api_mailbox.go +++ b/services/groupware/pkg/groupware/groupware_api_mailbox.go @@ -38,16 +38,16 @@ func (g Groupware) GetMailbox(w http.ResponseWriter, r *http.Request) { return } - g.respond(w, r, func(req Request) (any, string, *Error) { + g.respond(w, r, func(req Request) Response { res, err := g.jmap.GetMailbox(req.GetAccountId(), req.session, req.ctx, req.logger, []string{mailboxId}) if err != nil { - return res, "", req.apiErrorFromJmap(err) + return req.errorResponseFromJmap(err) } if len(res.List) == 1 { - return res.List[0], res.State, req.apiErrorFromJmap(err) + return response(res.List[0], res.State) } else { - return nil, res.State, req.apiErrorFromJmap(err) + return notFoundResponse(res.State) } }) } @@ -114,19 +114,19 @@ func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) { hasCriteria = true } - g.respond(w, r, func(req Request) (any, string, *Error) { + g.respond(w, r, func(req Request) Response { if hasCriteria { mailboxes, err := g.jmap.SearchMailboxes(req.GetAccountId(), req.session, req.ctx, req.logger, filter) if err != nil { - return nil, "", req.apiErrorFromJmap(err) + return req.errorResponseFromJmap(err) } - return mailboxes.Mailboxes, mailboxes.State, nil + return response(mailboxes.Mailboxes, mailboxes.State) } else { mailboxes, err := g.jmap.GetAllMailboxes(req.GetAccountId(), req.session, req.ctx, req.logger) if err != nil { - return nil, "", req.apiErrorFromJmap(err) + return req.errorResponseFromJmap(err) } - return mailboxes.List, mailboxes.State, nil + return response(mailboxes.List, mailboxes.State) } }) } diff --git a/services/groupware/pkg/groupware/groupware_api_messages.go b/services/groupware/pkg/groupware/groupware_api_messages.go index ab1cf0853a..bbd08496e1 100644 --- a/services/groupware/pkg/groupware/groupware_api_messages.go +++ b/services/groupware/pkg/groupware/groupware_api_messages.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/go-chi/chi/v5" @@ -18,38 +19,38 @@ func (g Groupware) GetAllMessages(w http.ResponseWriter, r *http.Request) { if since != "" { // ... then it's a completely different operation maxChanges := -1 - g.respond(w, r, func(req Request) (any, string, *Error) { + g.respond(w, r, func(req Request) Response { if mailboxId == "" { errorId := req.errorId() msg := fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId) - return nil, "", apiError(errorId, ErrorInvalidRequestParameter, + return errorResponse(apiError(errorId, ErrorInvalidRequestParameter, withDetail(msg), withSource(&ErrorSource{Parameter: UriParamMailboxId}), - ) + )) } logger := &log.Logger{Logger: req.logger.With().Str(HeaderSince, since).Logger()} emails, jerr := g.jmap.GetEmailsInMailboxSince(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, since, true, g.maxBodyValueBytes, maxChanges) if jerr != nil { - return nil, "", req.apiErrorFromJmap(jerr) + return req.errorResponseFromJmap(jerr) } - return emails, emails.State, nil + return response(emails, emails.State) }) } else { - g.respond(w, r, func(req Request) (any, string, *Error) { + g.respond(w, r, func(req Request) Response { l := req.logger.With() if mailboxId == "" { errorId := req.errorId() msg := fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId) - return nil, "", apiError(errorId, ErrorInvalidRequestParameter, + return errorResponse(apiError(errorId, ErrorInvalidRequestParameter, withDetail(msg), withSource(&ErrorSource{Parameter: UriParamMailboxId}), - ) + )) } offset, ok, err := req.parseNumericParam(QueryParamOffset, 0) if err != nil { - return nil, "", err + return errorResponse(err) } if ok { l = l.Int(QueryParamOffset, offset) @@ -57,7 +58,7 @@ func (g Groupware) GetAllMessages(w http.ResponseWriter, r *http.Request) { limit, ok, err := req.parseNumericParam(QueryParamLimit, g.defaultEmailLimit) if err != nil { - return nil, "", err + return errorResponse(err) } if ok { l = l.Int(QueryParamLimit, limit) @@ -67,34 +68,34 @@ func (g Groupware) GetAllMessages(w http.ResponseWriter, r *http.Request) { 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 req.errorResponseFromJmap(jerr) } - return emails, emails.State, nil + return response(emails, emails.State) }) } } 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) { + id := chi.URLParam(r, UriParamMessageId) + g.respond(w, r, func(req Request) Response { 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, + msg := fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamMessageId, logstr(id), "empty list of mail ids") + return errorResponse(apiError(errorId, ErrorInvalidRequestParameter, withDetail(msg), - withSource(&ErrorSource{Parameter: UriParamMessagesId}), - ) + withSource(&ErrorSource{Parameter: UriParamMessageId}), + )) } 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 req.errorResponseFromJmap(jerr) } - return emails, emails.State, nil + return response(emails, emails.State) }) } @@ -107,19 +108,19 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { if since != "" { // get messages changes since a given state maxChanges := -1 - g.respond(w, r, func(req Request) (any, string, *Error) { + g.respond(w, r, func(req Request) Response { logger := &log.Logger{Logger: req.logger.With().Str(HeaderSince, since).Logger()} emails, jerr := g.jmap.GetEmailsSince(req.GetAccountId(), req.session, req.ctx, logger, since, true, g.maxBodyValueBytes, maxChanges) if jerr != nil { - return nil, "", req.apiErrorFromJmap(jerr) + return req.errorResponseFromJmap(jerr) } - return emails, emails.State, nil + return response(emails, emails.State) }) } else { // do a search - g.respond(w, r, func(req Request) (any, string, *Error) { + g.respond(w, r, func(req Request) Response { mailboxId := q.Get(QueryParamMailboxId) notInMailboxIds := q[QueryParamNotInMailboxId] text := q.Get(QueryParamSearchText) @@ -134,7 +135,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { offset, ok, err := req.parseNumericParam(QueryParamOffset, 0) if err != nil { - return nil, "", err + return errorResponse(err) } if ok { l = l.Int(QueryParamOffset, offset) @@ -142,7 +143,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { limit, ok, err := req.parseNumericParam(QueryParamLimit, g.defaultEmailLimit) if err != nil { - return nil, "", err + return errorResponse(err) } if ok { l = l.Int(QueryParamLimit, limit) @@ -150,7 +151,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { before, ok, err := req.parseDateParam(QueryParamSearchBefore) if err != nil { - return nil, "", err + return errorResponse(err) } if ok { l = l.Time(QueryParamSearchBefore, before) @@ -158,7 +159,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { after, ok, err := req.parseDateParam(QueryParamSearchAfter) if err != nil { - return nil, "", err + return errorResponse(err) } if ok { l = l.Time(QueryParamSearchAfter, after) @@ -194,7 +195,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { minSize, ok, err := req.parseNumericParam(QueryParamSearchMinSize, 0) if err != nil { - return nil, "", err + return errorResponse(err) } if ok { l = l.Int(QueryParamSearchMinSize, minSize) @@ -202,7 +203,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { maxSize, ok, err := req.parseNumericParam(QueryParamSearchMaxSize, 0) if err != nil { - return nil, "", err + return errorResponse(err) } if ok { l = l.Int(QueryParamSearchMaxSize, maxSize) @@ -229,10 +230,120 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { emails, jerr := g.jmap.QueryEmails(req.GetAccountId(), &filter, req.session, req.ctx, logger, offset, limit, false, 0) if jerr != nil { - return nil, "", req.apiErrorFromJmap(jerr) + return req.errorResponseFromJmap(jerr) } - return emails, emails.QueryState, nil + return etagResponse(emails, emails.SessionState, emails.QueryState) }) } } + +type MessageCreation struct { + MailboxIds []string `json:"mailboxIds,omitempty"` + Keywords []string `json:"keywords,omitempty"` + From []jmap.EmailAddress `json:"from,omitempty"` + Subject string `json:"subject,omitempty"` + ReceivedAt time.Time `json:"receivedAt,omitzero"` + SentAt time.Time `json:"sentAt,omitzero"` // huh? + BodyStructure jmap.EmailBodyStructure `json:"bodyStructure"` + BodyValues map[string]jmap.EmailBodyValue `json:"bodyValues,omitempty"` +} + +func (g Groupware) CreateMessage(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + messageId := chi.URLParam(r, UriParamMessageId) + + l := req.logger.With() + l.Str(UriParamMessageId, messageId) + logger := &log.Logger{Logger: l.Logger()} + + var body MessageCreation + err := req.body(&body) + if err != nil { + return errorResponse(err) + } + + mailboxIdsMap := map[string]bool{} + for _, mailboxId := range body.MailboxIds { + mailboxIdsMap[mailboxId] = true + } + + keywordsMap := map[string]bool{} + for _, keyword := range body.Keywords { + keywordsMap[keyword] = true + } + + create := jmap.EmailCreate{ + MailboxIds: mailboxIdsMap, + Keywords: keywordsMap, + From: body.From, + Subject: body.Subject, + ReceivedAt: body.ReceivedAt, + SentAt: body.SentAt, + BodyStructure: body.BodyStructure, + BodyValues: body.BodyValues, + } + + created, jerr := g.jmap.CreateEmail(req.GetAccountId(), create, req.session, req.ctx, logger) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + + return response(created.Email, created.State) + }) +} + +func (g Groupware) UpdateMessage(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + messageId := chi.URLParam(r, UriParamMessageId) + + l := req.logger.With() + l.Str(UriParamMessageId, messageId) + + logger := &log.Logger{Logger: l.Logger()} + + var body map[string]any + err := req.body(&body) + if err != nil { + return errorResponse(err) + } + + updates := map[string]jmap.EmailUpdate{ + messageId: body, + } + + result, jerr := g.jmap.UpdateEmails(req.GetAccountId(), updates, req.session, req.ctx, logger) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + + if result.Updated == nil { + // TODO(pbleser-oc) handle missing update response + } + updatedEmail, ok := result.Updated[messageId] + if !ok { + // TODO(pbleser-oc) handle missing update response + } + + return response(updatedEmail, result.State) + }) + +} + +func (g Groupware) DeleteMessage(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + messageId := chi.URLParam(r, UriParamMessageId) + + l := req.logger.With() + l.Str(UriParamMessageId, messageId) + + logger := &log.Logger{Logger: l.Logger()} + + deleted, jerr := g.jmap.DeleteEmails(req.GetAccountId(), []string{messageId}, req.session, req.ctx, logger) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + + return noContentResponse(deleted.State) + }) +} diff --git a/services/groupware/pkg/groupware/groupware_api_vacation.go b/services/groupware/pkg/groupware/groupware_api_vacation.go index a1cbb72b32..b6b3dc8c16 100644 --- a/services/groupware/pkg/groupware/groupware_api_vacation.go +++ b/services/groupware/pkg/groupware/groupware_api_vacation.go @@ -29,8 +29,11 @@ type SwaggerVacationResponse200 struct { // 400: ErrorResponse400 // 500: ErrorResponse500 func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) { - g.respond(w, r, func(req Request) (any, string, *Error) { + g.respond(w, r, func(req Request) Response { res, err := g.jmap.GetVacationResponse(req.GetAccountId(), req.session, req.ctx, req.logger) - return res, res.State, req.apiErrorFromJmap(err) + if err != nil { + return req.errorResponseFromJmap(err) + } + return response(res, res.State) }) } diff --git a/services/groupware/pkg/groupware/groupware_error.go b/services/groupware/pkg/groupware/groupware_error.go index c3ca52f553..9b33410573 100644 --- a/services/groupware/pkg/groupware/groupware_error.go +++ b/services/groupware/pkg/groupware/groupware_error.go @@ -386,6 +386,9 @@ func errorResponse(id string, error GroupwareError, options ...ErrorOpt) ErrorRe func errorId(r *http.Request, ctx context.Context) string { requestId := chimiddleware.GetReqID(ctx) + if requestId == "" { + requestId = r.Header.Get("x-request-id") + } localId := uuid.NewString() if requestId != "" { return requestId + "." + localId @@ -398,14 +401,14 @@ func (r Request) errorId() string { return errorId(r.r, r.ctx) } -func apiError(id string, error GroupwareError, options ...ErrorOpt) *Error { +func apiError(id string, gwerr GroupwareError, options ...ErrorOpt) *Error { err := &Error{ Id: id, - NumStatus: error.Status, - Status: strconv.Itoa(error.Status), - Code: error.Code, - Title: error.Title, - Detail: error.Detail, + NumStatus: gwerr.Status, + Status: strconv.Itoa(gwerr.Status), + Code: gwerr.Code, + Title: gwerr.Title, + Detail: gwerr.Detail, } for _, o := range options { @@ -415,11 +418,11 @@ func apiError(id string, error GroupwareError, options ...ErrorOpt) *Error { return err } -func (r Request) apiErrorFromJmap(error jmap.Error) *Error { - if error == nil { +func (r Request) apiErrorFromJmap(err jmap.Error) *Error { + if err == nil { return nil } - gwe := groupwareErrorFromJmap(error) + gwe := groupwareErrorFromJmap(err) if gwe == nil { return nil } @@ -431,3 +434,7 @@ func (r Request) apiErrorFromJmap(error jmap.Error) *Error { func errorResponses(errors ...Error) ErrorResponse { return ErrorResponse{Errors: errors} } + +func (r Request) errorResponseFromJmap(err jmap.Error) Response { + return errorResponse(r.apiErrorFromJmap(err)) +} diff --git a/services/groupware/pkg/groupware/groupware_framework.go b/services/groupware/pkg/groupware/groupware_framework.go index 4930546748..32648f1e34 100644 --- a/services/groupware/pkg/groupware/groupware_framework.go +++ b/services/groupware/pkg/groupware/groupware_framework.go @@ -3,7 +3,9 @@ package groupware import ( "context" "crypto/tls" + "encoding/json" "fmt" + "io" "net/http" "net/url" "strconv" @@ -180,6 +182,67 @@ type Request struct { session *jmap.Session } +type Response struct { + body any + err *Error + etag string + sessionState string +} + +func errorResponse(err *Error) Response { + return Response{ + body: nil, + err: err, + etag: "", + sessionState: "", + } +} + +func response(body any, sessionStatus string) Response { + return Response{ + body: body, + err: nil, + etag: sessionStatus, + sessionState: sessionStatus, + } +} + +func etagResponse(body any, sessionState string, etag string) Response { + return Response{ + body: body, + err: nil, + etag: etag, + sessionState: sessionState, + } +} + +func etagOnlyResponse(body any, etag string) Response { + return Response{ + body: body, + err: nil, + etag: etag, + sessionState: "", + } +} + +func noContentResponse(sessionStatus string) Response { + return Response{ + body: "", + err: nil, + etag: sessionStatus, + sessionState: sessionStatus, + } +} + +func notFoundResponse(sessionStatus string) Response { + return Response{ + body: nil, + err: nil, + etag: sessionStatus, + sessionState: sessionStatus, + } +} + func (r Request) GetAccountId() string { accountId := chi.URLParam(r.r, UriParamAccount) return r.session.MailAccountId(accountId) @@ -246,6 +309,22 @@ func (r Request) parseDateParam(param string) (time.Time, bool, *Error) { return t, true, nil } +func (r Request) body(target any) *Error { + body := r.r.Body + defer func(b io.ReadCloser) { + err := b.Close() + if err != nil { + r.logger.Error().Err(err).Msg("failed to close request body") + } + }(body) + + err := json.NewDecoder(body).Decode(target) + if err != nil { + // TODO(pbleser-oc) error handling when failing to decode body + } + 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 { @@ -318,7 +397,7 @@ func (g Groupware) serveError(w http.ResponseWriter, r *http.Request, error *Err render.Render(w, r, errorResponses(*error)) } -func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(r Request) (any, string, *Error)) { +func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(r Request) Response) { ctx := r.Context() logger := g.logger.SubloggerWithRequestID(ctx) @@ -355,23 +434,34 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func( session: &session, } - response, state, apierr := handler(req) - if apierr != nil { - g.log(apierr) + response := handler(req) + if response.err != nil { + g.log(response.err) w.Header().Add("Content-Type", ContentTypeJsonApi) - render.Status(r, apierr.NumStatus) - w.WriteHeader(apierr.NumStatus) - render.Render(w, r, errorResponses(*apierr)) + render.Status(r, response.err.NumStatus) + w.WriteHeader(response.err.NumStatus) + render.Render(w, r, errorResponses(*response.err)) return } - if state != "" { - w.Header().Add("ETag", state) + if response.etag != "" { + w.Header().Add("ETag", response.etag) } - if response == nil { + if response.sessionState != "" { + if response.etag == "" { + w.Header().Add("ETag", response.sessionState) + } + w.Header().Add("Session-State", response.sessionState) + } + + switch response.body { + case nil: render.Status(r, http.StatusNotFound) w.WriteHeader(http.StatusNotFound) - } else { + case "": + render.Status(r, http.StatusNoContent) + w.WriteHeader(http.StatusNoContent) + default: render.Status(r, http.StatusOK) render.JSON(w, r, response) } diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go index 7f661a3ffa..4fc27267c7 100644 --- a/services/groupware/pkg/groupware/groupware_route.go +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -5,9 +5,9 @@ import ( ) const ( - UriParamAccount = "account" + UriParamAccount = "accountid" UriParamMailboxId = "mailbox" - UriParamMessagesId = "id" + UriParamMessageId = "messageid" UriParamBlobId = "blobid" UriParamBlobName = "blobname" QueryParamBlobType = "type" @@ -33,7 +33,7 @@ const ( 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.Route("/accounts/{accountid}", func(r chi.Router) { r.Get("/", g.GetAccount) r.Get("/identity", g.GetIdentity) r.Get("/vacation", g.GetVacation) @@ -44,7 +44,11 @@ func (g Groupware) Route(r chi.Router) { }) r.Route("/messages", func(r chi.Router) { r.Get("/", g.GetMessages) - r.Get("/{id}", g.GetMessagesById) + r.Post("/", g.CreateMessage) + r.Get("/{messageid}", g.GetMessagesById) + r.Patch("/{messageid}", g.UpdateMessage) // or PUT? + r.Put("/{messageid}", g.UpdateMessage) // or PATCH? + r.Delete("/{messageId}", g.DeleteMessage) }) r.Route("/blobs", func(r chi.Router) { r.Get("/{blobid}", g.GetBlob)