From 4620aa472728272ca44ccefd425ccceff24d4743 Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Thu, 9 Oct 2025 15:09:23 +0200
Subject: [PATCH] groupware: implement email updating and email keyword
updating endpoints
---
.../pkg/groupware/groupware_api_emails.go | 327 +++++++++++++++++-
.../groupware/pkg/groupware/groupware_docs.go | 5 +
.../pkg/groupware/groupware_error.go | 7 +
.../pkg/groupware/groupware_response.go | 10 +
.../pkg/groupware/groupware_route.go | 4 +
5 files changed, 338 insertions(+), 15 deletions(-)
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=?