groupware: add threadSize in email-by-id response

This commit is contained in:
Pascal Bleser
2025-10-22 12:15:24 +02:00
parent 6d434e245e
commit 5ae69edded
7 changed files with 131 additions and 194 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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