diff --git a/pkg/jmap/jmap_api_email.go b/pkg/jmap/jmap_api_email.go index 3d62d48491..c856f0b3ed 100644 --- a/pkg/jmap/jmap_api_email.go +++ b/pkg/jmap/jmap_api_email.go @@ -55,6 +55,29 @@ func (j *Client) GetEmails(accountId string, session *Session, ctx context.Conte }) } +func (j *Client) GetEmailBlobId(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, id string) (string, SessionState, Language, Error) { + logger = j.logger("GetEmailBlobId", session, logger) + + get := EmailGetCommand{AccountId: accountId, Ids: []string{id}, FetchAllBodyValues: false, Properties: []string{"blobId"}} + cmd, err := j.request(session, logger, invocation(CommandEmailGet, get, "0")) + if err != nil { + logger.Error().Err(err).Send() + return "", "", "", simpleError(err, JmapErrorInvalidJmapRequestPayload) + } + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (string, Error) { + var response EmailGetResponse + err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, "0", &response) + if err != nil { + return "", err + } + if len(response.List) != 1 { + return "", nil + } + email := response.List[0] + return email.BlobId, nil + }) +} + // Retrieve all the Emails in a given Mailbox by its id. func (j *Client) GetAllEmailsInMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, mailboxId string, offset uint, limit uint, collapseThreads bool, fetchBodies bool, maxBodyValueBytes uint) (Emails, SessionState, Language, Error) { logger = j.loggerParams("GetAllEmailsInMailbox", session, logger, func(z zerolog.Context) zerolog.Context { diff --git a/services/groupware/pkg/groupware/groupware_api_blob.go b/services/groupware/pkg/groupware/groupware_api_blob.go index e479303650..73ae4c198c 100644 --- a/services/groupware/pkg/groupware/groupware_api_blob.go +++ b/services/groupware/pkg/groupware/groupware_api_blob.go @@ -79,9 +79,6 @@ func (g *Groupware) DownloadBlob(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(req.r, UriParamBlobName) q := req.r.URL.Query() typ := q.Get(QueryParamBlobType) - if typ == "" { - typ = DefaultBlobDownloadType - } accountId, gwerr := req.GetAccountIdForBlob() if gwerr != nil { @@ -89,44 +86,51 @@ func (g *Groupware) DownloadBlob(w http.ResponseWriter, r *http.Request) { } logger := log.From(req.logger.With().Str(logAccountId, accountId)) - blob, lang, jerr := g.jmap.DownloadBlobStream(accountId, blobId, name, typ, req.session, req.ctx, logger, req.language()) - if blob != nil && blob.Body != nil { - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - logger.Error().Err(err).Msg("failed to close response body") - } - }(blob.Body) - } - if jerr != nil { - return req.apiErrorFromJmap(jerr) - } - if blob == nil { - w.WriteHeader(http.StatusNotFound) - return nil - } - - if blob.Type != "" { - w.Header().Add("Content-Type", blob.Type) - } - if blob.CacheControl != "" { - w.Header().Add("Cache-Control", blob.CacheControl) - } - if blob.ContentDisposition != "" { - w.Header().Add("Content-Disposition", blob.ContentDisposition) - } - if blob.Size >= 0 { - w.Header().Add("Content-Size", strconv.Itoa(blob.Size)) - } - if lang != "" { - w.Header().Add("Content-Language", string(lang)) - } - - _, err := io.Copy(w, blob.Body) - if err != nil { - return req.observedParameterError(ErrorStreamingResponse) - } - - return nil + return req.serveBlob(blobId, name, typ, logger, accountId, w) }) } + +func (r *Request) serveBlob(blobId string, name string, typ string, logger *log.Logger, accountId string, w http.ResponseWriter) *Error { + if typ == "" { + typ = DefaultBlobDownloadType + } + blob, lang, jerr := r.g.jmap.DownloadBlobStream(accountId, blobId, name, typ, r.session, r.ctx, logger, r.language()) + if blob != nil && blob.Body != nil { + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + logger.Error().Err(err).Msg("failed to close response body") + } + }(blob.Body) + } + if jerr != nil { + return r.apiErrorFromJmap(jerr) + } + if blob == nil { + w.WriteHeader(http.StatusNotFound) + return nil + } + + if blob.Type != "" { + w.Header().Add("Content-Type", blob.Type) + } + if blob.CacheControl != "" { + w.Header().Add("Cache-Control", blob.CacheControl) + } + if blob.ContentDisposition != "" { + w.Header().Add("Content-Disposition", blob.ContentDisposition) + } + if blob.Size >= 0 { + w.Header().Add("Content-Size", strconv.Itoa(blob.Size)) + } + if lang != "" { + w.Header().Add("Content-Language", string(lang)) + } + + _, err := io.Copy(w, blob.Body) + if err != nil { + return r.observedParameterError(ErrorStreamingResponse) + } + + return nil +} diff --git a/services/groupware/pkg/groupware/groupware_api_emails.go b/services/groupware/pkg/groupware/groupware_api_emails.go index 1ab735c2e9..0e90b780ae 100644 --- a/services/groupware/pkg/groupware/groupware_api_emails.go +++ b/services/groupware/pkg/groupware/groupware_api_emails.go @@ -135,42 +135,77 @@ func (g *Groupware) GetAllEmailsInMailbox(w http.ResponseWriter, r *http.Request func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, UriParamEmailId) - g.respond(w, r, func(req Request) Response { - ids := strings.Split(id, ",") - if len(ids) < 1 { - return req.parameterErrorResponse(UriParamEmailId, fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamEmailId, log.SafeString(id), "empty list of mail ids")) - } + ids := strings.Split(id, ",") - accountId, err := req.GetAccountIdForMail() - if err != nil { - return errorResponse(err) - } + accept := r.Header.Get("Accept") + if accept == "message/rfc822" { + g.stream(w, r, func(req Request, w http.ResponseWriter) *Error { + if len(ids) != 1 { + return req.parameterError(UriParamEmailId, fmt.Sprintf("when the Accept header is set to '%s', the API only supports serving a single email id", accept)) + } - l := req.logger.With().Str(logAccountId, log.SafeString(accountId)) - if len(ids) == 1 { - logger := log.From(l.Str("id", log.SafeString(id))) - emails, sessionState, lang, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, req.language(), ids, true, g.maxBodyValueBytes) + accountId, err := req.GetAccountIdForMail() + if err != nil { + return err + } + + logger := log.From(req.logger.With().Str(logAccountId, log.SafeString(accountId)).Str("id", log.SafeString(id)).Str("accept", log.SafeString(accept))) + + blobId, _, _, jerr := g.jmap.GetEmailBlobId(accountId, req.session, req.ctx, logger, req.language(), id) if jerr != nil { - return req.errorResponseFromJmap(jerr) + return req.apiErrorFromJmap(req.observeJmapError(jerr)) } - if len(emails.Emails) < 1 { - return notFoundResponse(sessionState) + if blobId == "" { + return nil } else { - return etagResponse(g.sanitizeEmail(emails.Emails[0]), sessionState, emails.State, lang) + name := blobId + ".eml" + typ := accept + accountId, gwerr := req.GetAccountIdForBlob() + if gwerr != nil { + return gwerr + } + return req.serveBlob(blobId, name, typ, logger, accountId, w) } - } else { - logger := log.From(l.Array("ids", log.SafeStringArray(ids))) - emails, sessionState, lang, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, req.language(), ids, true, g.maxBodyValueBytes) - if jerr != nil { - return req.errorResponseFromJmap(jerr) + }) + } else { + g.respond(w, r, func(req Request) Response { + if len(ids) < 1 { + return req.parameterErrorResponse(UriParamEmailId, fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamEmailId, log.SafeString(id), "empty list of mail ids")) } - if len(emails.Emails) < 1 { - return notFoundResponse(sessionState) + + accountId, err := req.GetAccountIdForMail() + if err != nil { + return errorResponse(err) + } + l := req.logger.With().Str(logAccountId, log.SafeString(accountId)) + if len(ids) == 1 { + l = l.Str("id", log.SafeString(id)) + logger := log.From(l) + + emails, sessionState, lang, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, req.language(), ids, true, g.maxBodyValueBytes) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + if len(emails.Emails) < 1 { + return notFoundResponse(sessionState) + } else { + return etagResponse(g.sanitizeEmail(emails.Emails[0]), sessionState, emails.State, lang) + } } else { - return etagResponse(g.sanitizeEmails(emails.Emails), sessionState, emails.State, lang) + logger := log.From(l.Array("ids", log.SafeStringArray(ids))) + + emails, sessionState, lang, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, req.language(), ids, true, g.maxBodyValueBytes) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + if len(emails.Emails) < 1 { + return notFoundResponse(sessionState) + } else { + return etagResponse(g.sanitizeEmails(emails.Emails), sessionState, emails.State, lang) + } } - } - }) + }) + } } func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request) { @@ -369,6 +404,7 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, boo subject := q.Get(QueryParamSearchSubject) body := q.Get(QueryParamSearchBody) keywords := q[QueryParamSearchKeyword] + messageId := q.Get(QueryParamSearchMessageId) snippets := false @@ -433,6 +469,9 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, boo if body != "" { l = l.Str(QueryParamSearchBody, log.SafeString(body)) } + if messageId != "" { + l = l.Str(QueryParamSearchMessageId, log.SafeString(messageId)) + } minSize, ok, err := req.parseIntParam(QueryParamSearchMinSize, 0) if err != nil { @@ -468,6 +507,14 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, boo After: after, MinSize: minSize, MaxSize: maxSize, + Header: []string{}, + } + if messageId != "" { + // The array MUST contain either one or two elements. + // The first element is the name of the header field to match against. + // The second (optional) element is the text to look for in the header field value. + // If not supplied, the message matches simply if it has a header field of the given name. + firstFilter.Header = []string{"Message-ID", messageId} } filter = &firstFilter diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go index 4c86ef394b..01a185cd12 100644 --- a/services/groupware/pkg/groupware/groupware_route.go +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -42,6 +42,7 @@ const ( QueryParamSearchMinSize = "minsize" QueryParamSearchMaxSize = "maxsize" QueryParamSearchKeyword = "keyword" + QueryParamSearchMessageId = "messageId" QueryParamSearchFetchBodies = "fetchbodies" QueryParamSearchFetchEmails = "fetchemails" QueryParamOffset = "offset" @@ -94,7 +95,7 @@ func (g *Groupware) Route(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.Get("/{emailid}", g.GetEmailsById) // Accept:message/rfc822 // r.Put("/{emailid}", g.ReplaceEmail) // TODO r.Patch("/{emailid}", g.UpdateEmail) r.Patch("/{emailid}/keywords", g.UpdateEmailKeywords) @@ -102,6 +103,7 @@ func (g *Groupware) Route(r chi.Router) { 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("/blobs", func(r chi.Router) { diff --git a/services/groupware/pkg/groupware/groupware_test.go b/services/groupware/pkg/groupware/groupware_test.go index 1017a1af3f..dac9191877 100644 --- a/services/groupware/pkg/groupware/groupware_test.go +++ b/services/groupware/pkg/groupware/groupware_test.go @@ -12,14 +12,22 @@ func TestSanitizeEmail(t *testing.T) { Subject: "test", BodyValues: map[string]jmap.EmailBodyValue{ "koze92I1": { - Value: `Google`, + Value: `Cyberdyne`, + }, + "zee7urae": { + Value: `Hello. Click here for AI slop.`, }, }, HtmlBody: []jmap.EmailBodyPart{ { PartId: "koze92I1", Type: "text/html", - Size: 65, + Size: 71, + }, + { + PartId: "zee7urae", + Type: "text/html", + Size: 81, }, }, } @@ -29,6 +37,8 @@ func TestSanitizeEmail(t *testing.T) { safe := g.sanitizeEmail(email) require := require.New(t) - require.Equal(`Google`, safe.BodyValues["koze92I1"].Value) - require.Equal(57, safe.HtmlBody[0].Size) + require.Equal(`Cyberdyne`, safe.BodyValues["koze92I1"].Value) + require.Equal(63, safe.HtmlBody[0].Size) + require.Equal(`Hello. Click here for AI slop.`, safe.BodyValues["zee7urae"].Value) + require.Equal(30, safe.HtmlBody[1].Size) }