groupware: add searching emails by their Message-Id + retrieving an email by its ID as message/rfc822

This commit is contained in:
Pascal Bleser
2025-10-20 16:02:03 +02:00
parent df4253102d
commit 8999caf5a1
5 changed files with 160 additions and 74 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -12,14 +12,22 @@ func TestSanitizeEmail(t *testing.T) {
Subject: "test",
BodyValues: map[string]jmap.EmailBodyValue{
"koze92I1": {
Value: `<a onblur="alert(secret)" href="http://www.google.com">Google</a>`,
Value: `<a onblur="alert(secret)" href="http://www.cyberdyne.com">Cyberdyne</a>`,
},
"zee7urae": {
Value: `Hello. <a onblur="hack()" href="file:///download.exe">Click here</a> 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(`<a href="http://www.google.com" rel="nofollow">Google</a>`, safe.BodyValues["koze92I1"].Value)
require.Equal(57, safe.HtmlBody[0].Size)
require.Equal(`<a href="http://www.cyberdyne.com" rel="nofollow">Cyberdyne</a>`, 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)
}