diff --git a/services/groupware/pkg/groupware/groupware_api_emails.go b/services/groupware/pkg/groupware/groupware_api_emails.go index 7f341700f3..fdeabc3008 100644 --- a/services/groupware/pkg/groupware/groupware_api_emails.go +++ b/services/groupware/pkg/groupware/groupware_api_emails.go @@ -75,7 +75,7 @@ func (g *Groupware) GetAllEmailsInMailbox(w http.ResponseWriter, r *http.Request return errorResponse(err) } - logger := log.From(req.logger.With().Str(HeaderSince, since).Str(logAccountId, accountId)) + logger := log.From(req.logger.With().Str(HeaderSince, log.SafeString(since)).Str(logAccountId, log.SafeString(accountId))) emails, sessionState, lang, jerr := g.jmap.GetMailboxChanges(accountId, req.session, req.ctx, logger, req.language(), mailboxId, since, true, g.maxBodyValueBytes, maxChanges) if jerr != nil { @@ -194,7 +194,7 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request) if err != nil { return errorResponse(err) } - l := req.logger.With().Str(logAccountId, accountId) + l := req.logger.With().Str(logAccountId, log.SafeString(accountId)) logger := log.From(l) emails, sessionState, lang, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, req.language(), []string{id}, false, 0) if jerr != nil { @@ -217,7 +217,7 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request) return gwerr } - l := req.logger.With().Str(logAccountId, mailAccountId).Str(logBlobAccountId, log.SafeString(blobAccountId)) + l := req.logger.With().Str(logAccountId, log.SafeString(mailAccountId)).Str(logBlobAccountId, log.SafeString(blobAccountId)) l = contextAppender(l) logger := log.From(l) @@ -285,7 +285,7 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request) func (g *Groupware) getEmailsSince(w http.ResponseWriter, r *http.Request, since string) { g.respond(w, r, func(req Request) Response { - l := req.logger.With().Str(QueryParamSince, since) + l := req.logger.With().Str(QueryParamSince, log.SafeString(since)) maxChanges, ok, err := req.parseUIntParam(QueryParamMaxChanges, 0) if err != nil { return errorResponse(err) @@ -497,7 +497,7 @@ func (g *Groupware) searchEmails(w http.ResponseWriter, r *http.Request) { if err != nil { return errorResponse(err) } - logger = log.From(logger.With().Str(logAccountId, accountId)) + logger = log.From(logger.With().Str(logAccountId, log.SafeString(accountId))) results, sessionState, lang, jerr := g.jmap.QueryEmailsWithSnippets(accountId, filter, req.session, req.ctx, logger, req.language(), offset, limit, fetchBodies, g.maxBodyValueBytes) if jerr != nil { @@ -530,7 +530,7 @@ func (g *Groupware) searchEmails(w http.ResponseWriter, r *http.Request) { if err != nil { return errorResponse(err) } - logger = log.From(logger.With().Str(logAccountId, accountId)) + logger = log.From(logger.With().Str(logAccountId, log.SafeString(accountId))) results, sessionState, lang, jerr := g.jmap.QueryEmailSnippets(accountId, filter, req.session, req.ctx, logger, req.language(), offset, limit) if jerr != nil { @@ -581,7 +581,7 @@ func (g *Groupware) CreateEmail(w http.ResponseWriter, r *http.Request) { if gwerr != nil { return errorResponse(gwerr) } - logger = log.From(logger.With().Str(logAccountId, accountId)) + logger = log.From(logger.With().Str(logAccountId, log.SafeString(accountId))) var body EmailCreation err := req.body(&body) @@ -619,12 +619,20 @@ func (g *Groupware) CreateEmail(w http.ResponseWriter, r *http.Request) { }) } +// swagger:parameters update_email +type SwaggerUpdateEmailBody struct { + // List of identifiers of emails to delete. + // in: body + // example: ["caen3iujoo8u", "aec8phaetaiz", "bohna0me"] + Body map[string]string +} + func (g *Groupware) UpdateEmail(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { emailId := chi.URLParam(r, UriParamEmailId) l := req.logger.With() - l.Str(UriParamEmailId, emailId) + l.Str(UriParamEmailId, log.SafeString(emailId)) accountId, gwerr := req.GetAccountIdForMail() if gwerr != nil { @@ -661,9 +669,218 @@ func (g *Groupware) UpdateEmail(w http.ResponseWriter, r *http.Request) { return response(updatedEmail, sessionState, lang) }) - } +type emailKeywordUpdates struct { + Add []string `json:"add,omitempty"` + Remove []string `json:"remove,omitempty"` +} + +func (e emailKeywordUpdates) IsEmpty() bool { + return len(e.Add) == 0 && len(e.Remove) == 0 +} + +func (g *Groupware) UpdateEmailKeywords(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + emailId := chi.URLParam(r, UriParamEmailId) + + l := req.logger.With() + l.Str(UriParamEmailId, log.SafeString(emailId)) + + accountId, gwerr := req.GetAccountIdForMail() + if gwerr != nil { + return errorResponse(gwerr) + } + l.Str(logAccountId, accountId) + + logger := log.From(l) + + var body emailKeywordUpdates + err := req.body(&body) + if err != nil { + return errorResponse(err) + } + + if body.IsEmpty() { + return noContentResponse(req.session.State) + } + + patch := map[string]*bool{} + truth := true + for _, keyword := range body.Add { + patch[keyword] = &truth + } + for _, keyword := range body.Remove { + patch[keyword] = nil + } + patches := map[string]jmap.EmailUpdate{ + emailId: { + "keywords": patch, + }, + } + + result, sessionState, lang, jerr := g.jmap.UpdateEmails(accountId, patches, req.session, req.ctx, logger, req.language()) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + + if result.Updated == nil { + return errorResponse(apiError(req.errorId(), ErrorApiInconsistency, withTitle("API Inconsistency: Missing Email Update Response", + "An internal API behaved unexpectedly: missing Email update response from JMAP endpoint"))) + } + updatedEmail, ok := result.Updated[emailId] + if !ok { + return errorResponse(apiError(req.errorId(), ErrorApiInconsistency, withTitle("API Inconsistency: Wrong Email Update Response ID", + "An internal API behaved unexpectedly: wrong Email update ID response from JMAP endpoint"))) + } + + return response(updatedEmail, sessionState, lang) + }) +} + +// swagger:route POST /groupware/accounts/{account}/emails/{emailid}/keywords email add_email_keywords +// Add keywords to an email by its unique identifier. +// +// responses: +// +// 204: Success204 +// 400: ErrorResponse400 +// 404: ErrorResponse404 +// 500: ErrorResponse500 +func (g *Groupware) AddEmailKeywords(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + emailId := chi.URLParam(r, UriParamEmailId) + + l := req.logger.With() + l.Str(UriParamEmailId, log.SafeString(emailId)) + + accountId, gwerr := req.GetAccountIdForMail() + if gwerr != nil { + return errorResponse(gwerr) + } + l.Str(logAccountId, accountId) + + logger := log.From(l) + + var body []string + err := req.body(&body) + if err != nil { + return errorResponse(err) + } + + if len(body) < 1 { + return noContentResponse(req.session.State) + } + + patch := map[string]bool{} + for _, keyword := range body { + patch[keyword] = true + } + patches := map[string]jmap.EmailUpdate{ + emailId: { + "keywords": patch, + }, + } + + result, sessionState, lang, jerr := g.jmap.UpdateEmails(accountId, patches, req.session, req.ctx, logger, req.language()) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + + if result.Updated == nil { + return errorResponse(apiError(req.errorId(), ErrorApiInconsistency, withTitle("API Inconsistency: Missing Email Update Response", + "An internal API behaved unexpectedly: missing Email update response from JMAP endpoint"))) + } + updatedEmail, ok := result.Updated[emailId] + if !ok { + return errorResponse(apiError(req.errorId(), ErrorApiInconsistency, withTitle("API Inconsistency: Wrong Email Update Response ID", + "An internal API behaved unexpectedly: wrong Email update ID response from JMAP endpoint"))) + } + + if updatedEmail == nil { + return noContentResponseWithEtag(sessionState, result.State) + } else { + return response(updatedEmail, sessionState, lang) + } + }) +} + +// swagger:route DELETE /groupware/accounts/{account}/emails/{emailid}/keywords email remove_email_keywords +// Remove keywords of an email by its unique identifier. +// +// responses: +// +// 204: Success204 +// 400: ErrorResponse400 +// 404: ErrorResponse404 +// 500: ErrorResponse500 +func (g *Groupware) RemoveEmailKeywords(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + emailId := chi.URLParam(r, UriParamEmailId) + + l := req.logger.With() + l.Str(UriParamEmailId, log.SafeString(emailId)) + + accountId, gwerr := req.GetAccountIdForMail() + if gwerr != nil { + return errorResponse(gwerr) + } + l.Str(logAccountId, accountId) + + logger := log.From(l) + + var body []string + err := req.body(&body) + if err != nil { + return errorResponse(err) + } + + if len(body) < 1 { + return noContentResponse(req.session.State) + } + + patch := map[string]*bool{} + for _, keyword := range body { + patch[keyword] = nil + } + patches := map[string]jmap.EmailUpdate{ + emailId: { + "keywords": patch, + }, + } + + result, sessionState, lang, jerr := g.jmap.UpdateEmails(accountId, patches, req.session, req.ctx, logger, req.language()) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + + if result.Updated == nil { + return errorResponse(apiError(req.errorId(), ErrorApiInconsistency, withTitle("API Inconsistency: Missing Email Update Response", + "An internal API behaved unexpectedly: missing Email update response from JMAP endpoint"))) + } + updatedEmail, ok := result.Updated[emailId] + if !ok { + return errorResponse(apiError(req.errorId(), ErrorApiInconsistency, withTitle("API Inconsistency: Wrong Email Update Response ID", + "An internal API behaved unexpectedly: wrong Email update ID response from JMAP endpoint"))) + } + + if updatedEmail == nil { + return noContentResponseWithEtag(sessionState, result.State) + } else { + return response(updatedEmail, sessionState, lang) + } + }) +} + +// swagger:route DELETE /groupware/accounts/{account}/emails/{emailid} email delete_email +// Delete an email by its unique identifier. +// +// responses: +// +// 204: Success204 +// 400: ErrorResponse400 +// 404: ErrorResponse404 +// 500: ErrorResponse500 func (g *Groupware) DeleteEmail(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { emailId := chi.URLParam(r, UriParamEmailId) @@ -679,12 +896,86 @@ func (g *Groupware) DeleteEmail(w http.ResponseWriter, r *http.Request) { logger := log.From(l) - _, sessionState, _, jerr := g.jmap.DeleteEmails(accountId, []string{emailId}, req.session, req.ctx, logger, req.language()) + resp, sessionState, _, jerr := g.jmap.DeleteEmails(accountId, []string{emailId}, req.session, req.ctx, logger, req.language()) if jerr != nil { return req.errorResponseFromJmap(jerr) } - return noContentResponse(sessionState) + for _, e := range resp.NotDestroyed { + desc := e.Description + if desc != "" { + return errorResponseWithSessionState(apiError( + req.errorId(), + ErrorFailedToDeleteEmail, + withDetail(e.Description), + ), sessionState) + } else { + return errorResponseWithSessionState(apiError( + req.errorId(), + ErrorFailedToDeleteEmail, + ), sessionState) + } + } + return noContentResponseWithEtag(sessionState, resp.State) + }) +} + +// swagger:parameters delete_emails +type SwaggerDeleteEmailsBody struct { + // List of identifiers of emails to delete. + // in: body + // example: ["caen3iujoo8u", "aec8phaetaiz", "bohna0me"] + Body []string +} + +// swagger:route DELETE /groupware/accounts/{account}/emails email delete_emails +// Delete a set of emails by their unique identifiers. +// +// The identifiers of the emails to delete are specified as part of the request +// body, as an array of strings. +// +// responses: +// +// 204: Success204 +// 400: ErrorResponse400 +// 404: ErrorResponse404 +// 500: ErrorResponse500 +func (g *Groupware) DeleteEmails(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + var emailIds []string + err := req.body(&emailIds) + if err != nil { + return errorResponse(err) + } + + l := req.logger.With() + l.Array("emailIds", log.SafeStringArray(emailIds)) + + accountId, gwerr := req.GetAccountIdForMail() + if gwerr != nil { + return errorResponse(gwerr) + } + l.Str(logAccountId, accountId) + + logger := log.From(l) + + resp, sessionState, _, jerr := g.jmap.DeleteEmails(accountId, emailIds, req.session, req.ctx, logger, req.language()) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + + if len(resp.NotDestroyed) > 0 { + meta := make(map[string]any, len(resp.NotDestroyed)) + for emailId, e := range resp.NotDestroyed { + meta[emailId] = e.Description + } + return errorResponseWithSessionState(apiError( + req.errorId(), + ErrorFailedToDeleteEmail, + withMeta(meta), + ), sessionState) + } + return noContentResponseWithEtag(sessionState, resp.State) }) } @@ -764,7 +1055,7 @@ func (g *Groupware) RelatedToEmail(w http.ResponseWriter, r *http.Request) { if gwerr != nil { return errorResponse(gwerr) } - l = l.Str(logAccountId, accountId) + l = l.Str(logAccountId, log.SafeString(accountId)) logger := log.From(l) @@ -1042,16 +1333,22 @@ type SwaggerGetLatestEmailsSummaryForAllAccounts200 struct { // swagger:parameters get_latest_emails_summary_for_all_accounts type SwaggerGetLatestEmailsSummaryForAllAccountsParams struct { + // The maximum amount of email summaries to return. // in: query // example: 10 + // default: 10 Limit uint `json:"limit"` + // Whether to include emails that have already been seen (read) or not. // in: query // example: true - Unread bool `json:"unread"` + // default: false + Seen bool `json:"seen"` + // Whether to include emails that have been flagged as junk or phishing. // in: query // example: false + // default: false Undesirable bool `json:"undesirable"` } @@ -1060,11 +1357,11 @@ type SwaggerGetLatestEmailsSummaryForAllAccountsParams struct { // // Retrieves summaries of the latest emails of a user, in all accounts, across all mailboxes. // -// The number of total summaries to retrieve is specified using the query parameter 'limit'. +// The number of total summaries to retrieve is specified using the query parameter `limit`. // // The following additional query parameters may be specified to further filter the emails to summarize: // -// !- `unread`: when `true`, only unread emails will be summarized (default is to summarize all emails, read or unread) +// !- `seen`: when `true`, emails that have already been seen (read) will be included as well (default is to only include emails that have not been read yet) // !- `undesirable`: when `true`, emails that are flagged as spam or phishing will also be summarized (default is to ignore those) // // responses: diff --git a/services/groupware/pkg/groupware/groupware_docs.go b/services/groupware/pkg/groupware/groupware_docs.go index ecba92680b..c4a759ae30 100644 --- a/services/groupware/pkg/groupware/groupware_docs.go +++ b/services/groupware/pkg/groupware/groupware_docs.go @@ -41,3 +41,8 @@ type SwaggerErrorResponse500 struct { *ErrorResponse } } + +// When the request succeeds. +// swagger:response Success204 +type SwaggerSuccess204 struct { +} diff --git a/services/groupware/pkg/groupware/groupware_error.go b/services/groupware/pkg/groupware/groupware_error.go index a80c7b4be5..fc2b69c0f2 100644 --- a/services/groupware/pkg/groupware/groupware_error.go +++ b/services/groupware/pkg/groupware/groupware_error.go @@ -194,6 +194,7 @@ const ( ErrorCodeMissingContactsAccountCapability = "MACCON" ErrorCodeMissingTasksSessionCapability = "MSCTSK" ErrorCodeMissingTaskAccountCapability = "MACTSK" + ErrorCodeFailedToDeleteEmail = "DELEML" ) var ( @@ -413,6 +414,12 @@ var ( Title: "Account is missing the task capability '" + jmap.JmapTasks + "'", Detail: "The JMAP Account of the user does not have the required capability '" + jmap.JmapTasks + "'.", } + ErrorFailedToDeleteEmail = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeFailedToDeleteEmail, + Title: "Failed to delete emails", + Detail: "One or more emails could not be deleted.", + } ) type ErrorOpt interface { diff --git a/services/groupware/pkg/groupware/groupware_response.go b/services/groupware/pkg/groupware/groupware_response.go index 49456c2fb5..51fec8c418 100644 --- a/services/groupware/pkg/groupware/groupware_response.go +++ b/services/groupware/pkg/groupware/groupware_response.go @@ -73,6 +73,16 @@ func noContentResponse(sessionState jmap.SessionState) Response { } } +func noContentResponseWithEtag(sessionState jmap.SessionState, etag jmap.State) Response { + return Response{ + body: nil, + status: http.StatusNoContent, + err: nil, + etag: etag, + sessionState: sessionState, + } +} + /* func acceptedResponse(sessionState jmap.SessionState) Response { return Response{ diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go index ec1e9af1b3..70033cffa5 100644 --- a/services/groupware/pkg/groupware/groupware_route.go +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -82,9 +82,13 @@ func (g *Groupware) Route(r chi.Router) { r.Route("/emails", func(r chi.Router) { r.Get("/", g.GetEmails) // ?fetchemails=true&fetchbodies=true&text=&subject=&body=&keyword=&keyword=&... r.Post("/", g.CreateEmail) + r.Delete("/", g.DeleteEmails) r.Get("/{emailid}", g.GetEmailsById) // r.Put("/{emailid}", g.ReplaceEmail) // TODO r.Patch("/{emailid}", g.UpdateEmail) + r.Patch("/{emailid}/keywords", g.UpdateEmailKeywords) + r.Post("/{emailid}/keywords", g.AddEmailKeywords) + r.Delete("/{emailid}/keywords", g.RemoveEmailKeywords) r.Delete("/{emailid}", g.DeleteEmail) Report(r, "/{emailid}", g.RelatedToEmail) r.Get("/{emailid}/attachments", g.GetEmailAttachments) // ?partId=&name=?&blobId=?