From 5ae69edded13270cd0190221cf5e9d76f9db3b50 Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Wed, 22 Oct 2025 12:15:24 +0200 Subject: [PATCH] groupware: add threadSize in email-by-id response --- pkg/jmap/jmap_api_email.go | 167 ++++++++---------- pkg/jmap/jmap_model.go | 14 +- pkg/jmap/jmap_tools.go | 18 -- pkg/jmap/jmap_tools_test.go | 47 ----- .../pkg/groupware/groupware_api_emails.go | 76 +++++--- .../pkg/groupware/groupware_request.go | 1 + .../pkg/groupware/groupware_route.go | 2 +- 7 files changed, 131 insertions(+), 194 deletions(-) diff --git a/pkg/jmap/jmap_api_email.go b/pkg/jmap/jmap_api_email.go index f14b6f8fa..2799c3187 100644 --- a/pkg/jmap/jmap_api_email.go +++ b/pkg/jmap/jmap_api_email.go @@ -32,7 +32,7 @@ type Emails struct { } // Retrieve specific Emails by their id. -func (j *Client) GetEmails(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string, fetchBodies bool, maxBodyValueBytes uint, markAsSeen bool) (Emails, SessionState, Language, Error) { +func (j *Client) GetEmails(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string, fetchBodies bool, maxBodyValueBytes uint, markAsSeen bool, withThreads bool) (Emails, SessionState, Language, Error) { logger = j.logger("GetEmails", session, logger) get := EmailGetCommand{AccountId: accountId, Ids: ids, FetchAllBodyValues: fetchBodies} @@ -50,6 +50,17 @@ func (j *Client) GetEmails(accountId string, session *Session, ctx context.Conte mark := EmailSetCommand{AccountId: accountId, Update: updates} methodCalls = []Invocation{invocation(CommandEmailSet, mark, "0"), invokeGet} } + if withThreads { + threads := ThreadGetRefCommand{ + AccountId: accountId, + IdsRef: &ResultReference{ + ResultOf: "1", + Name: CommandEmailGet, + Path: "/list/*/" + EmailPropertyThreadId, + }, + } + methodCalls = append(methodCalls, invocation(CommandThreadGet, threads, "2")) + } cmd, err := j.request(session, logger, methodCalls...) if err != nil { @@ -73,6 +84,14 @@ func (j *Client) GetEmails(accountId string, session *Session, ctx context.Conte if err != nil { return Emails{}, err } + if withThreads { + var threads ThreadGetResponse + err = retrieveResponseMatchParameters(logger, body, CommandThreadGet, "2", &threads) + if err != nil { + return Emails{}, err + } + setThreadSize(&threads, response.List) + } return Emails{Emails: response.List, State: response.State}, nil }) } @@ -636,14 +655,19 @@ type CreatedEmail struct { State State `json:"state"` } -func (j *Client) CreateEmail(accountId string, email EmailCreate, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (CreatedEmail, SessionState, Language, Error) { +func (j *Client) CreateEmail(accountId string, email EmailCreate, replaceId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (CreatedEmail, SessionState, Language, Error) { + set := EmailSetCommand{ + AccountId: accountId, + Create: map[string]EmailCreate{ + "c": email, + }, + } + if replaceId != "" { + set.Destroy = []string{replaceId} + } + cmd, err := j.request(session, logger, - invocation(CommandEmailSubmissionSet, EmailSetCommand{ - AccountId: accountId, - Create: map[string]EmailCreate{ - "c": email, - }, - }, "0"), + invocation(CommandEmailSet, set, "0"), ) if err != nil { return CreatedEmail{}, "", "", err @@ -899,16 +923,6 @@ type EmailsSummary struct { State State `json:"state"` } -type EmailWithThread struct { - Email - ThreadSize int `json:"threadSize,omitzero"` -} - -type EmailsWithThreadSummary struct { - Emails []EmailWithThread `json:"emails"` - State State `json:"state"` -} - var EmailSummaryProperties = []string{ EmailPropertyId, EmailPropertyThreadId, @@ -928,21 +942,26 @@ var EmailSummaryProperties = []string{ EmailPropertyPreview, } -func (j *Client) QueryEmailSummaries(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, filter EmailFilterElement, limit uint) (map[string]EmailsSummary, SessionState, Language, Error) { +func (j *Client) QueryEmailSummaries(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, filter EmailFilterElement, limit uint, withThreads bool) (map[string]EmailsSummary, SessionState, Language, Error) { logger = j.logger("QueryEmailSummaries", session, logger) uniqueAccountIds := structs.Uniq(accountIds) - invocations := make([]Invocation, len(uniqueAccountIds)*2) + factor := 2 + if withThreads { + factor++ + } + + invocations := make([]Invocation, len(uniqueAccountIds)*factor) for i, accountId := range uniqueAccountIds { - invocations[i*2+0] = invocation(CommandEmailQuery, EmailQueryCommand{ + invocations[i*factor+0] = invocation(CommandEmailQuery, EmailQueryCommand{ AccountId: accountId, Filter: filter, Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}}, Limit: limit, //CalculateTotal: false, }, mcid(accountId, "0")) - invocations[i*2+1] = invocation(CommandEmailGet, EmailGetRefCommand{ + invocations[i*factor+1] = invocation(CommandEmailGet, EmailGetRefCommand{ AccountId: accountId, IdsRef: &ResultReference{ Name: CommandEmailQuery, @@ -951,6 +970,16 @@ func (j *Client) QueryEmailSummaries(accountIds []string, session *Session, ctx }, Properties: EmailSummaryProperties, }, mcid(accountId, "1")) + if withThreads { + invocations[i*factor+2] = invocation(CommandThreadGet, ThreadGetRefCommand{ + AccountId: accountId, + IdsRef: &ResultReference{ + Name: CommandEmailGet, + Path: "/list/*/" + EmailPropertyThreadId, + ResultOf: mcid(accountId, "1"), + }, + }, mcid(accountId, "2")) + } } cmd, err := j.request(session, logger, invocations...) if err != nil { @@ -968,87 +997,31 @@ func (j *Client) QueryEmailSummaries(accountIds []string, session *Session, ctx if len(response.NotFound) > 0 { // TODO what to do when there are not-found emails here? potentially nothing, they could have been deleted between query and get? } + if withThreads { + var thread ThreadGetResponse + err = retrieveResponseMatchParameters(logger, body, CommandThreadGet, mcid(accountId, "2"), &thread) + if err != nil { + return nil, err + } + setThreadSize(&thread, response.List) + } + resp[accountId] = EmailsSummary{Emails: response.List, State: response.State} } return resp, nil }) } -func (j *Client) QueryEmailSummariesWithThreadCount(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, filter EmailFilterElement, limit uint) (map[string]EmailsWithThreadSummary, SessionState, Language, Error) { - logger = j.logger("QueryEmailSummariesWithThreadCount", session, logger) - - uniqueAccountIds := structs.Uniq(accountIds) - - invocations := make([]Invocation, len(uniqueAccountIds)*3) - for i, accountId := range uniqueAccountIds { - invocations[i*3+0] = invocation(CommandEmailQuery, EmailQueryCommand{ - AccountId: accountId, - Filter: filter, - Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}}, - Limit: limit, - //CalculateTotal: false, - }, mcid(accountId, "0")) - invocations[i*3+1] = invocation(CommandEmailGet, EmailGetRefCommand{ - AccountId: accountId, - IdsRef: &ResultReference{ - Name: CommandEmailQuery, - Path: "/ids/*", - ResultOf: mcid(accountId, "0"), - }, - Properties: EmailSummaryProperties, - }, mcid(accountId, "1")) - invocations[i*3+2] = invocation(CommandThreadGet, ThreadGetRefCommand{ - AccountId: accountId, - IdsRef: &ResultReference{ - Name: CommandEmailGet, - Path: "/list/*/" + EmailPropertyThreadId, - ResultOf: mcid(accountId, "1"), - }, - }, mcid(accountId, "2")) +func setThreadSize(threads *ThreadGetResponse, emails []Email) { + threadSizeById := make(map[string]int, len(threads.List)) + for _, thread := range threads.List { + threadSizeById[thread.Id] = len(thread.EmailIds) } - cmd, err := j.request(session, logger, invocations...) - if err != nil { - return nil, "", "", err - } - - return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]EmailsWithThreadSummary, Error) { - resp := map[string]EmailsWithThreadSummary{} - for _, accountId := range uniqueAccountIds { - var response EmailGetResponse - err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, mcid(accountId, "1"), &response) - if err != nil { - return nil, err - } - - var thread ThreadGetResponse - err = retrieveResponseMatchParameters(logger, body, CommandThreadGet, mcid(accountId, "2"), &thread) - if err != nil { - return nil, err - } - - threadSizeById := make(map[string]int, len(thread.List)) - for _, thread := range thread.List { - threadSizeById[thread.Id] = len(thread.EmailIds) - } - - if len(response.NotFound) > 0 { - // TODO what to do when there are not-found emails here? potentially nothing, they could have been deleted between query and get? - } - - list := make([]EmailWithThread, len(response.List)) - for i, email := range response.List { - ts, ok := threadSizeById[email.ThreadId] - if !ok { - ts = 1 - } - list[i] = EmailWithThread{ - Email: email, - ThreadSize: ts, - } - } - - resp[accountId] = EmailsWithThreadSummary{Emails: list, State: response.State} + for i := range len(emails) { + ts, ok := threadSizeById[emails[i].ThreadId] + if !ok { + ts = 1 } - return resp, nil - }) + emails[i].ThreadSize = ts + } } diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index 6ce4b8d75..389617fd6 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -2065,6 +2065,10 @@ type Email struct { // example: $threadId ThreadId string `json:"threadId,omitempty"` + // The number of emails (this one included) that are in the thread this email is in. + // Note that this is not part of the JMAP specification, and is only calculated when requested. + ThreadSize int `json:"threadSize,omitzero"` + // The set of Mailbox ids this Email belongs to. // // An Email in the mail store MUST belong to one or more Mailboxes at all times (until it is destroyed). @@ -2179,7 +2183,7 @@ type Email struct { // This is the full MIME structure of the message body, without recursing into message/rfc822 or message/global parts. // // Note that EmailBodyParts may have subParts if they are of type multipart/*. - BodyStructure EmailBodyPart `json:"bodyStructure,omitzero"` + BodyStructure *EmailBodyPart `json:"bodyStructure,omitzero"` // This is a map of partId to an EmailBodyValue object for none, some, or all text/* parts. // @@ -2812,12 +2816,6 @@ type MailboxQueryResponse struct { Limit int `json:"limit,omitzero"` } -type EmailBodyStructure struct { - Type string `json:"type"` - PartId string `json:"partId"` - Other map[string]any `mapstructure:",remain"` -} - type EmailCreate struct { // The set of Mailbox ids this Email belongs to. // @@ -2866,7 +2864,7 @@ type EmailCreate struct { // This is the full MIME structure of the message body, without recursing into message/rfc822 or message/global parts. // // Note that EmailBodyParts may have subParts if they are of type multipart/*. - BodyStructure EmailBodyStructure `json:"bodyStructure"` + BodyStructure *EmailBodyPart `json:"bodyStructure,omitempty"` // This is a map of partId to an EmailBodyValue object for none, some, or all text/* parts. BodyValues map[string]EmailBodyValue `json:"bodyValues,omitempty"` diff --git a/pkg/jmap/jmap_tools.go b/pkg/jmap/jmap_tools.go index 6f2337eab..cb823cd7a 100644 --- a/pkg/jmap/jmap_tools.go +++ b/pkg/jmap/jmap_tools.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "maps" "reflect" "strings" "sync" @@ -207,23 +206,6 @@ func retrieveResponseMatchParameters[T any](logger *log.Logger, data *Response, return nil } -func (e EmailBodyStructure) MarshalJSON() ([]byte, error) { - m := map[string]any{} - maps.Copy(m, e.Other) // do this first to avoid overwriting type and partId - m["type"] = e.Type - m["partId"] = e.PartId - return json.Marshal(m) -} - -func (e *EmailBodyStructure) UnmarshalJSON(bs []byte) error { - m := map[string]any{} - err := json.Unmarshal(bs, &m) - if err != nil { - return err - } - return decodeMap(m, e) -} - func (i *Invocation) MarshalJSON() ([]byte, error) { // JMAP requests have a slightly unusual structure since they are not a JSON object // but, instead, a three-element array composed of diff --git a/pkg/jmap/jmap_tools_test.go b/pkg/jmap/jmap_tools_test.go index 16a1b0b01..621331e54 100644 --- a/pkg/jmap/jmap_tools_test.go +++ b/pkg/jmap/jmap_tools_test.go @@ -88,53 +88,6 @@ func TestDeserializeEmailGetResponse(t *testing.T) { require.Equal("cbejozsk1fgcviw7thwzsvtgmf1ep0a3izjoimj02jmtsunpeuwmsaya1yma", email.BlobId) } -func TestUnmarshallingUnknown(t *testing.T) { - require := require.New(t) - - const text = `{ - "subject": "aaa", - "bodyStructure": { - "type": "a", - "partId": "b", - "header:x": "yz", - "header:a": "bc" - } - }` - - var target EmailCreate - err := json.Unmarshal([]byte(text), &target) - - require.NoError(err) - require.Equal("aaa", target.Subject) - bs := target.BodyStructure - require.Equal("a", bs.Type) - require.Equal("b", bs.PartId) - require.Contains(bs.Other, "header:x") - require.Equal(bs.Other["header:x"], "yz") - require.Contains(bs.Other, "header:a") - require.Equal(bs.Other["header:a"], "bc") -} - -func TestMarshallingUnknown(t *testing.T) { - require := require.New(t) - - source := EmailCreate{ - Subject: "aaa", - BodyStructure: EmailBodyStructure{ - Type: "a", - PartId: "b", - Other: map[string]any{ - "header:x": "yz", - "header:a": "bc", - }, - }, - } - - result, err := json.Marshal(source) - require.NoError(err) - require.Equal(`{"subject":"aaa","bodyStructure":{"header:a":"bc","header:x":"yz","partId":"b","type":"a"}}`, string(result)) -} - func TestUnmarshallingError(t *testing.T) { require := require.New(t) diff --git a/services/groupware/pkg/groupware/groupware_api_emails.go b/services/groupware/pkg/groupware/groupware_api_emails.go index 855a99253..312b3cce7 100644 --- a/services/groupware/pkg/groupware/groupware_api_emails.go +++ b/services/groupware/pkg/groupware/groupware_api_emails.go @@ -204,7 +204,7 @@ func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) { 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, markAsSeen) + emails, sessionState, lang, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, req.language(), ids, true, g.maxBodyValueBytes, markAsSeen, true) if jerr != nil { return req.errorResponseFromJmap(jerr) } @@ -220,7 +220,7 @@ func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) { } 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, markAsSeen) + emails, sessionState, lang, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, req.language(), ids, true, g.maxBodyValueBytes, markAsSeen, false) if jerr != nil { return req.errorResponseFromJmap(jerr) } @@ -270,7 +270,7 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request) } 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, false) + emails, sessionState, lang, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, req.language(), []string{id}, false, 0, false, false) if jerr != nil { return req.errorResponseFromJmap(jerr) } @@ -298,7 +298,7 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request) l = contextAppender(l) logger := log.From(l) - emails, _, lang, jerr := g.jmap.GetEmails(mailAccountId, req.session, req.ctx, logger, req.language(), []string{id}, false, 0, false) + emails, _, lang, jerr := g.jmap.GetEmails(mailAccountId, req.session, req.ctx, logger, req.language(), []string{id}, false, 0, false, false) if jerr != nil { return req.apiErrorFromJmap(req.observeJmapError(jerr)) } @@ -869,6 +869,7 @@ func (g *Groupware) GetEmailsForAllAccounts(w http.ResponseWriter, r *http.Reque }) } +/* type EmailCreation struct { MailboxIds []string `json:"mailboxIds,omitempty"` Keywords []string `json:"keywords,omitempty"` @@ -879,6 +880,7 @@ type EmailCreation struct { BodyStructure jmap.EmailBodyStructure `json:"bodyStructure"` BodyValues map[string]jmap.EmailBodyValue `json:"bodyValues,omitempty"` } +*/ func (g *Groupware) CreateEmail(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { @@ -890,25 +892,15 @@ func (g *Groupware) CreateEmail(w http.ResponseWriter, r *http.Request) { } logger = log.From(logger.With().Str(logAccountId, log.SafeString(accountId))) - var body EmailCreation + var body jmap.Email err := req.body(&body) if err != nil { return errorResponse(err) } - mailboxIdsMap := map[string]bool{} - for _, mailboxId := range body.MailboxIds { - mailboxIdsMap[mailboxId] = true - } - - keywordsMap := map[string]bool{} - for _, keyword := range body.Keywords { - keywordsMap[keyword] = true - } - create := jmap.EmailCreate{ - MailboxIds: mailboxIdsMap, - Keywords: keywordsMap, + MailboxIds: body.MailboxIds, + Keywords: body.Keywords, From: body.From, Subject: body.Subject, ReceivedAt: body.ReceivedAt, @@ -917,7 +909,46 @@ func (g *Groupware) CreateEmail(w http.ResponseWriter, r *http.Request) { BodyValues: body.BodyValues, } - created, sessionState, lang, jerr := g.jmap.CreateEmail(accountId, create, req.session, req.ctx, logger, req.language()) + created, sessionState, lang, jerr := g.jmap.CreateEmail(accountId, create, "", req.session, req.ctx, logger, req.language()) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + + return response(created.Email, sessionState, lang) + }) +} + +func (g *Groupware) ReplaceEmail(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + logger := req.logger + + accountId, gwerr := req.GetAccountIdForMail() + if gwerr != nil { + return errorResponse(gwerr) + } + + replaceId := chi.URLParam(r, UriParamEmailId) + + logger = log.From(logger.With().Str(logAccountId, log.SafeString(accountId))) + + var body jmap.Email + 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, lang, jerr := g.jmap.CreateEmail(accountId, create, replaceId, req.session, req.ctx, logger, req.language()) if jerr != nil { return req.errorResponseFromJmap(jerr) } @@ -1361,7 +1392,7 @@ func (g *Groupware) RelatedToEmail(w http.ResponseWriter, r *http.Request) { reqId := req.GetRequestId() getEmailsBefore := time.Now() - emails, sessionState, lang, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, req.language(), []string{id}, true, g.maxBodyValueBytes, false) + emails, sessionState, lang, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, req.language(), []string{id}, true, g.maxBodyValueBytes, false, false) getEmailsDuration := time.Since(getEmailsBefore) if jerr != nil { return req.errorResponseFromJmap(jerr) @@ -1610,7 +1641,7 @@ type EmailSummary struct { Preview string `json:"preview,omitempty"` } -func summarizeEmail(accountId string, email jmap.EmailWithThread) EmailSummary { +func summarizeEmail(accountId string, email jmap.Email) EmailSummary { return EmailSummary{ AccountId: accountId, Id: email.Id, @@ -1635,7 +1666,7 @@ func summarizeEmail(accountId string, email jmap.EmailWithThread) EmailSummary { type emailWithAccountId struct { accountId string - email jmap.EmailWithThread + email jmap.Email } // When the request succeeds. @@ -1728,8 +1759,7 @@ func (g *Groupware) GetLatestEmailsSummaryForAllAccounts(w http.ResponseWriter, logger := log.From(l) - // emailsSummariesByAccount, sessionState, lang, jerr := g.jmap.QueryEmailSummaries(allAccountIds, req.session, req.ctx, logger, req.language(), filter, limit) - emailsSummariesByAccount, sessionState, lang, jerr := g.jmap.QueryEmailSummariesWithThreadCount(allAccountIds, req.session, req.ctx, logger, req.language(), filter, limit) + emailsSummariesByAccount, sessionState, lang, jerr := g.jmap.QueryEmailSummaries(allAccountIds, req.session, req.ctx, logger, req.language(), filter, limit, true) if jerr != nil { return req.errorResponseFromJmap(jerr) } diff --git a/services/groupware/pkg/groupware/groupware_request.go b/services/groupware/pkg/groupware/groupware_request.go index 56d6b6739..680fd4932 100644 --- a/services/groupware/pkg/groupware/groupware_request.go +++ b/services/groupware/pkg/groupware/groupware_request.go @@ -290,6 +290,7 @@ func (r Request) body(target any) *Error { err := json.NewDecoder(body).Decode(target) if err != nil { + r.logger.Warn().Msgf("failed to deserialize the request body: %s", err.Error()) return r.observedParameterError(ErrorInvalidRequestBody, withSource(&ErrorSource{Pointer: "/"})) // we don't get any details here } return nil diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go index 0c469d4db..e9a0bb802 100644 --- a/services/groupware/pkg/groupware/groupware_route.go +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -97,7 +97,7 @@ func (g *Groupware) Route(r chi.Router) { r.Post("/", g.CreateEmail) r.Delete("/", g.DeleteEmails) r.Get("/{emailid}", g.GetEmailsById) // Accept:message/rfc822 - // r.Put("/{emailid}", g.ReplaceEmail) // TODO + r.Put("/{emailid}", g.ReplaceEmail) r.Patch("/{emailid}", g.UpdateEmail) r.Patch("/{emailid}/keywords", g.UpdateEmailKeywords) r.Post("/{emailid}/keywords", g.AddEmailKeywords)