diff --git a/pkg/jmap/jmap_api_email.go b/pkg/jmap/jmap_api_email.go index c490af6e2c..987415a1ad 100644 --- a/pkg/jmap/jmap_api_email.go +++ b/pkg/jmap/jmap_api_email.go @@ -730,9 +730,11 @@ func (j *Client) UpdateEmails(accountId string, updates map[string]EmailUpdate, if err != nil { return nil, "", err } - if len(setResponse.NotUpdated) != len(updates) { - // error occured - // TODO(pbleser-oc) handle submission errors + if len(setResponse.NotUpdated) > 0 { + // TODO we don't have composite errors + for _, notUpdated := range setResponse.NotUpdated { + return nil, "", setErrorError(notUpdated, EmailType) + } } return setResponse.Updated, setResponse.NewState, nil }) @@ -783,29 +785,49 @@ type SubmittedEmail struct { MdnBlobIds []string `json:"mdnBlobIds,omitempty"` } -func (j *Client) SubmitEmail(accountId string, identityId string, emailId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, data []byte) (SubmittedEmail, SessionState, State, Language, Error) { +type MoveMail struct { + FromMailboxId string + ToMailboxId string +} + +func (j *Client) SubmitEmail(accountId string, identityId string, emailId string, move *MoveMail, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (EmailSubmission, SessionState, State, Language, Error) { + logger = j.logger("SubmitEmail", session, logger) + + update := map[string]any{ + EmailPropertyKeywords + "/" + JmapKeywordDraft: nil, // unmark as draft + EmailPropertyKeywords + "/" + JmapKeywordSeen: true, // mark as seen (read) + } + if move != nil && move.FromMailboxId != "" && move.ToMailboxId != "" && move.FromMailboxId != move.ToMailboxId { + update[EmailPropertyMailboxIds+"/"+move.FromMailboxId] = nil + update[EmailPropertyMailboxIds+"/"+move.ToMailboxId] = true + } + + id := "s0" + set := EmailSubmissionSetCommand{ AccountId: accountId, Create: map[string]EmailSubmissionCreate{ - "s0": { + id: { IdentityId: identityId, EmailId: emailId, + // leaving Envelope empty }, }, OnSuccessUpdateEmail: map[string]PatchObject{ - "#s0": { - EmailPropertyKeywords + "/" + JmapKeywordDraft: nil, - }, + "#" + id: update, }, } - get := EmailSubmissionGetRefCommand{ + get := EmailSubmissionGetCommand{ AccountId: accountId, - IdRef: &ResultReference{ - ResultOf: "0", - Name: CommandEmailSubmissionSet, - Path: "/created/s0/" + EmailPropertyId, - }, + Ids: []string{"#" + id}, + /* + IdRef: &ResultReference{ + ResultOf: "0", + Name: CommandEmailSubmissionSet, + Path: ["#"]"/created/" + "#" + id + "/" + EmailPropertyId, + }, + */ } cmd, err := j.request(session, logger, @@ -813,14 +835,14 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string invocation(CommandEmailSubmissionGet, get, "1"), ) if err != nil { - return SubmittedEmail{}, "", "", "", err + return EmailSubmission{}, "", "", "", err } - return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (SubmittedEmail, State, Error) { + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (EmailSubmission, State, Error) { var submissionResponse EmailSubmissionSetResponse err = retrieveResponseMatchParameters(logger, body, CommandEmailSubmissionSet, "0", &submissionResponse) if err != nil { - return SubmittedEmail{}, "", err + return EmailSubmission{}, "", err } if len(submissionResponse.NotCreated) > 0 { @@ -836,31 +858,28 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string var setResponse EmailSetResponse err = retrieveResponseMatchParameters(logger, body, CommandEmailSet, "0", &setResponse) if err != nil { - return SubmittedEmail{}, "", err + return EmailSubmission{}, "", err } - var getResponse EmailSubmissionGetResponse - err = retrieveResponseMatchParameters(logger, body, CommandEmailSubmissionGet, "1", &getResponse) - if err != nil { - return SubmittedEmail{}, "", err + if emailId := structs.FirstKey(setResponse.Updated); emailId != nil && len(setResponse.Updated) == 1 { + var getResponse EmailSubmissionGetResponse + err = retrieveResponseMatchParameters(logger, body, CommandEmailSubmissionGet, "1", &getResponse) + if err != nil { + return EmailSubmission{}, "", err + } + + if len(getResponse.List) != 1 { + // for some reason (error?)... + // TODO(pbleser-oc) handle absence of emailsubmission + } + + submission := getResponse.List[0] + + return submission, setResponse.NewState, nil + } else { + err = simpleError(fmt.Errorf("failed to submit email: updated is empty"), 0) // TODO proper error handling + return EmailSubmission{}, "", 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, - SendAt: submission.SendAt, - ThreadId: submission.ThreadId, - UndoStatus: submission.UndoStatus, - Envelope: submission.Envelope, - DsnBlobIds: submission.DsnBlobIds, - MdnBlobIds: submission.MdnBlobIds, - }, setResponse.NewState, nil }) } diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index 8a6ae7599b..ee34ad858e 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -2540,6 +2540,24 @@ type EmailSubmission struct { MdnBlobIds []string `json:"mdnBlobIds,omitempty"` } +type EmailSubmissionGetCommand struct { + // The id of the account to use. + AccountId string `json:"accountId"` + + // The ids of the EmailSubmission objects to return. + // + // If null, then all records of the data type are returned, if this is supported for that data + // type and the number of records does not exceed the maxObjectsInGet limit. + Ids []string `json:"ids,omitempty"` + + // If supplied, only the properties listed in the array are returned for each EmailSubmission object. + // + // If null, all properties of the object are returned. The id property of the object is always returned, + // even if not explicitly requested. If an invalid property is requested, the call MUST be rejected + // with an invalidArguments error. + Properties []string `json:"properties,omitempty"` +} + type EmailSubmissionGetRefCommand struct { // The id of the account to use. AccountId string `json:"accountId"` @@ -2952,6 +2970,18 @@ type EmailCreate struct { // ["From:" field]: https://www.rfc-editor.org/rfc/rfc5322.html#section-3.6.2 From []EmailAddress `json:"from,omitempty"` + // The value is identical to the value of header:To:asAddresses. + To []EmailAddress `json:"to,omitempty"` + + // The value is identical to the value of header:Cc:asAddresses. + Cc []EmailAddress `json:"cc,omitempty"` + + // The value is identical to the value of header:Bcc:asAddresses. + Bcc []EmailAddress `json:"bcc,omitempty"` + + // The value is identical to the value of header:Reply-To:asAddresses. + ReplyTo []EmailAddress `json:"replyTo,omitempty"` + // The "Subject:" field contains a short string identifying the topic of the message. Subject string `json:"subject,omitempty"` @@ -2982,6 +3012,31 @@ type EmailCreate struct { // This is a map of partId to an EmailBodyValue object for none, some, or all text/* parts. BodyValues map[string]EmailBodyValue `json:"bodyValues,omitempty"` + + // A list of text/plain, text/html, image/*, audio/*, and/or video/* parts to display (sequentially) as the + // message body, with a preference for text/plain when alternative versions are available. + TextBody []EmailBodyPart `json:"textBody,omitempty"` + + // A list of text/plain, text/html, image/*, audio/*, and/or video/* parts to display (sequentially) as the + // message body, with a preference for text/html when alternative versions are available. + HtmlBody []EmailBodyPart `json:"htmlBody,omitempty"` + + // A list, traversing depth-first, of all parts in bodyStructure. + // + // They must satisfy either of the following conditions: + // + // - not of type multipart/* and not included in textBody or htmlBody + // - of type image/*, audio/*, or video/* and not in both textBody and htmlBody + // + // None of these parts include subParts, including message/* types. + // + // Attached messages may be fetched using the Email/parse method and the blobId. + // + // Note that a text/html body part HTML may reference image parts in attachments by using cid: + // links to reference the Content-Id, as defined in [RFC2392], or by referencing the Content-Location. + // + // [RFC2392]: https://www.rfc-editor.org/rfc/rfc2392.html + Attachments []EmailBodyPart `json:"attachments,omitempty"` } type EmailUpdate map[string]any diff --git a/pkg/structs/structs.go b/pkg/structs/structs.go index 6d95c5e07e..e9e888e467 100644 --- a/pkg/structs/structs.go +++ b/pkg/structs/structs.go @@ -137,3 +137,10 @@ func Missing[E comparable](expected, actual []E) []E { } return missing } + +func FirstKey[K comparable, V any](m map[K]V) *K { + for k := range m { + return &k + } + return nil +} diff --git a/services/groupware/pkg/groupware/groupware_api_emails.go b/services/groupware/pkg/groupware/groupware_api_emails.go index bd2f91fad1..ee08bb03cd 100644 --- a/services/groupware/pkg/groupware/groupware_api_emails.go +++ b/services/groupware/pkg/groupware/groupware_api_emails.go @@ -887,24 +887,13 @@ func (g *Groupware) CreateEmail(w http.ResponseWriter, r *http.Request) { } logger = log.From(logger.With().Str(logAccountId, log.SafeString(accountId))) - var body jmap.Email + var body jmap.EmailCreate err := req.body(&body) if err != nil { return errorResponse(err) } - create := jmap.EmailCreate{ - MailboxIds: body.MailboxIds, - Keywords: body.Keywords, - From: body.From, - Subject: body.Subject, - ReceivedAt: body.ReceivedAt, - SentAt: body.SentAt, - BodyStructure: body.BodyStructure, - BodyValues: body.BodyValues, - } - - created, sessionState, state, lang, jerr := g.jmap.CreateEmail(accountId, create, "", req.session, req.ctx, logger, req.language()) + created, sessionState, state, lang, jerr := g.jmap.CreateEmail(accountId, body, "", req.session, req.ctx, logger, req.language()) if jerr != nil { return req.errorResponseFromJmap(jerr) } @@ -926,24 +915,13 @@ func (g *Groupware) ReplaceEmail(w http.ResponseWriter, r *http.Request) { logger = log.From(logger.With().Str(logAccountId, log.SafeString(accountId))) - var body jmap.Email + var body jmap.EmailCreate err := req.body(&body) if err != nil { return errorResponse(err) } - create := jmap.EmailCreate{ - MailboxIds: body.MailboxIds, - Keywords: body.Keywords, - From: body.From, - Subject: body.Subject, - ReceivedAt: body.ReceivedAt, - SentAt: body.SentAt, - BodyStructure: body.BodyStructure, - BodyValues: body.BodyValues, - } - - created, sessionState, state, lang, jerr := g.jmap.CreateEmail(accountId, create, replaceId, req.session, req.ctx, logger, req.language()) + created, sessionState, state, lang, jerr := g.jmap.CreateEmail(accountId, body, replaceId, req.session, req.ctx, logger, req.language()) if jerr != nil { return req.errorResponseFromJmap(jerr) } @@ -1305,6 +1283,58 @@ func (g *Groupware) DeleteEmails(w http.ResponseWriter, r *http.Request) { }) } +func (g *Groupware) SendEmail(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)) + + identityId, err := req.getMandatoryStringParam(QueryParamIdentityId) + if err != nil { + return errorResponse(err) + } + l.Str(QueryParamIdentityId, log.SafeString(identityId)) + + var move *jmap.MoveMail = nil + { + moveFromMailboxId, _ := req.getStringParam(QueryParamMoveFromMailboxId, "") + moveToMailboxId, _ := req.getStringParam(QueryParamMoveToMailboxId, "") + if moveFromMailboxId != "" && moveToMailboxId != "" { + move = &jmap.MoveMail{FromMailboxId: moveFromMailboxId, ToMailboxId: moveToMailboxId} + l.Str(QueryParamMoveFromMailboxId, log.SafeString(moveFromMailboxId)).Str(QueryParamMoveToMailboxId, log.SafeString(moveFromMailboxId)) + } else if moveFromMailboxId == "" && moveToMailboxId == "" { + // nothing to change + } else { + missing := moveFromMailboxId + if moveFromMailboxId == "" { + missing = moveFromMailboxId + } + // only one is set + msg := fmt.Sprintf("Missing required value for query parameter '%v'", missing) + return errorResponse(req.observedParameterError(ErrorMissingMandatoryRequestParameter, + withDetail(msg), + withSource(&ErrorSource{Parameter: missing}))) + } + } + + accountId, gwerr := req.GetAccountIdForMail() + if gwerr != nil { + return errorResponse(gwerr) + } + l.Str(logAccountId, accountId) + + logger := log.From(l) + + resp, sessionState, state, lang, jerr := g.jmap.SubmitEmail(accountId, identityId, emailId, move, req.session, req.ctx, logger, req.language()) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + + return etagResponse(resp, sessionState, state, lang) + }) +} + type AboutEmailsEvent struct { Id string `json:"id"` Source string `json:"source"` @@ -1833,35 +1863,6 @@ func filterFromNotKeywords(keywords []string) jmap.EmailFilterElement { } } -func squashQueryState[V any](all map[string]V, mapper func(V) jmap.State) jmap.State { - n := len(all) - if n == 0 { - return jmap.State("") - } - if n == 1 { - for _, v := range all { - return mapper(v) - } - } - - parts := make([]string, n) - sortedKeys := make([]string, n) - i := 0 - for k := range all { - sortedKeys[i] = k - i++ - } - slices.Sort(sortedKeys) - for i, k := range sortedKeys { - if v, ok := all[k]; ok { - parts[i] = k + ":" + string(mapper(v)) - } else { - parts[i] = k + ":" - } - } - return jmap.State(strings.Join(parts, ",")) -} - var sanitizationPolicy *bluemonday.Policy = bluemonday.UGCPolicy() var sanitizableMediaTypes = []string{ diff --git a/services/groupware/pkg/groupware/groupware_error.go b/services/groupware/pkg/groupware/groupware_error.go index bb586d3928..b9ed2429b7 100644 --- a/services/groupware/pkg/groupware/groupware_error.go +++ b/services/groupware/pkg/groupware/groupware_error.go @@ -174,6 +174,7 @@ const ( ErrorCodeInvalidRequestPayload = "INVRQP" ErrorCodeInvalidResponsePayload = "INVRSP" ErrorCodeInvalidRequestParameter = "INVPAR" + ErrorCodeMissingMandatoryRequestParameter = "MISMPA" ErrorCodeInvalidRequestBody = "INVBDY" ErrorCodeNonExistingAccount = "INVACC" ErrorCodeIndeterminateAccount = "INDACC" @@ -297,6 +298,12 @@ var ( Title: "Invalid Request Parameter", Detail: "At least one of the parameters in the request is invalid.", } + ErrorMissingMandatoryRequestParameter = GroupwareError{ + Status: http.StatusBadRequest, + Code: ErrorCodeMissingMandatoryRequestParameter, + Title: "Missing Mandatory Request Parameter", + Detail: "A mandatory request parameter is missing.", + } ErrorInvalidRequestBody = GroupwareError{ Status: http.StatusBadRequest, Code: ErrorCodeInvalidRequestBody, diff --git a/services/groupware/pkg/groupware/groupware_request.go b/services/groupware/pkg/groupware/groupware_request.go index 2a9b4e3a0b..5ac5a74958 100644 --- a/services/groupware/pkg/groupware/groupware_request.go +++ b/services/groupware/pkg/groupware/groupware_request.go @@ -168,6 +168,34 @@ func (r Request) parameterErrorResponse(param string, detail string) Response { return errorResponse(r.parameterError(param, detail)) } +func (r Request) getStringParam(param string, defaultValue string) (string, bool) { + q := r.r.URL.Query() + if !q.Has(param) { + return defaultValue, false + } + str := q.Get(param) + if str == "" { + return defaultValue, false + } + return str, true +} + +func (r Request) getMandatoryStringParam(param string) (string, *Error) { + str := "" + q := r.r.URL.Query() + if q.Has(param) { + str = q.Get(param) + } + if str == "" { + msg := fmt.Sprintf("Missing required value for query parameter '%v'", param) + return "", r.observedParameterError(ErrorMissingMandatoryRequestParameter, + withDetail(msg), + withSource(&ErrorSource{Parameter: param}), + ) + } + return str, nil +} + func (r Request) parseIntParam(param string, defaultValue int) (int, bool, *Error) { q := r.r.URL.Query() if !q.Has(param) { diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go index c15b1b69d4..f5df7f3b39 100644 --- a/services/groupware/pkg/groupware/groupware_route.go +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -30,6 +30,9 @@ const ( QueryParamSince = "since" QueryParamMaxChanges = "maxchanges" QueryParamMailboxId = "mailbox" + QueryParamIdentityId = "identity" + QueryParamMoveFromMailboxId = "move-from" + QueryParamMoveToMailboxId = "move-to" QueryParamNotInMailboxId = "notmailbox" QueryParamSearchText = "text" QueryParamSearchFrom = "from" @@ -74,43 +77,58 @@ func (g *Groupware) Route(r chi.Router) { r.Get("/", g.GetEmailsForAllAccounts) r.Get("/latest/summary", g.GetLatestEmailsSummaryForAllAccounts) // ?limit=10&seen=true&undesirable=true }) - r.Get("/quota", g.GetQuotaForAllAccounts) + r.Route("/quota", func(r chi.Router) { + r.Get("/", g.GetQuotaForAllAccounts) + }) }) r.Route("/{accountid}", func(r chi.Router) { r.Get("/", g.GetAccount) r.Route("/identities", func(r chi.Router) { r.Get("/", g.GetIdentities) - r.Get("/{identityid}", g.GetIdentityById) r.Post("/", g.AddIdentity) - r.Patch("/{identityid}", g.ModifyIdentity) - r.Delete("/{identityid}", g.DeleteIdentity) + r.Route("/{identityid}", func(r chi.Router) { + r.Get("/", g.GetIdentityById) + r.Patch("/", g.ModifyIdentity) + r.Delete("/", g.DeleteIdentity) + }) }) r.Get("/vacation", g.GetVacation) r.Put("/vacation", g.SetVacation) r.Get("/quota", g.GetQuota) r.Route("/mailboxes", func(r chi.Router) { r.Get("/", g.GetMailboxes) // ?name=&role=&subcribed= - r.Get("/{mailboxid}", g.GetMailbox) - r.Get("/{mailboxid}/emails", g.GetAllEmailsInMailbox) - r.Get("/{mailboxid}/changes", g.GetMailboxChanges) r.Post("/", g.CreateMailbox) - r.Patch("/{mailboxid}", g.UpdateMailbox) - r.Delete("/{mailboxid}", g.DeleteMailbox) + r.Route("/{mailboxid}", func(r chi.Router) { + r.Get("/", g.GetMailbox) + r.Get("/emails", g.GetAllEmailsInMailbox) + r.Get("/changes", g.GetMailboxChanges) + r.Patch("/", g.UpdateMailbox) + r.Delete("/", g.DeleteMailbox) + }) }) 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) // Accept:message/rfc822 - r.Put("/{emailid}", g.ReplaceEmail) - 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}/related", g.RelatedToEmail) - r.Get("/{emailid}/attachments", g.GetEmailAttachments) // ?partId=&name=?&blobId=? + r.Route("/{emailid}", func(r chi.Router) { + r.Get("/", g.GetEmailsById) // Accept:message/rfc822 + r.Put("/", g.ReplaceEmail) + r.Post("/", g.SendEmail) + r.Patch("/", g.UpdateEmail) + r.Delete("/", g.DeleteEmail) + Report(r, "/", g.RelatedToEmail) + r.Route("/related", func(r chi.Router) { + r.Get("/", g.RelatedToEmail) + }) + r.Route("/keywords", func(r chi.Router) { + r.Patch("/", g.UpdateEmailKeywords) + r.Post("/", g.AddEmailKeywords) + r.Delete("/", g.RemoveEmailKeywords) + }) + r.Route("/attachments", func(r chi.Router) { + r.Get("/", g.GetEmailAttachments) // ?partId=&name=?&blobId=? + }) + }) }) r.Route("/blobs", func(r chi.Router) { r.Get("/{blobid}", g.GetBlobMeta) @@ -121,8 +139,10 @@ func (g *Groupware) Route(r chi.Router) { }) r.Route("/addressbooks", func(r chi.Router) { r.Get("/", g.GetAddressbooks) - r.Get("/{addressbookid}", g.GetAddressbook) - r.Get("/{addressbookid}/contacts", g.GetContactsInAddressbook) + r.Route("/{addressbookid}", func(r chi.Router) { + r.Get("/", g.GetAddressbook) + r.Get("/contacts", g.GetContactsInAddressbook) + }) r.Route("/contacts", func(r chi.Router) { r.Post("/", g.CreateContact) r.Delete("/{contactid}", g.DeleteContact) @@ -130,13 +150,17 @@ func (g *Groupware) Route(r chi.Router) { }) r.Route("/calendars", func(r chi.Router) { r.Get("/", g.GetCalendars) - r.Get("/{calendarid}", g.GetCalendarById) - r.Get("/{calendarid}/events", g.GetEventsInCalendar) + r.Route("/{calendarid}", func(r chi.Router) { + r.Get("/", g.GetCalendarById) + r.Get("/events", g.GetEventsInCalendar) + }) }) r.Route("/tasklists", func(r chi.Router) { r.Get("/", g.GetTaskLists) - r.Get("/{tasklistid}", g.GetTaskListById) - r.Get("/{tasklistid}/tasks", g.GetTasksInTaskList) + r.Route("/{tasklistid}", func(r chi.Router) { + r.Get("/", g.GetTaskListById) + r.Get("/tasks", g.GetTasksInTaskList) + }) }) }) })