From 9ef74191c0b60b1cd694f691ea9365e8284e1554 Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Fri, 17 Oct 2025 10:02:40 +0200
Subject: [PATCH] groupware:
* made several email related operations multi-account:
QueryEmailSnippets, QueryEmails, QueryEmailsWithSnippets
* add GetIdentitiesForAllAccounts
* add GetEmailsForAllAccounts
* jmap: add CreateIdentity, UpdateIdentity; groupware: add
GetIdentityById, AddIdentity, ModifyIdentity
* add temporary workaround until Calendars, Tasks, Contacts are
implemented in Stalwart when determining the default account for
those: use the mail one in the mean time
---
pkg/jmap/jmap_api_email.go | 436 ++++++++++--------
pkg/jmap/jmap_api_identity.go | 80 +++-
pkg/jmap/jmap_integration_test.go | 2 +-
pkg/jmap/jmap_model.go | 55 ++-
pkg/structs/structs.go | 15 +
.../pkg/groupware/groupware_api_emails.go | 343 +++++++++++---
.../pkg/groupware/groupware_api_identity.go | 68 ++-
.../pkg/groupware/groupware_framework.go | 1 +
.../pkg/groupware/groupware_request.go | 12 +-
.../pkg/groupware/groupware_response.go | 8 +
.../pkg/groupware/groupware_route.go | 9 +-
11 files changed, 773 insertions(+), 256 deletions(-)
diff --git a/pkg/jmap/jmap_api_email.go b/pkg/jmap/jmap_api_email.go
index 6b85beb4c4..3d62d48491 100644
--- a/pkg/jmap/jmap_api_email.go
+++ b/pkg/jmap/jmap_api_email.go
@@ -187,71 +187,125 @@ func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context.
})
}
-type EmailSnippetQueryResult struct {
- Snippets []SearchSnippet `json:"snippets,omitempty"`
- Total uint `json:"total"`
- Limit uint `json:"limit,omitzero"`
- Position uint `json:"position,omitzero"`
- QueryState State `json:"queryState"`
+type SearchSnippetWithMeta struct {
+ ReceivedAt time.Time `json:"receivedAt,omitzero"`
+ EmailId string `json:"emailId,omitempty"`
+ SearchSnippet
}
-func (j *Client) QueryEmailSnippets(accountId string, filter EmailFilterElement, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, offset uint, limit uint) (EmailSnippetQueryResult, SessionState, Language, Error) {
- logger = j.loggerParams("QueryEmails", session, logger, func(z zerolog.Context) zerolog.Context {
+type EmailSnippetQueryResult struct {
+ Snippets []SearchSnippetWithMeta `json:"snippets,omitempty"`
+ Total uint `json:"total"`
+ Limit uint `json:"limit,omitzero"`
+ Position uint `json:"position,omitzero"`
+ QueryState State `json:"queryState"`
+}
+
+func (j *Client) QueryEmailSnippets(accountIds []string, filter EmailFilterElement, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, offset uint, limit uint) (map[string]EmailSnippetQueryResult, SessionState, Language, Error) {
+ logger = j.loggerParams("QueryEmailSnippets", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Uint(logLimit, limit).Uint(logOffset, offset)
})
- query := EmailQueryCommand{
- AccountId: accountId,
- Filter: filter,
- Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}},
- CollapseThreads: true,
- CalculateTotal: true,
- }
- if offset > 0 {
- query.Position = offset
- }
- if limit > 0 {
- query.Limit = limit
+ uniqueAccountIds := structs.Uniq(accountIds)
+ invocations := make([]Invocation, len(uniqueAccountIds)*3)
+ for i, accountId := range uniqueAccountIds {
+ query := EmailQueryCommand{
+ AccountId: accountId,
+ Filter: filter,
+ Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}},
+ CollapseThreads: true,
+ CalculateTotal: true,
+ }
+ if offset > 0 {
+ query.Position = offset
+ }
+ if limit > 0 {
+ query.Limit = limit
+ }
+
+ mails := EmailGetRefCommand{
+ AccountId: accountId,
+ IdsRef: &ResultReference{
+ ResultOf: mcid(accountId, "0"),
+ Name: CommandEmailQuery,
+ Path: "/ids/*",
+ },
+ FetchAllBodyValues: false,
+ MaxBodyValueBytes: 0,
+ Properties: []string{"id", "receivedAt", "sentAt"},
+ }
+
+ snippet := SearchSnippetGetRefCommand{
+ AccountId: accountId,
+ Filter: filter,
+ EmailIdRef: &ResultReference{
+ ResultOf: mcid(accountId, "0"),
+ Name: CommandEmailQuery,
+ Path: "/ids/*",
+ },
+ }
+
+ invocations[i*3+0] = invocation(CommandEmailQuery, query, mcid(accountId, "0"))
+ invocations[i*3+1] = invocation(CommandEmailGet, mails, mcid(accountId, "1"))
+ invocations[i*3+2] = invocation(CommandSearchSnippetGet, snippet, mcid(accountId, "2"))
}
- snippet := SearchSnippetGetRefCommand{
- AccountId: accountId,
- Filter: filter,
- EmailIdRef: &ResultReference{
- ResultOf: "0",
- Name: CommandEmailQuery,
- Path: "/ids/*",
- },
- }
-
- cmd, err := j.request(session, logger,
- invocation(CommandEmailQuery, query, "0"),
- invocation(CommandSearchSnippetGet, snippet, "1"),
- )
+ cmd, err := j.request(session, logger, invocations...)
if err != nil {
- return EmailSnippetQueryResult{}, "", "", err
+ return nil, "", "", err
}
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (EmailSnippetQueryResult, Error) {
- var queryResponse EmailQueryResponse
- err = retrieveResponseMatchParameters(logger, body, CommandEmailQuery, "0", &queryResponse)
- if err != nil {
- return EmailSnippetQueryResult{}, err
- }
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]EmailSnippetQueryResult, Error) {
+ results := make(map[string]EmailSnippetQueryResult, len(uniqueAccountIds))
+ for _, accountId := range uniqueAccountIds {
+ var queryResponse EmailQueryResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandEmailQuery, mcid(accountId, "0"), &queryResponse)
+ if err != nil {
+ return nil, err
+ }
- var snippetResponse SearchSnippetGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandSearchSnippetGet, "1", &snippetResponse)
- if err != nil {
- return EmailSnippetQueryResult{}, err
- }
+ var mailResponse EmailGetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, mcid(accountId, "1"), &mailResponse)
+ if err != nil {
+ return nil, err
+ }
- return EmailSnippetQueryResult{
- Snippets: snippetResponse.List,
- Total: queryResponse.Total,
- Limit: queryResponse.Limit,
- Position: queryResponse.Position,
- QueryState: queryResponse.QueryState,
- }, nil
+ var snippetResponse SearchSnippetGetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandSearchSnippetGet, mcid(accountId, "2"), &snippetResponse)
+ if err != nil {
+ return nil, err
+ }
+
+ mailResponseById := structs.Index(mailResponse.List, func(e Email) string { return e.Id })
+
+ snippets := make([]SearchSnippetWithMeta, len(queryResponse.Ids))
+ if len(queryResponse.Ids) > len(snippetResponse.List) {
+ // TODO how do we handle this, if there are more email IDs than snippets?
+ }
+
+ i := 0
+ for _, id := range queryResponse.Ids {
+ if mail, ok := mailResponseById[id]; ok {
+ snippets[i] = SearchSnippetWithMeta{
+ EmailId: id,
+ ReceivedAt: mail.ReceivedAt,
+ SearchSnippet: snippetResponse.List[i],
+ }
+ } else {
+ // TODO how do we handle this, if there is no email result for that id?
+ }
+ i++
+ }
+
+ results[accountId] = EmailSnippetQueryResult{
+ Snippets: snippets,
+ Total: queryResponse.Total,
+ Limit: queryResponse.Limit,
+ Position: queryResponse.Position,
+ QueryState: queryResponse.QueryState,
+ }
+ }
+ return results, nil
})
}
@@ -263,64 +317,72 @@ type EmailQueryResult struct {
QueryState State `json:"queryState"`
}
-func (j *Client) QueryEmails(accountId string, filter EmailFilterElement, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, offset uint, limit uint, fetchBodies bool, maxBodyValueBytes uint) (EmailQueryResult, SessionState, Language, Error) {
+func (j *Client) QueryEmails(accountIds []string, filter EmailFilterElement, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, offset uint, limit uint, fetchBodies bool, maxBodyValueBytes uint) (map[string]EmailQueryResult, SessionState, Language, Error) {
logger = j.loggerParams("QueryEmails", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies)
})
- query := EmailQueryCommand{
- AccountId: accountId,
- Filter: filter,
- Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}},
- CollapseThreads: true,
- CalculateTotal: true,
- }
- if offset > 0 {
- query.Position = offset
- }
- if limit > 0 {
- query.Limit = limit
+ uniqueAccountIds := structs.Uniq(accountIds)
+ invocations := make([]Invocation, len(uniqueAccountIds)*2)
+ for i, accountId := range uniqueAccountIds {
+ query := EmailQueryCommand{
+ AccountId: accountId,
+ Filter: filter,
+ Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}},
+ CollapseThreads: true,
+ CalculateTotal: true,
+ }
+ if offset > 0 {
+ query.Position = offset
+ }
+ if limit > 0 {
+ query.Limit = limit
+ }
+
+ mails := EmailGetRefCommand{
+ AccountId: accountId,
+ IdsRef: &ResultReference{
+ ResultOf: mcid(accountId, "0"),
+ Name: CommandEmailQuery,
+ Path: "/ids/*",
+ },
+ FetchAllBodyValues: fetchBodies,
+ MaxBodyValueBytes: maxBodyValueBytes,
+ }
+
+ invocations[i*2+0] = invocation(CommandEmailQuery, query, mcid(accountId, "0"))
+ invocations[i*2+1] = invocation(CommandEmailGet, mails, mcid(accountId, "1"))
}
- mails := EmailGetRefCommand{
- AccountId: accountId,
- IdsRef: &ResultReference{
- ResultOf: "0",
- Name: CommandEmailQuery,
- Path: "/ids/*",
- },
- FetchAllBodyValues: fetchBodies,
- MaxBodyValueBytes: maxBodyValueBytes,
- }
-
- cmd, err := j.request(session, logger,
- invocation(CommandEmailQuery, query, "0"),
- invocation(CommandEmailGet, mails, "1"),
- )
+ cmd, err := j.request(session, logger, invocations...)
if err != nil {
- return EmailQueryResult{}, "", "", err
+ return nil, "", "", err
}
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (EmailQueryResult, Error) {
- var queryResponse EmailQueryResponse
- err = retrieveResponseMatchParameters(logger, body, CommandEmailQuery, "0", &queryResponse)
- if err != nil {
- return EmailQueryResult{}, err
- }
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]EmailQueryResult, Error) {
+ results := make(map[string]EmailQueryResult, len(uniqueAccountIds))
+ for _, accountId := range uniqueAccountIds {
+ var queryResponse EmailQueryResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandEmailQuery, mcid(accountId, "0"), &queryResponse)
+ if err != nil {
+ return nil, err
+ }
- var emailsResponse EmailGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, "1", &emailsResponse)
- if err != nil {
- return EmailQueryResult{}, err
- }
+ var emailsResponse EmailGetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, mcid(accountId, "1"), &emailsResponse)
+ if err != nil {
+ return nil, err
+ }
- return EmailQueryResult{
- Emails: emailsResponse.List,
- Total: queryResponse.Total,
- Limit: queryResponse.Limit,
- Position: queryResponse.Position,
- QueryState: queryResponse.QueryState,
- }, nil
+ results[accountId] = EmailQueryResult{
+ Emails: emailsResponse.List,
+ Total: queryResponse.Total,
+ Limit: queryResponse.Limit,
+ Position: queryResponse.Position,
+ QueryState: queryResponse.QueryState,
+ }
+ }
+ return results, nil
})
}
@@ -337,104 +399,110 @@ type EmailQueryWithSnippetsResult struct {
QueryState State `json:"queryState"`
}
-func (j *Client) QueryEmailsWithSnippets(accountId string, filter EmailFilterElement, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, offset uint, limit uint, fetchBodies bool, maxBodyValueBytes uint) (EmailQueryWithSnippetsResult, SessionState, Language, Error) {
+func (j *Client) QueryEmailsWithSnippets(accountIds []string, filter EmailFilterElement, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, offset uint, limit uint, fetchBodies bool, maxBodyValueBytes uint) (map[string]EmailQueryWithSnippetsResult, SessionState, Language, Error) {
logger = j.loggerParams("QueryEmailsWithSnippets", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies)
})
- query := EmailQueryCommand{
- AccountId: accountId,
- Filter: filter,
- Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}},
- CollapseThreads: false,
- CalculateTotal: true,
- }
- if offset > 0 {
- query.Position = offset
- }
- if limit > 0 {
- query.Limit = limit
+ uniqueAccountIds := structs.Uniq(accountIds)
+ invocations := make([]Invocation, len(uniqueAccountIds)*3)
+ for i, accountId := range uniqueAccountIds {
+ query := EmailQueryCommand{
+ AccountId: accountId,
+ Filter: filter,
+ Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}},
+ CollapseThreads: false,
+ CalculateTotal: true,
+ }
+ if offset > 0 {
+ query.Position = offset
+ }
+ if limit > 0 {
+ query.Limit = limit
+ }
+
+ snippet := SearchSnippetGetRefCommand{
+ AccountId: accountId,
+ Filter: filter,
+ EmailIdRef: &ResultReference{
+ ResultOf: mcid(accountId, "0"),
+ Name: CommandEmailQuery,
+ Path: "/ids/*",
+ },
+ }
+
+ mails := EmailGetRefCommand{
+ AccountId: accountId,
+ IdsRef: &ResultReference{
+ ResultOf: mcid(accountId, "0"),
+ Name: CommandEmailQuery,
+ Path: "/ids/*",
+ },
+ FetchAllBodyValues: fetchBodies,
+ MaxBodyValueBytes: maxBodyValueBytes,
+ }
+ invocations[i*3+0] = invocation(CommandEmailQuery, query, mcid(accountId, "0"))
+ invocations[i*3+1] = invocation(CommandSearchSnippetGet, snippet, mcid(accountId, "1"))
+ invocations[i*3+2] = invocation(CommandEmailGet, mails, mcid(accountId, "2"))
}
- snippet := SearchSnippetGetRefCommand{
- AccountId: accountId,
- Filter: filter,
- EmailIdRef: &ResultReference{
- ResultOf: "0",
- Name: CommandEmailQuery,
- Path: "/ids/*",
- },
- }
-
- mails := EmailGetRefCommand{
- AccountId: accountId,
- IdsRef: &ResultReference{
- ResultOf: "0",
- Name: CommandEmailQuery,
- Path: "/ids/*",
- },
- FetchAllBodyValues: fetchBodies,
- MaxBodyValueBytes: maxBodyValueBytes,
- }
-
- cmd, err := j.request(session, logger,
- invocation(CommandEmailQuery, query, "0"),
- invocation(CommandSearchSnippetGet, snippet, "1"),
- invocation(CommandEmailGet, mails, "2"),
- )
-
+ cmd, err := j.request(session, logger, invocations...)
if err != nil {
logger.Error().Err(err).Send()
- return EmailQueryWithSnippetsResult{}, "", "", simpleError(err, JmapErrorInvalidJmapRequestPayload)
+ return nil, "", "", simpleError(err, JmapErrorInvalidJmapRequestPayload)
}
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (EmailQueryWithSnippetsResult, Error) {
- var queryResponse EmailQueryResponse
- err = retrieveResponseMatchParameters(logger, body, CommandEmailQuery, "0", &queryResponse)
- if err != nil {
- return EmailQueryWithSnippetsResult{}, err
- }
-
- var snippetResponse SearchSnippetGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandSearchSnippetGet, "1", &snippetResponse)
- if err != nil {
- return EmailQueryWithSnippetsResult{}, err
- }
-
- var emailsResponse EmailGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, "2", &emailsResponse)
- if err != nil {
- return EmailQueryWithSnippetsResult{}, err
- }
-
- snippetsById := map[string][]SearchSnippet{}
- for _, snippet := range snippetResponse.List {
- list, ok := snippetsById[snippet.EmailId]
- if !ok {
- list = []SearchSnippet{}
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]EmailQueryWithSnippetsResult, Error) {
+ result := make(map[string]EmailQueryWithSnippetsResult, len(uniqueAccountIds))
+ for _, accountId := range uniqueAccountIds {
+ var queryResponse EmailQueryResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandEmailQuery, mcid(accountId, "0"), &queryResponse)
+ if err != nil {
+ return nil, err
}
- snippetsById[snippet.EmailId] = append(list, snippet)
- }
- results := []EmailWithSnippets{}
- for _, email := range emailsResponse.List {
- snippets, ok := snippetsById[email.Id]
- if !ok {
- snippets = []SearchSnippet{}
+ var snippetResponse SearchSnippetGetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandSearchSnippetGet, mcid(accountId, "1"), &snippetResponse)
+ if err != nil {
+ return nil, err
}
- results = append(results, EmailWithSnippets{
- Email: email,
- Snippets: snippets,
- })
- }
- return EmailQueryWithSnippetsResult{
- Results: results,
- Total: queryResponse.Total,
- Limit: queryResponse.Limit,
- Position: queryResponse.Position,
- QueryState: queryResponse.QueryState,
- }, nil
+ var emailsResponse EmailGetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, mcid(accountId, "2"), &emailsResponse)
+ if err != nil {
+ return nil, err
+ }
+
+ snippetsById := map[string][]SearchSnippet{}
+ for _, snippet := range snippetResponse.List {
+ list, ok := snippetsById[snippet.EmailId]
+ if !ok {
+ list = []SearchSnippet{}
+ }
+ snippetsById[snippet.EmailId] = append(list, snippet)
+ }
+
+ results := []EmailWithSnippets{}
+ for _, email := range emailsResponse.List {
+ snippets, ok := snippetsById[email.Id]
+ if !ok {
+ snippets = []SearchSnippet{}
+ }
+ results = append(results, EmailWithSnippets{
+ Email: email,
+ Snippets: snippets,
+ })
+ }
+
+ result[accountId] = EmailQueryWithSnippetsResult{
+ Results: results,
+ Total: queryResponse.Total,
+ Limit: queryResponse.Limit,
+ Position: queryResponse.Position,
+ QueryState: queryResponse.QueryState,
+ }
+ }
+ return result, nil
})
}
diff --git a/pkg/jmap/jmap_api_identity.go b/pkg/jmap/jmap_api_identity.go
index 0c095b4dc7..bb2465a1ca 100644
--- a/pkg/jmap/jmap_api_identity.go
+++ b/pkg/jmap/jmap_api_identity.go
@@ -13,9 +13,8 @@ type Identities struct {
State State `json:"state"`
}
-// https://jmap.io/spec-mail.html#identityget
-func (j *Client) GetIdentity(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (Identities, SessionState, Language, Error) {
- logger = j.logger("GetIdentity", session, logger)
+func (j *Client) GetAllIdentities(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (Identities, SessionState, Language, Error) {
+ logger = j.logger("GetAllIdentities", session, logger)
cmd, err := j.request(session, logger, invocation(CommandIdentityGet, IdentityGetCommand{AccountId: accountId}, "0"))
if err != nil {
return Identities{}, "", "", err
@@ -33,16 +32,35 @@ func (j *Client) GetIdentity(accountId string, session *Session, ctx context.Con
})
}
+func (j *Client) GetIdentities(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, identityIds []string) (Identities, SessionState, Language, Error) {
+ logger = j.logger("GetIdentities", session, logger)
+ cmd, err := j.request(session, logger, invocation(CommandIdentityGet, IdentityGetCommand{AccountId: accountId, Ids: identityIds}, "0"))
+ if err != nil {
+ return Identities{}, "", "", err
+ }
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (Identities, Error) {
+ var response IdentityGetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandIdentityGet, "0", &response)
+ if err != nil {
+ return Identities{}, err
+ }
+ return Identities{
+ Identities: response.List,
+ State: response.State,
+ }, nil
+ })
+}
+
type IdentitiesGetResponse struct {
Identities map[string][]Identity `json:"identities,omitempty"`
NotFound []string `json:"notFound,omitempty"`
State State `json:"state"`
}
-func (j *Client) GetIdentities(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (IdentitiesGetResponse, SessionState, Language, Error) {
+func (j *Client) GetIdentitiesForAllAccounts(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (IdentitiesGetResponse, SessionState, Language, Error) {
uniqueAccountIds := structs.Uniq(accountIds)
- logger = j.logger("GetIdentities", session, logger)
+ logger = j.logger("GetIdentitiesForAllAccounts", session, logger)
calls := make([]Invocation, len(uniqueAccountIds))
for i, accountId := range uniqueAccountIds {
@@ -129,3 +147,55 @@ func (j *Client) GetIdentitiesAndMailboxes(mailboxAccountId string, accountIds [
}, nil
})
}
+
+func (j *Client) CreateIdentity(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, identity Identity) (State, SessionState, Language, Error) {
+ logger = j.logger("CreateIdentity", session, logger)
+ cmd, err := j.request(session, logger, invocation(CommandIdentitySet, IdentitySetCommand{
+ AccountId: accountId,
+ Create: map[string]Identity{
+ "c": identity,
+ },
+ }, "0"))
+ if err != nil {
+ return "", "", "", err
+ }
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (State, Error) {
+ var response IdentitySetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandIdentitySet, "0", &response)
+ if err != nil {
+ return response.NewState, err
+ }
+ setErr, notok := response.NotCreated["c"]
+ if notok {
+ logger.Error().Msgf("%T.NotCreated returned an error %v", response, setErr)
+ return "", setErrorError(setErr, IdentityType)
+ }
+ return response.NewState, nil
+ })
+}
+
+func (j *Client) UpdateIdentity(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, identity Identity) (State, SessionState, Language, Error) {
+ logger = j.logger("UpdateIdentity", session, logger)
+ cmd, err := j.request(session, logger, invocation(CommandIdentitySet, IdentitySetCommand{
+ AccountId: accountId,
+ Update: map[string]PatchObject{
+ "c": identity.AsPatch(),
+ },
+ }, "0"))
+ if err != nil {
+ return "", "", "", err
+ }
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (State, Error) {
+ var response IdentitySetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandIdentitySet, "0", &response)
+ if err != nil {
+ return response.NewState, err
+ }
+ setErr, notok := response.NotCreated["c"]
+ if notok {
+ logger.Error().Msgf("%T.NotCreated returned an error %v", response, setErr)
+ return "", setErrorError(setErr, IdentityType)
+ }
+ return response.NewState, nil
+ })
+}
diff --git a/pkg/jmap/jmap_integration_test.go b/pkg/jmap/jmap_integration_test.go
index ecafcf1d75..fb81113057 100644
--- a/pkg/jmap/jmap_integration_test.go
+++ b/pkg/jmap/jmap_integration_test.go
@@ -724,7 +724,7 @@ func TestEmails(t *testing.T) {
{
{
- resp, sessionState, _, err := s.client.GetIdentity(accountId, s.session, s.ctx, s.logger, "")
+ resp, sessionState, _, err := s.client.GetAllIdentities(accountId, s.session, s.ctx, s.logger, "")
require.NoError(err)
require.Equal(s.session.State, sessionState)
require.Len(resp.Identities, 1)
diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go
index 5eeeefef1d..bb81e3c57b 100644
--- a/pkg/jmap/jmap_model.go
+++ b/pkg/jmap/jmap_model.go
@@ -3025,9 +3025,29 @@ type IdentityGetCommand struct {
Ids []string `json:"ids,omitempty"`
}
+type IdentitySetCommand struct {
+ AccountId string `json:"accountId"`
+ IfInState string `json:"ifInState,omitempty"`
+ Create map[string]Identity `json:"create,omitempty"`
+ Update map[string]PatchObject `json:"update,omitempty"`
+ Destroy []string `json:"destroy,omitempty"`
+}
+
+type IdentitySetResponse struct {
+ AccountId string `json:"accountId"`
+ OldState State `json:"oldState,omitempty"`
+ NewState State `json:"newState,omitempty"`
+ Created map[string]Identity `json:"created,omitempty"`
+ Updated map[string]Identity `json:"updated,omitempty"`
+ Destroyed []string `json:"destroyed,omitempty"`
+ NotCreated map[string]SetError `json:"notCreated,omitempty"`
+ NotUpdated map[string]SetError `json:"notUpdated,omitempty"`
+ NotDestroyed map[string]SetError `json:"notDestroyed,omitempty"`
+}
+
type Identity struct {
// The id of the Identity.
- Id string `json:"id"`
+ Id string `json:"id,omitempty"`
// The “From” name the client SHOULD use when creating a new Email from this Identity.
Name string `json:"name,omitempty"`
@@ -3043,13 +3063,13 @@ type Identity struct {
ReplyTo string `json:"replyTo,omitempty"`
// The Bcc value the client SHOULD set when creating a new Email from this Identity.
- Bcc []EmailAddress `json:"bcc,omitempty"`
+ Bcc *[]EmailAddress `json:"bcc,omitempty"`
// A signature the client SHOULD insert into new plaintext messages that will be sent from
// this Identity.
//
// Clients MAY ignore this and/or combine this with a client-specific signature preference.
- TextSignature string `json:"textSignature,omitempty"`
+ TextSignature *string `json:"textSignature,omitempty"`
// A signature the client SHOULD insert into new HTML messages that will be sent from this
// Identity.
@@ -3057,7 +3077,7 @@ type Identity struct {
// This text MUST be an HTML snippet to be inserted into the section of the HTML.
//
// Clients MAY ignore this and/or combine this with a client-specific signature preference.
- HtmlSignature string `json:"htmlSignature,omitempty"`
+ HtmlSignature *string `json:"htmlSignature,omitempty"`
// Is the user allowed to delete this Identity?
//
@@ -3065,7 +3085,30 @@ type Identity struct {
//
// Attempts to destroy an Identity with mayDelete: false will be rejected with a standard
// forbidden SetError.
- MayDelete bool `json:"mayDelete"`
+ MayDelete bool `json:"mayDelete,omitzero"`
+}
+
+func (i Identity) AsPatch() PatchObject {
+ p := PatchObject{}
+ if i.Name != "" {
+ p["name"] = i.Name
+ }
+ if i.Email != "" {
+ p["email"] = i.Email
+ }
+ if i.ReplyTo != "" {
+ p["replyTo"] = i.ReplyTo
+ }
+ if i.Bcc != nil {
+ p["bcc"] = i.Bcc
+ }
+ if i.TextSignature != nil {
+ p["textSignature"] = i.TextSignature
+ }
+ if i.HtmlSignature != nil {
+ p["htmlSignature"] = i.HtmlSignature
+ }
+ return p
}
type IdentityGetResponse struct {
@@ -4639,6 +4682,7 @@ const (
CommandMailboxQuery Command = "Mailbox/query"
CommandMailboxChanges Command = "Mailbox/changes"
CommandIdentityGet Command = "Identity/get"
+ CommandIdentitySet Command = "Identity/set"
CommandVacationResponseGet Command = "VacationResponse/get"
CommandVacationResponseSet Command = "VacationResponse/set"
CommandSearchSnippetGet Command = "SearchSnippet/get"
@@ -4660,6 +4704,7 @@ var CommandResponseTypeMap = map[Command]func() any{
CommandEmailSubmissionSet: func() any { return EmailSubmissionSetResponse{} },
CommandThreadGet: func() any { return ThreadGetResponse{} },
CommandIdentityGet: func() any { return IdentityGetResponse{} },
+ CommandIdentitySet: func() any { return IdentitySetResponse{} },
CommandVacationResponseGet: func() any { return VacationResponseGetResponse{} },
CommandVacationResponseSet: func() any { return VacationResponseSetResponse{} },
CommandSearchSnippetGet: func() any { return SearchSnippetGetResponse{} },
diff --git a/pkg/structs/structs.go b/pkg/structs/structs.go
index 6c7558789f..d9c30e6082 100644
--- a/pkg/structs/structs.go
+++ b/pkg/structs/structs.go
@@ -66,3 +66,18 @@ func Map[E any, R any](source []E, indexer func(E) R) []R {
}
return result
}
+
+func MapN[E any, R any](source []E, indexer func(E) *R) []R {
+ if source == nil {
+ var zero []R
+ return zero
+ }
+ result := []R{}
+ for _, e := range source {
+ opt := indexer(e)
+ if opt != nil {
+ result = append(result, *opt)
+ }
+ }
+ return result
+}
diff --git a/services/groupware/pkg/groupware/groupware_api_emails.go b/services/groupware/pkg/groupware/groupware_api_emails.go
index fdeabc3008..5576885aac 100644
--- a/services/groupware/pkg/groupware/groupware_api_emails.go
+++ b/services/groupware/pkg/groupware/groupware_api_emails.go
@@ -5,7 +5,7 @@ import (
"fmt"
"io"
"net/http"
- "sort"
+ "slices"
"strconv"
"strings"
"time"
@@ -312,30 +312,43 @@ func (g *Groupware) getEmailsSince(w http.ResponseWriter, r *http.Request, since
}
type EmailSearchSnippetsResults struct {
- Results []jmap.SearchSnippet `json:"results,omitempty"`
- Total uint `json:"total,omitzero"`
- Limit uint `json:"limit,omitzero"`
- QueryState jmap.State `json:"queryState,omitempty"`
+ Results []Snippet `json:"results,omitempty"`
+ Total uint `json:"total,omitzero"`
+ Limit uint `json:"limit,omitzero"`
+ QueryState jmap.State `json:"queryState,omitempty"`
}
type EmailWithSnippets struct {
+ AccountId string `json:"accountId,omitempty"`
jmap.Email
Snippets []SnippetWithoutEmailId `json:"snippets,omitempty"`
}
+type Snippet struct {
+ AccountId string `json:"accountId,omitempty"`
+ jmap.SearchSnippetWithMeta
+}
+
type SnippetWithoutEmailId struct {
Subject string `json:"subject,omitempty"`
Preview string `json:"preview,omitempty"`
}
-type EmailSearchResults struct {
+type EmailWithSnippetsSearchResults struct {
Results []EmailWithSnippets `json:"results"`
Total uint `json:"total,omitzero"`
Limit uint `json:"limit,omitzero"`
QueryState jmap.State `json:"queryState,omitempty"`
}
-func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, uint, uint, *log.Logger, *Error) {
+type EmailSearchResults struct {
+ Results []jmap.Email `json:"results"`
+ Total uint `json:"total,omitzero"`
+ Limit uint `json:"limit,omitzero"`
+ QueryState jmap.State `json:"queryState,omitempty"`
+}
+
+func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, bool, uint, uint, *log.Logger, *Error) {
q := req.r.URL.Query()
mailboxId := q.Get(QueryParamMailboxId)
notInMailboxIds := q[QueryParamNotInMailboxId]
@@ -348,11 +361,13 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, uin
body := q.Get(QueryParamSearchBody)
keywords := q[QueryParamSearchKeyword]
+ snippets := false
+
l := req.logger.With()
offset, ok, err := req.parseUIntParam(QueryParamOffset, 0)
if err != nil {
- return false, nil, 0, 0, nil, err
+ return false, nil, snippets, 0, 0, nil, err
}
if ok {
l = l.Uint(QueryParamOffset, offset)
@@ -360,7 +375,7 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, uin
limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaultEmailLimit)
if err != nil {
- return false, nil, 0, 0, nil, err
+ return false, nil, snippets, 0, 0, nil, err
}
if ok {
l = l.Uint(QueryParamLimit, limit)
@@ -368,7 +383,7 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, uin
before, ok, err := req.parseDateParam(QueryParamSearchBefore)
if err != nil {
- return false, nil, 0, 0, nil, err
+ return false, nil, snippets, 0, 0, nil, err
}
if ok {
l = l.Time(QueryParamSearchBefore, before)
@@ -376,7 +391,7 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, uin
after, ok, err := req.parseDateParam(QueryParamSearchAfter)
if err != nil {
- return false, nil, 0, 0, nil, err
+ return false, nil, snippets, 0, 0, nil, err
}
if ok {
l = l.Time(QueryParamSearchAfter, after)
@@ -412,7 +427,7 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, uin
minSize, ok, err := req.parseIntParam(QueryParamSearchMinSize, 0)
if err != nil {
- return false, nil, 0, 0, nil, err
+ return false, nil, snippets, 0, 0, nil, err
}
if ok {
l = l.Int(QueryParamSearchMinSize, minSize)
@@ -420,7 +435,7 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, uin
maxSize, ok, err := req.parseIntParam(QueryParamSearchMaxSize, 0)
if err != nil {
- return false, nil, 0, 0, nil, err
+ return false, nil, snippets, 0, 0, nil, err
}
if ok {
l = l.Int(QueryParamSearchMaxSize, maxSize)
@@ -447,6 +462,10 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, uin
}
filter = &firstFilter
+ if text != "" || subject != "" || body != "" {
+ snippets = true
+ }
+
if len(keywords) > 0 {
firstFilter.HasKeyword = keywords[0]
if len(keywords) > 1 {
@@ -462,12 +481,12 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, uin
}
}
- return true, filter, offset, limit, logger, nil
+ return true, filter, snippets, offset, limit, logger, nil
}
func (g *Groupware) searchEmails(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
- ok, filter, offset, limit, logger, err := g.buildFilter(req)
+ ok, filter, makesSnippets, offset, limit, logger, err := g.buildFilter(req)
if !ok {
return errorResponse(err)
}
@@ -499,32 +518,44 @@ func (g *Groupware) searchEmails(w http.ResponseWriter, r *http.Request) {
}
logger = log.From(logger.With().Str(logAccountId, log.SafeString(accountId)))
- results, sessionState, lang, jerr := g.jmap.QueryEmailsWithSnippets(accountId, filter, req.session, req.ctx, logger, req.language(), offset, limit, fetchBodies, g.maxBodyValueBytes)
+ g.jmap.QueryEmails([]string{accountId}, filter, req.session, req.ctx, logger, req.language(), offset, limit, fetchBodies, g.maxBodyValueBytes)
+
+ resultsByAccount, sessionState, lang, jerr := g.jmap.QueryEmailsWithSnippets([]string{accountId}, filter, req.session, req.ctx, logger, req.language(), offset, limit, fetchBodies, g.maxBodyValueBytes)
if jerr != nil {
return req.errorResponseFromJmap(jerr)
}
- flattened := make([]EmailWithSnippets, len(results.Results))
- for i, result := range results.Results {
- snippets := make([]SnippetWithoutEmailId, len(result.Snippets))
- for j, snippet := range result.Snippets {
- snippets[j] = SnippetWithoutEmailId{
- Subject: snippet.Subject,
- Preview: snippet.Preview,
+ if results, ok := resultsByAccount[accountId]; ok {
+ flattened := make([]EmailWithSnippets, len(results.Results))
+ for i, result := range results.Results {
+ var snippets []SnippetWithoutEmailId
+ if makesSnippets {
+ snippets := make([]SnippetWithoutEmailId, len(result.Snippets))
+ for j, snippet := range result.Snippets {
+ snippets[j] = SnippetWithoutEmailId{
+ Subject: snippet.Subject,
+ Preview: snippet.Preview,
+ }
+ }
+ } else {
+ snippets = nil
+ }
+ flattened[i] = EmailWithSnippets{
+ // AccountId: accountId,
+ Email: result.Email,
+ Snippets: snippets,
}
}
- flattened[i] = EmailWithSnippets{
- Email: result.Email,
- Snippets: snippets,
- }
- }
- return etagResponse(EmailSearchResults{
- Results: flattened,
- Total: results.Total,
- Limit: results.Limit,
- QueryState: results.QueryState,
- }, sessionState, results.QueryState, lang)
+ return etagResponse(EmailWithSnippetsSearchResults{
+ Results: flattened,
+ Total: results.Total,
+ Limit: results.Limit,
+ QueryState: results.QueryState,
+ }, sessionState, results.QueryState, lang)
+ } else {
+ return notFoundResponse(sessionState)
+ }
} else {
accountId, err := req.GetAccountIdForMail()
if err != nil {
@@ -532,17 +563,21 @@ func (g *Groupware) searchEmails(w http.ResponseWriter, r *http.Request) {
}
logger = log.From(logger.With().Str(logAccountId, log.SafeString(accountId)))
- results, sessionState, lang, jerr := g.jmap.QueryEmailSnippets(accountId, filter, req.session, req.ctx, logger, req.language(), offset, limit)
+ resultsByAccountId, sessionState, lang, jerr := g.jmap.QueryEmailSnippets([]string{accountId}, filter, req.session, req.ctx, logger, req.language(), offset, limit)
if jerr != nil {
return req.errorResponseFromJmap(jerr)
}
- return etagResponse(EmailSearchSnippetsResults{
- Results: results.Snippets,
- Total: results.Total,
- Limit: results.Limit,
- QueryState: results.QueryState,
- }, sessionState, results.QueryState, lang)
+ if results, ok := resultsByAccountId[accountId]; ok {
+ return etagResponse(EmailSearchSnippetsResults{
+ Results: structs.Map(results.Snippets, func(s jmap.SearchSnippetWithMeta) Snippet { return Snippet{SearchSnippetWithMeta: s} }),
+ Total: results.Total,
+ Limit: results.Limit,
+ QueryState: results.QueryState,
+ }, sessionState, results.QueryState, lang)
+ } else {
+ return notFoundResponse(sessionState)
+ }
}
})
}
@@ -562,6 +597,175 @@ func (g *Groupware) GetEmails(w http.ResponseWriter, r *http.Request) {
}
}
+func (g *Groupware) GetEmailsForAllAccounts(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(req Request) Response {
+ ok, filter, makesSnippets, offset, limit, logger, err := g.buildFilter(req)
+ if !ok {
+ return errorResponse(err)
+ }
+
+ if !filter.IsNotEmpty() {
+ filter = nil
+ }
+
+ fetchEmails, ok, err := req.parseBoolParam(QueryParamSearchFetchEmails, false)
+ if err != nil {
+ return errorResponse(err)
+ }
+ if ok {
+ logger = log.From(logger.With().Bool(QueryParamSearchFetchEmails, fetchEmails))
+ }
+
+ allAccountIds := structs.Keys(req.session.Accounts) // TODO(pbleser-oc) do we need a limit for a maximum amount of accounts to query at once?
+ logger = log.From(logger.With().Array(logAccountId, log.SafeStringArray(allAccountIds)))
+
+ if fetchEmails {
+ fetchBodies, ok, err := req.parseBoolParam(QueryParamSearchFetchBodies, false)
+ if err != nil {
+ return errorResponse(err)
+ }
+ if ok {
+ logger = log.From(logger.With().Bool(QueryParamSearchFetchBodies, fetchBodies))
+ }
+
+ if makesSnippets {
+ resultsByAccountId, sessionState, lang, jerr := g.jmap.QueryEmailsWithSnippets(allAccountIds, filter, req.session, req.ctx, logger, req.language(), offset, limit, fetchBodies, g.maxBodyValueBytes)
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+
+ flattenedByAccountId := make(map[string][]EmailWithSnippets, len(resultsByAccountId))
+ total := 0
+ var totalOverAllAccounts uint = 0
+ for accountId, results := range resultsByAccountId {
+ totalOverAllAccounts += results.Total
+ flattened := make([]EmailWithSnippets, len(results.Results))
+ for i, result := range results.Results {
+ snippets := structs.MapN(result.Snippets, func(s jmap.SearchSnippet) *SnippetWithoutEmailId {
+ if s.Subject != "" || s.Preview != "" {
+ return &SnippetWithoutEmailId{
+ Subject: s.Subject,
+ Preview: s.Preview,
+ }
+ } else {
+ return nil
+ }
+ })
+ flattened[i] = EmailWithSnippets{
+ AccountId: accountId,
+ Email: result.Email,
+ Snippets: snippets,
+ }
+ }
+ flattenedByAccountId[accountId] = flattened
+ total += len(flattened)
+ }
+
+ flattened := make([]EmailWithSnippets, total)
+ {
+ i := 0
+ for _, list := range flattenedByAccountId {
+ for _, e := range list {
+ flattened[i] = e
+ i++
+ }
+ }
+ }
+
+ slices.SortFunc(flattened, func(a, b EmailWithSnippets) int { return a.ReceivedAt.Compare(b.ReceivedAt) })
+ squashedQueryState := squashQueryState(resultsByAccountId, func(e jmap.EmailQueryWithSnippetsResult) jmap.State { return e.QueryState })
+
+ // TODO offset and limit over the aggregated results by account
+
+ return etagResponse(EmailWithSnippetsSearchResults{
+ Results: flattened,
+ Total: totalOverAllAccounts,
+ Limit: limit,
+ QueryState: squashedQueryState,
+ }, sessionState, squashedQueryState, lang)
+ } else {
+ resultsByAccountId, sessionState, lang, jerr := g.jmap.QueryEmails(allAccountIds, filter, req.session, req.ctx, logger, req.language(), offset, limit, fetchBodies, g.maxBodyValueBytes)
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+
+ total := 0
+ var totalOverAllAccounts uint = 0
+ for _, results := range resultsByAccountId {
+ totalOverAllAccounts += results.Total
+ total += len(results.Emails)
+ }
+
+ flattened := make([]jmap.Email, total)
+ {
+ i := 0
+ for _, list := range resultsByAccountId {
+ for _, e := range list.Emails {
+ flattened[i] = e
+ i++
+ }
+ }
+ }
+
+ slices.SortFunc(flattened, func(a, b jmap.Email) int { return a.ReceivedAt.Compare(b.ReceivedAt) })
+ squashedQueryState := squashQueryState(resultsByAccountId, func(e jmap.EmailQueryResult) jmap.State { return e.QueryState })
+
+ // TODO offset and limit over the aggregated results by account
+
+ return etagResponse(EmailSearchResults{
+ Results: flattened,
+ Total: totalOverAllAccounts,
+ Limit: limit,
+ QueryState: squashedQueryState,
+ }, sessionState, squashedQueryState, lang)
+ }
+ } else {
+ if makesSnippets {
+ resultsByAccountId, sessionState, lang, jerr := g.jmap.QueryEmailSnippets(allAccountIds, filter, req.session, req.ctx, logger, req.language(), offset, limit)
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+
+ var totalOverAllAccounts uint = 0
+ total := 0
+ for _, results := range resultsByAccountId {
+ totalOverAllAccounts += results.Total
+ total += len(results.Snippets)
+ }
+
+ flattened := make([]Snippet, total)
+ {
+ i := 0
+ for accountId, results := range resultsByAccountId {
+ for _, result := range results.Snippets {
+ flattened[i] = Snippet{
+ AccountId: accountId,
+ SearchSnippetWithMeta: result,
+ }
+ }
+ }
+ }
+
+ slices.SortFunc(flattened, func(a, b Snippet) int { return a.ReceivedAt.Compare(b.ReceivedAt) })
+
+ // TODO offset and limit over the aggregated results by account
+
+ squashedQueryState := squashQueryState(resultsByAccountId, func(e jmap.EmailSnippetQueryResult) jmap.State { return e.QueryState })
+
+ return etagResponse(EmailSearchSnippetsResults{
+ Results: flattened,
+ Total: totalOverAllAccounts,
+ Limit: limit,
+ QueryState: squashedQueryState,
+ }, sessionState, squashedQueryState, lang)
+ } else {
+ // TODO implement search without email bodies (only retrieve a few chosen properties?) + without snippets
+ return notImplementesResponse()
+ }
+ }
+ })
+}
+
type EmailCreation struct {
MailboxIds []string `json:"mailboxIds,omitempty"`
Keywords []string `json:"keywords,omitempty"`
@@ -1086,17 +1290,19 @@ func (g *Groupware) RelatedToEmail(w http.ResponseWriter, r *http.Request) {
g.job(logger, RelationTypeSameSender, func(jobId uint64, l *log.Logger) {
before := time.Now()
- results, _, lang, jerr := g.jmap.QueryEmails(accountId, filter, req.session, bgctx, l, req.language(), 0, limit, false, g.maxBodyValueBytes)
- duration := time.Since(before)
- if jerr != nil {
- req.observeJmapError(jerr)
- l.Error().Err(jerr).Msgf("failed to query %v emails", RelationTypeSameSender)
- } else {
- req.observe(g.metrics.EmailSameSenderDuration.WithLabelValues(req.session.JmapEndpoint), duration.Seconds())
- related := filterEmails(results.Emails, email)
- l.Trace().Msgf("'%v' found %v other emails", RelationTypeSameSender, len(related))
- if len(related) > 0 {
- req.push(RelationEntityEmail, AboutEmailsEvent{Id: reqId, Emails: related, Source: RelationTypeSameSender, Language: lang})
+ resultsByAccountId, _, lang, jerr := g.jmap.QueryEmails([]string{accountId}, filter, req.session, bgctx, l, req.language(), 0, limit, false, g.maxBodyValueBytes)
+ if results, ok := resultsByAccountId[accountId]; ok {
+ duration := time.Since(before)
+ if jerr != nil {
+ req.observeJmapError(jerr)
+ l.Error().Err(jerr).Msgf("failed to query %v emails", RelationTypeSameSender)
+ } else {
+ req.observe(g.metrics.EmailSameSenderDuration.WithLabelValues(req.session.JmapEndpoint), duration.Seconds())
+ related := filterEmails(results.Emails, email)
+ l.Trace().Msgf("'%v' found %v other emails", RelationTypeSameSender, len(related))
+ if len(related) > 0 {
+ req.push(RelationEntityEmail, AboutEmailsEvent{Id: reqId, Emails: related, Source: RelationTypeSameSender, Language: lang})
+ }
}
}
})
@@ -1433,9 +1639,7 @@ func (g *Groupware) GetLatestEmailsSummaryForAllAccounts(w http.ResponseWriter,
}
}
- sort.Slice(all, func(i int, j int) bool {
- return all[i].email.ReceivedAt.Before(all[j].email.ReceivedAt)
- })
+ slices.SortFunc(all, func(a, b emailWithAccountId) int { return -(a.email.ReceivedAt.Compare(b.email.ReceivedAt)) })
summaries := make([]EmailSummary, min(limit, total))
for i = 0; i < limit && i < total; i++ {
@@ -1470,3 +1674,32 @@ func filterFromNotKeywords(keywords []string) jmap.EmailFilterElement {
return jmap.EmailFilterOperator{Operator: jmap.And, Conditions: conditions}
}
}
+
+func squashQueryState[V any](all map[string]V, mapper func(V) jmap.State) jmap.State {
+ n := len(all)
+ if n == 0 {
+ return jmap.State("")
+ }
+ if n == 1 {
+ for _, v := range all {
+ return mapper(v)
+ }
+ }
+
+ parts := make([]string, n)
+ sortedKeys := make([]string, n)
+ i := 0
+ for k := range all {
+ sortedKeys[i] = k
+ i++
+ }
+ slices.Sort(sortedKeys)
+ for i, k := range sortedKeys {
+ if v, ok := all[k]; ok {
+ parts[i] = k + ":" + string(mapper(v))
+ } else {
+ parts[i] = k + ":"
+ }
+ }
+ return jmap.State(strings.Join(parts, ","))
+}
diff --git a/services/groupware/pkg/groupware/groupware_api_identity.go b/services/groupware/pkg/groupware/groupware_api_identity.go
index 1cdd1618b2..a6a2bc98c7 100644
--- a/services/groupware/pkg/groupware/groupware_api_identity.go
+++ b/services/groupware/pkg/groupware/groupware_api_identity.go
@@ -3,6 +3,7 @@ package groupware
import (
"net/http"
+ "github.com/go-chi/chi/v5"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/log"
)
@@ -27,15 +28,78 @@ type SwaggerGetIdentitiesResponse struct {
// 500: ErrorResponse500
func (g *Groupware) GetIdentities(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
- accountId, err := req.GetAccountIdWithoutFallback()
+ accountId, err := req.GetAccountIdForMail()
if err != nil {
return errorResponse(err)
}
logger := log.From(req.logger.With().Str(logAccountId, accountId))
- res, sessionState, lang, jerr := g.jmap.GetIdentity(accountId, req.session, req.ctx, logger, req.language())
+ res, sessionState, lang, jerr := g.jmap.GetAllIdentities(accountId, req.session, req.ctx, logger, req.language())
if jerr != nil {
return req.errorResponseFromJmap(jerr)
}
return etagResponse(res, sessionState, res.State, lang)
})
}
+
+func (g *Groupware) GetIdentityById(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(req Request) Response {
+ accountId, err := req.GetAccountIdWithoutFallback()
+ if err != nil {
+ return errorResponse(err)
+ }
+ id := chi.URLParam(r, UriParamIdentityId)
+ logger := log.From(req.logger.With().Str(logAccountId, accountId).Str(logIdentityId, id))
+ res, sessionState, lang, jerr := g.jmap.GetIdentities(accountId, req.session, req.ctx, logger, req.language(), []string{id})
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+ if len(res.Identities) < 1 {
+ return notFoundResponse(sessionState)
+ }
+ return etagResponse(res.Identities[0], sessionState, res.State, lang)
+ })
+}
+
+func (g *Groupware) AddIdentity(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(req Request) Response {
+ accountId, err := req.GetAccountIdWithoutFallback()
+ if err != nil {
+ return errorResponse(err)
+ }
+ logger := log.From(req.logger.With().Str(logAccountId, accountId))
+
+ var identity jmap.Identity
+ err = req.body(&identity)
+ if err != nil {
+ return errorResponse(err)
+ }
+
+ newState, sessionState, _, jerr := g.jmap.CreateIdentity(accountId, req.session, req.ctx, logger, req.language(), identity)
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+ return noContentResponseWithEtag(sessionState, newState)
+ })
+}
+
+func (g *Groupware) ModifyIdentity(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(req Request) Response {
+ accountId, err := req.GetAccountIdWithoutFallback()
+ if err != nil {
+ return errorResponse(err)
+ }
+ logger := log.From(req.logger.With().Str(logAccountId, accountId))
+
+ var identity jmap.Identity
+ err = req.body(&identity)
+ if err != nil {
+ return errorResponse(err)
+ }
+
+ newState, sessionState, _, jerr := g.jmap.UpdateIdentity(accountId, req.session, req.ctx, logger, req.language(), identity)
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+ return noContentResponseWithEtag(sessionState, newState)
+ })
+}
diff --git a/services/groupware/pkg/groupware/groupware_framework.go b/services/groupware/pkg/groupware/groupware_framework.go
index 63d1d6547c..33613521ec 100644
--- a/services/groupware/pkg/groupware/groupware_framework.go
+++ b/services/groupware/pkg/groupware/groupware_framework.go
@@ -43,6 +43,7 @@ const (
logInvalidQueryParameter = "error-query-param"
logInvalidPathParameter = "error-path-param"
logFolderId = "folder-id"
+ logIdentityId = "identity-id"
logQuery = "query"
logEmailId = "email-id"
logJobDescription = "job"
diff --git a/services/groupware/pkg/groupware/groupware_request.go b/services/groupware/pkg/groupware/groupware_request.go
index ea21dca348..56d6b67399 100644
--- a/services/groupware/pkg/groupware/groupware_request.go
+++ b/services/groupware/pkg/groupware/groupware_request.go
@@ -123,15 +123,21 @@ func (r Request) GetAccountIdForSubmission() (string, *Error) {
}
func (r Request) GetAccountIdForTask() (string, *Error) {
- return r.getAccountId(r.session.PrimaryAccounts.Task, errNoPrimaryAccountForTask)
+ // TODO we don't have these yet, not implemented in Stalwart
+ // return r.getAccountId(r.session.PrimaryAccounts.Task, errNoPrimaryAccountForTask)
+ return r.GetAccountIdForMail()
}
func (r Request) GetAccountIdForCalendar() (string, *Error) {
- return r.getAccountId(r.session.PrimaryAccounts.Calendar, errNoPrimaryAccountForCalendar)
+ // TODO we don't have these yet, not implemented in Stalwart
+ // return r.getAccountId(r.session.PrimaryAccounts.Calendar, errNoPrimaryAccountForCalendar)
+ return r.GetAccountIdForMail()
}
func (r Request) GetAccountIdForContact() (string, *Error) {
- return r.getAccountId(r.session.PrimaryAccounts.Contact, errNoPrimaryAccountForContact)
+ // TODO we don't have these yet, not implemented in Stalwart
+ // return r.getAccountId(r.session.PrimaryAccounts.Contact, errNoPrimaryAccountForContact)
+ return r.GetAccountIdForMail()
}
func (r Request) GetAccountForMail() (jmap.Account, *Error) {
diff --git a/services/groupware/pkg/groupware/groupware_response.go b/services/groupware/pkg/groupware/groupware_response.go
index 51fec8c418..a2f283eec4 100644
--- a/services/groupware/pkg/groupware/groupware_response.go
+++ b/services/groupware/pkg/groupware/groupware_response.go
@@ -116,3 +116,11 @@ func notFoundResponse(sessionState jmap.SessionState) Response {
sessionState: sessionState,
}
}
+
+func notImplementesResponse() Response {
+ return Response{
+ body: nil,
+ status: http.StatusNotImplemented,
+ err: nil,
+ }
+}
diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go
index 17f49ffe69..40515201e8 100644
--- a/services/groupware/pkg/groupware/groupware_route.go
+++ b/services/groupware/pkg/groupware/groupware_route.go
@@ -14,6 +14,7 @@ const (
UriParamAccountId = "accountid"
UriParamMailboxId = "mailbox"
UriParamEmailId = "emailid"
+ UriParamIdentityId = "identityid"
UriParamBlobId = "blobid"
UriParamBlobName = "blobname"
UriParamStreamId = "stream"
@@ -66,13 +67,19 @@ func (g *Groupware) Route(r chi.Router) {
r.Get("/roles/{role}", g.GetMailboxByRoleForAllAccounts) // ?role=
})
r.Route("/emails", func(r chi.Router) {
+ r.Get("/", g.GetEmailsForAllAccounts)
r.Get("/latest/summary", g.GetLatestEmailsSummaryForAllAccounts) // ?limit=10&seen=true&undesirable=true
})
r.Get("/quota", g.GetQuotaForAllAccounts)
})
r.Route("/accounts/{accountid}", func(r chi.Router) {
r.Get("/", g.GetAccount)
- r.Get("/identities", g.GetIdentities)
+ r.Route("/identities", func(r chi.Router) {
+ r.Get("/", g.GetIdentities)
+ r.Get("/{identityid}", g.GetIdentityById)
+ r.Post("/", g.AddIdentity)
+ r.Patch("/{identityid}", g.ModifyIdentity)
+ })
r.Get("/vacation", g.GetVacation)
r.Put("/vacation", g.SetVacation)
r.Get("/quota", g.GetQuota)