groupware: add threadSize in email-by-id response

This commit is contained in:
Pascal Bleser
2025-10-22 12:15:24 +02:00
parent 87665ac42c
commit 46e5d82148
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)