From ffc0e1f42ad01234ec34b42d670eca98b19e62ee Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Mon, 27 Apr 2026 10:29:47 +0200
Subject: [PATCH] groupware: add support for anchor and anchor offset
pagination
* add query parameters 'anchor' and 'offset':
- anchor is an object identifier
- offset is a numeric offset relative to the anchor
---
pkg/jmap/api_contact.go | 10 +--
pkg/jmap/api_email.go | 4 +-
pkg/jmap/api_event.go | 8 +--
pkg/jmap/api_mailbox.go | 6 +-
pkg/jmap/api_principal.go | 10 +--
...est.go => integration_addressbook_test.go} | 70 ++++++++++++++++++-
pkg/jmap/integration_calendar_test.go | 6 +-
pkg/jmap/integration_email_test.go | 4 +-
pkg/jmap/integration_test.go | 2 +-
pkg/jmap/model.go | 12 ++--
pkg/jmap/templates.go | 12 ++--
pkg/jmap/tools.go | 4 +-
.../groupware/pkg/groupware/api_emails.go | 4 +-
.../groupware/pkg/groupware/api_events.go | 8 +--
services/groupware/pkg/groupware/route.go | 2 +
services/groupware/pkg/groupware/templates.go | 44 ++++++++++--
16 files changed, 154 insertions(+), 52 deletions(-)
rename pkg/jmap/{integration_contact_test.go => integration_addressbook_test.go} (88%)
diff --git a/pkg/jmap/api_contact.go b/pkg/jmap/api_contact.go
index 4e5d8d0cff..1185e24ef1 100644
--- a/pkg/jmap/api_contact.go
+++ b/pkg/jmap/api_contact.go
@@ -74,14 +74,14 @@ func (r *ContactCardSearchResults) GetTotal() *uint { return r.Tota
func (r *ContactCardSearchResults) RemoveResults() { r.Results = nil }
func (r *ContactCardSearchResults) SetLimit(limit *uint) { r.Limit = limit }
-func (j *Client) QueryContactCards(accountIds []string,
+func (j *Client) QueryContactCards(accountIds []string, //NOSONAR
filter ContactCardFilterElement, sortBy []ContactCardComparator,
- position int, limit *uint, calculateTotal bool,
+ position int, anchor string, anchorOffset *int, limit *uint, calculateTotal bool,
ctx Context) (map[string]*ContactCardSearchResults, SessionState, State, Language, Error) {
return queryN(j, "QueryContactCards", ContactCardType,
[]ContactCardComparator{{Property: ContactCardPropertyUpdated, IsAscending: false}},
- func(accountId string, filter ContactCardFilterElement, sortBy []ContactCardComparator, position int, limit *uint) ContactCardQueryCommand {
- return ContactCardQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: position, Limit: limit, CalculateTotal: calculateTotal}
+ func(accountId string, filter ContactCardFilterElement, sortBy []ContactCardComparator, position int, anchor string, anchorOffset *int, limit *uint) ContactCardQueryCommand {
+ return ContactCardQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: position, Anchor: anchor, AnchorOffset: anchorOffset, Limit: limit, CalculateTotal: calculateTotal}
},
func(accountId string, cmd Command, path string, rof string) ContactCardGetRefCommand {
return ContactCardGetRefCommand{AccountId: accountId, IdsRef: &ResultReference{Name: cmd, Path: path, ResultOf: rof}}
@@ -96,7 +96,7 @@ func (j *Client) QueryContactCards(accountIds []string,
}
},
accountIds,
- filter, sortBy, limit, position, ctx,
+ filter, sortBy, position, anchor, anchorOffset, limit, ctx,
)
}
diff --git a/pkg/jmap/api_email.go b/pkg/jmap/api_email.go
index d4cb25f7bf..c4678178ef 100644
--- a/pkg/jmap/api_email.go
+++ b/pkg/jmap/api_email.go
@@ -125,7 +125,7 @@ func (r *EmailSearchResults) SetLimit(limit *uint) { r.Limit = limit }
// Retrieve all the Emails in a given Mailbox by its id.
func (j *Client) GetAllEmailsInMailbox(accountId string, mailboxId string, //NOSONAR
- position int, limit *uint, collapseThreads bool, fetchBodies bool, maxBodyValueBytes uint, withThreads bool,
+ position int, anchor string, anchorOffset *int, limit *uint, collapseThreads bool, fetchBodies bool, maxBodyValueBytes uint, withThreads bool,
ctx Context) (*EmailSearchResults, SessionState, State, Language, Error) {
logger := j.loggerParams("GetAllEmailsInMailbox", ctx, func(z zerolog.Context) zerolog.Context {
l := z.Bool(logFetchBodies, fetchBodies).Int(logPosition, position)
@@ -143,6 +143,8 @@ func (j *Client) GetAllEmailsInMailbox(accountId string, mailboxId string, //NOS
CollapseThreads: collapseThreads,
CalculateTotal: true,
Position: position,
+ Anchor: anchor,
+ AnchorOffset: anchorOffset,
Limit: limit,
}
diff --git a/pkg/jmap/api_event.go b/pkg/jmap/api_event.go
index 1e3edad647..9fbb8450d4 100644
--- a/pkg/jmap/api_event.go
+++ b/pkg/jmap/api_event.go
@@ -28,12 +28,12 @@ func (j *Client) GetCalendarEvents(accountId string, eventIds []string, ctx Cont
func (j *Client) QueryCalendarEvents(accountIds []string, //NOSONAR
filter CalendarEventFilterElement, sortBy []CalendarEventComparator,
- position int, limit *uint, calculateTotal bool,
+ position int, anchor string, anchorOffset *int, limit *uint, calculateTotal bool,
ctx Context) (map[string]*CalendarEventSearchResults, SessionState, State, Language, Error) {
return queryN(j, "QueryCalendarEvents", CalendarEventType,
[]CalendarEventComparator{{Property: CalendarEventPropertyStart, IsAscending: false}},
- func(accountId string, filter CalendarEventFilterElement, sortBy []CalendarEventComparator, position int, limit *uint) CalendarEventQueryCommand {
- return CalendarEventQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: position, Limit: limit, CalculateTotal: calculateTotal}
+ func(accountId string, filter CalendarEventFilterElement, sortBy []CalendarEventComparator, position int, anchor string, anchorOffset *int, limit *uint) CalendarEventQueryCommand {
+ return CalendarEventQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: position, Anchor: anchor, AnchorOffset: anchorOffset, Limit: limit, CalculateTotal: calculateTotal}
},
func(accountId string, cmd Command, path string, rof string) CalendarEventGetRefCommand {
return CalendarEventGetRefCommand{AccountId: accountId, IdsRef: &ResultReference{Name: cmd, Path: path, ResultOf: rof}}
@@ -48,7 +48,7 @@ func (j *Client) QueryCalendarEvents(accountIds []string, //NOSONAR
}
},
accountIds,
- filter, sortBy, limit, position, ctx,
+ filter, sortBy, position, anchor, anchorOffset, limit, ctx,
)
}
diff --git a/pkg/jmap/api_mailbox.go b/pkg/jmap/api_mailbox.go
index c72a23bc7d..28e151d071 100644
--- a/pkg/jmap/api_mailbox.go
+++ b/pkg/jmap/api_mailbox.go
@@ -185,8 +185,8 @@ func (j *Client) GetMailboxChangesForMultipleAccounts(accountIds []string, //NOS
func (j *Client) GetMailboxRolesForMultipleAccounts(accountIds []string, ctx Context) (map[string]*[]string, SessionState, State, Language, Error) {
return queryN(j, "GetMailboxRolesForMultipleAccounts", MailboxType,
[]MailboxComparator{{Property: MailboxPropertySortOrder, IsAscending: true}},
- func(accountId string, filter MailboxFilterCondition, sortBy []MailboxComparator, _ int, _ *uint) MailboxQueryCommand {
- return MailboxQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, SortAsTree: false, FilterAsTree: false, Position: 0, Limit: nil, CalculateTotal: false}
+ func(accountId string, filter MailboxFilterCondition, sortBy []MailboxComparator, _ int, _ string, _ *int, _ *uint) MailboxQueryCommand {
+ return MailboxQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, SortAsTree: false, FilterAsTree: false, Position: 0, Anchor: "", AnchorOffset: nil, Limit: nil, CalculateTotal: false}
},
func(accountId string, cmd Command, path, rof string) MailboxGetRefCommand {
return MailboxGetRefCommand{AccountId: accountId, IdsRef: &ResultReference{Name: cmd, Path: path, ResultOf: rof}}
@@ -196,7 +196,7 @@ func (j *Client) GetMailboxRolesForMultipleAccounts(accountIds []string, ctx Con
slices.Sort(roles)
return &roles
},
- accountIds, MailboxFilterCondition{HasAnyRole: truep}, nil, nil, 0,
+ accountIds, MailboxFilterCondition{HasAnyRole: truep}, nil, 0, "", nil, nil,
ctx,
)
}
diff --git a/pkg/jmap/api_principal.go b/pkg/jmap/api_principal.go
index e5198a0b43..c8d48b37d6 100644
--- a/pkg/jmap/api_principal.go
+++ b/pkg/jmap/api_principal.go
@@ -26,14 +26,14 @@ func (r *PrincipalSearchResults) GetTotal() *uint { return r.Total
func (r *PrincipalSearchResults) RemoveResults() { r.Results = nil }
func (r *PrincipalSearchResults) SetLimit(limit *uint) { r.Limit = limit }
-func (j *Client) QueryPrincipals(accountId string,
+func (j *Client) QueryPrincipals(accountId string, //NOSONAR
filter PrincipalFilterElement, sortBy []PrincipalComparator,
- position uint, limit *uint, calculateTotal bool,
+ position int, anchor string, anchorOffset *int, limit *uint, calculateTotal bool,
ctx Context) (*PrincipalSearchResults, SessionState, State, Language, Error) {
return query(j, "QueryPrincipals", PrincipalType,
[]PrincipalComparator{{Property: PrincipalPropertyName, IsAscending: true}},
- func(filter PrincipalFilterElement, sortBy []PrincipalComparator, position uint, limit *uint) PrincipalQueryCommand {
- return PrincipalQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: position, Limit: limit, CalculateTotal: calculateTotal}
+ func(filter PrincipalFilterElement, sortBy []PrincipalComparator, position int, anchor string, anchorOffset *int, limit *uint) PrincipalQueryCommand {
+ return PrincipalQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: position, Anchor: anchor, AnchorOffset: anchorOffset, Limit: limit, CalculateTotal: calculateTotal}
},
func(cmd Command, path string, rof string) PrincipalGetRefCommand {
return PrincipalGetRefCommand{AccountId: accountId, IdsRef: &ResultReference{Name: cmd, Path: path, ResultOf: rof}}
@@ -47,6 +47,6 @@ func (j *Client) QueryPrincipals(accountId string,
Limit: ptrIf(query.Limit, limit != nil),
}
},
- filter, sortBy, limit, position, ctx,
+ filter, sortBy, position, anchor, anchorOffset, limit, ctx,
)
}
diff --git a/pkg/jmap/integration_contact_test.go b/pkg/jmap/integration_addressbook_test.go
similarity index 88%
rename from pkg/jmap/integration_contact_test.go
rename to pkg/jmap/integration_addressbook_test.go
index c9e47db610..447d676a72 100644
--- a/pkg/jmap/integration_contact_test.go
+++ b/pkg/jmap/integration_addressbook_test.go
@@ -100,14 +100,14 @@ func TestContacts(t *testing.T) {
{Property: ContactCardPropertyCreated, IsAscending: true},
}
- contactsByAccount, ss, os, _, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, nil, true, ctx)
+ contactsByAccount, ss, os, _, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, "", nil, nil, true, ctx)
require.NoError(err)
require.Len(contactsByAccount, 1)
require.Contains(contactsByAccount, accountId)
results := contactsByAccount[accountId]
require.Len(results.Results, int(count))
- require.Equal(uint(0), results.Limit)
+ require.Nil(results.Limit)
require.Equal(uint(0), results.Position)
require.NotNil(results.Total)
require.Equal(count, *results.Total)
@@ -142,6 +142,70 @@ func TestContacts(t *testing.T) {
matchContact(t, fetched.List[0], actual)
}
+ {
+ limit := uint(10)
+ slices := count / limit
+ remainder := count
+ require.Greater(slices, uint(1), "we need to have more than 10 objects in order to test the pagination of search results")
+ for i := range slices {
+ position := int(i * limit)
+ page := min(remainder, limit)
+ m, sessionState, _, _, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, position, "", nil, &limit, true, ctx)
+ require.NoError(err)
+ require.Len(m, 1)
+ require.Contains(m, accountId)
+ results := m[accountId]
+ require.Equal(len(results.Results), int(page))
+ require.NotNil(results.Limit)
+ require.Equal(limit, *results.Limit)
+ require.Equal(uint(position), results.Position)
+ require.Equal(true, results.CanCalculateChanges)
+ require.NotNil(results.Total)
+ require.Equal(count, *results.Total)
+ remainder -= uint(len(results.Results))
+
+ require.Equal(ss, sessionState)
+ }
+ }
+
+ {
+ chunkSize := 3
+ anchor := results.Results[0].Id
+ offset := 0
+ i := 0
+ for chunk := range slices.Chunk(results.Results, chunkSize) {
+ m, sessionState, _, _, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, anchor, &offset, uintPtr(chunkSize), true, ctx)
+ require.Equal(ss, sessionState)
+ require.NoError(err)
+ require.Len(m, 1)
+ require.Contains(m, accountId)
+ results := m[accountId]
+ l := len(results.Results)
+ require.LessOrEqual(l, chunkSize)
+ require.NotZero(l)
+ require.NotNil(results.Limit)
+ require.Equal(uint(chunkSize), *results.Limit)
+ //require.Equal(uint(i*chunkSize), results.Position)
+ require.Equal(true, results.CanCalculateChanges)
+ require.NotNil(results.Total)
+ require.Equal(count, *results.Total)
+
+ fmt.Printf("\x1b[34;1m===[%d]========================================\x1b[0m\n", i)
+ fmt.Printf("pos: %d\n", results.Position)
+ fmt.Printf("chunk : %s\n", strings.Join(structs.Map(chunk, func(c ContactCard) string { return c.Id }), " | "))
+ fmt.Printf("results: %s\n", strings.Join(structs.Map(results.Results, func(c ContactCard) string { return c.Id }), " | "))
+ fmt.Printf("============================================\n")
+
+ for i := range l {
+ require.Equal(chunk[i].Id, results.Results[i].Id)
+ }
+ anchor = chunk[len(chunk)-1].Id
+ offset = 1
+ i++
+ }
+ require.True(false)
+ }
+
{
now := time.Now().Truncate(time.Duration(1) * time.Second).UTC()
for _, event := range expectedContactCardsById {
@@ -169,7 +233,7 @@ func TestContacts(t *testing.T) {
os = state
}
{
- shouldBeEmpty, sessionState, state, _, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, nil, true, ctx)
+ shouldBeEmpty, sessionState, state, _, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, "", nil, nil, true, ctx)
require.NoError(err)
require.Contains(shouldBeEmpty, accountId)
resp := shouldBeEmpty[accountId]
diff --git a/pkg/jmap/integration_calendar_test.go b/pkg/jmap/integration_calendar_test.go
index 95220c1388..cc2fa51c4a 100644
--- a/pkg/jmap/integration_calendar_test.go
+++ b/pkg/jmap/integration_calendar_test.go
@@ -93,7 +93,7 @@ func TestEvents(t *testing.T) {
ss := EmptySessionState
os := EmptyState
{
- resultsByAccount, sessionState, state, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, nil, true, ctx)
+ resultsByAccount, sessionState, state, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, "", nil, nil, true, ctx)
require.NoError(err)
require.Len(resultsByAccount, 1)
@@ -124,7 +124,7 @@ func TestEvents(t *testing.T) {
for i := range slices {
position := int(i * limit)
page := min(remainder, limit)
- m, sessionState, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, position, &limit, true, ctx)
+ m, sessionState, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, position, "", nil, &limit, true, ctx)
require.NoError(err)
require.Len(m, 1)
require.Contains(m, accountId)
@@ -173,7 +173,7 @@ func TestEvents(t *testing.T) {
}
{
- shouldBeEmpty, sessionState, state, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, nil, true, ctx)
+ shouldBeEmpty, sessionState, state, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, "", nil, nil, true, ctx)
require.NoError(err)
require.Contains(shouldBeEmpty, accountId)
resp := shouldBeEmpty[accountId]
diff --git a/pkg/jmap/integration_email_test.go b/pkg/jmap/integration_email_test.go
index 96303ef009..59c10949fe 100644
--- a/pkg/jmap/integration_email_test.go
+++ b/pkg/jmap/integration_email_test.go
@@ -81,7 +81,7 @@ func TestEmails(t *testing.T) {
}
{
- resp, sessionState, _, _, err := s.client.GetAllEmailsInMailbox(accountId, inboxId, 0, nil, true, false, 0, true, ctx)
+ resp, sessionState, _, _, err := s.client.GetAllEmailsInMailbox(accountId, inboxId, 0, "", nil, nil, true, false, 0, true, ctx)
require.NoError(err)
require.Equal(session.State, sessionState)
@@ -95,7 +95,7 @@ func TestEmails(t *testing.T) {
}
{
- resp, sessionState, _, _, err := s.client.GetAllEmailsInMailbox(accountId, inboxId, 0, nil, false, false, 0, true, ctx)
+ resp, sessionState, _, _, err := s.client.GetAllEmailsInMailbox(accountId, inboxId, 0, "", nil, nil, false, false, 0, true, ctx)
require.NoError(err)
require.Equal(session.State, sessionState)
diff --git a/pkg/jmap/integration_test.go b/pkg/jmap/integration_test.go
index 054cc84729..a9c46c4fb1 100644
--- a/pkg/jmap/integration_test.go
+++ b/pkg/jmap/integration_test.go
@@ -223,7 +223,7 @@ func withDirectoryQueries(allowDirectoryQueries bool) func(map[string]any) {
}
func newStalwartTest(t *testing.T, options ...func(map[string]any)) (*StalwartTest, error) { //NOSONAR
- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
var _ context.CancelFunc = cancel // ignore context leak warning: it is passed in the struct and called in Close()
// A master user name different from "master" does not seem to work as of the current Stalwart version
diff --git a/pkg/jmap/model.go b/pkg/jmap/model.go
index 78e5acc1cb..e1e00995aa 100644
--- a/pkg/jmap/model.go
+++ b/pkg/jmap/model.go
@@ -1833,7 +1833,7 @@ type MailboxQueryCommand struct {
//
// For example, -1 means the object immediately preceding the anchor is the first result in
// the list returned.
- AnchorOffset int `json:"anchorOffset,omitzero" doc:"opt" default:"0"`
+ AnchorOffset *int `json:"anchorOffset,omitempty" doc:"opt" default:"0"`
// The maximum number of results to return.
//
@@ -2129,7 +2129,7 @@ type EmailQueryCommand struct {
//
// For example, -1 means the Email immediately preceding the anchor is the first result in
// the list returned.
- AnchorOffset int `json:"anchorOffset,omitzero" doc:"opt" default:"0"`
+ AnchorOffset *int `json:"anchorOffset,omitempty" doc:"opt" default:"0"`
// The maximum number of results to return.
//
@@ -7074,7 +7074,7 @@ type ContactCardQueryCommand struct {
//
// For example, -1 means the Email immediately preceding the anchor is the first result in
// the list returned.
- AnchorOffset int `json:"anchorOffset,omitzero" default:"0" doc:"opt"`
+ AnchorOffset *int `json:"anchorOffset,omitempty" default:"0" doc:"opt"`
// The maximum number of results to return.
//
@@ -7804,7 +7804,7 @@ type CalendarEventQueryCommand struct {
//
// For example, -1 means the Email immediately preceding the anchor is the first result in
// the list returned.
- AnchorOffset int `json:"anchorOffset,omitzero" doc:"opt" default:"0"`
+ AnchorOffset *int `json:"anchorOffset,omitempty" doc:"opt" default:"0"`
// The maximum number of results to return.
//
@@ -8265,7 +8265,7 @@ type PrincipalQueryCommand struct {
//
// If the index is greater than or equal to the total number of objects in the results
// list, then the ids array in the response will be empty, but this is not an error.
- Position uint `json:"position,omitzero" default:"0" doc:"opt"`
+ Position int `json:"position,omitzero" default:"0" doc:"opt"`
// An Email id.
//
@@ -8281,7 +8281,7 @@ type PrincipalQueryCommand struct {
//
// For example, -1 means the Principal immediately preceding the anchor is the first result in
// the list returned.
- AnchorOffset int `json:"anchorOffset,omitzero" default:"0" doc:"opt"`
+ AnchorOffset *int `json:"anchorOffset,omitempty" default:"0" doc:"opt"`
// The maximum number of results to return.
//
diff --git a/pkg/jmap/templates.go b/pkg/jmap/templates.go
index 57aa11ed2b..61f826bdad 100644
--- a/pkg/jmap/templates.go
+++ b/pkg/jmap/templates.go
@@ -430,10 +430,10 @@ func update[T Foo, CHANGES Change, SET SetCommand[T], GET GetCommand[T], RESP an
func query[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T], QUERYRESP QueryResponse[T], GETRESP GetResponse[T], RESP any]( //NOSONAR
client *Client, name string, objType ObjectType,
defaultSortBy []SORT,
- queryCommandFactory func(filter FILTER, sortBy []SORT, position uint, limit *uint) QUERY,
+ queryCommandFactory func(filter FILTER, sortBy []SORT, position int, anchor string, anchorOffset *int, limit *uint) QUERY,
getCommandFactory func(cmd Command, path string, rof string) GET,
respMapper func(query QUERYRESP, get GETRESP) *RESP,
- filter FILTER, sortBy []SORT, limit *uint, position uint,
+ filter FILTER, sortBy []SORT, position int, anchor string, anchorOffset *int, limit *uint,
ctx Context) (*RESP, SessionState, State, Language, Error) {
logger := client.logger(name, ctx)
@@ -443,7 +443,7 @@ func query[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T]
sortBy = defaultSortBy
}
- query := queryCommandFactory(filter, sortBy, position, limit)
+ query := queryCommandFactory(filter, sortBy, position, anchor, anchorOffset, limit)
get := getCommandFactory(query.GetCommand(), "/ids/*", "0")
cmd, err := client.request(ctx, objType.Namespaces, invocation(query, "0"), invocation(get, "1"))
@@ -469,11 +469,11 @@ func query[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T]
func queryN[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T], QUERYRESP QueryResponse[T], GETRESP GetResponse[T], RESP any]( //NOSONAR
client *Client, name string, objType ObjectType,
defaultSortBy []SORT,
- queryCommandFactory func(accountId string, filter FILTER, sortBy []SORT, position int, limit *uint) QUERY,
+ queryCommandFactory func(accountId string, filter FILTER, sortBy []SORT, position int, anchor string, anchorOffset *int, imit *uint) QUERY,
getCommandFactory func(accountId string, cmd Command, path string, rof string) GET,
respMapper func(query QUERYRESP, get GETRESP) *RESP,
accountIds []string,
- filter FILTER, sortBy []SORT, limit *uint, position int,
+ filter FILTER, sortBy []SORT, position int, anchor string, anchorOffset *int, limit *uint,
ctx Context) (map[string]*RESP, SessionState, State, Language, Error) {
logger := client.logger(name, ctx)
ctx = ctx.WithLogger(logger)
@@ -488,7 +488,7 @@ func queryN[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T
var g GET
var q QUERY
for i, accountId := range uniqueAccountIds {
- query := queryCommandFactory(accountId, filter, sortBy, position, limit)
+ query := queryCommandFactory(accountId, filter, sortBy, position, anchor, anchorOffset, limit)
get := getCommandFactory(accountId, query.GetCommand(), "/ids/*", mcid(accountId, "0"))
invocations[i*2+0] = invocation(query, mcid(accountId, "0"))
invocations[i*2+1] = invocation(get, mcid(accountId, "1"))
diff --git a/pkg/jmap/tools.go b/pkg/jmap/tools.go
index 7a2f3ac85f..c01f4a12de 100644
--- a/pkg/jmap/tools.go
+++ b/pkg/jmap/tools.go
@@ -409,8 +409,8 @@ func identity1[T any](t T) T {
func list[T Foo, GETRESP GetResponse[T]](r GETRESP) []T { return r.GetList() }
func getid[T Idable](r T) string { return r.GetId() }
-func uintPtr(i uint) *uint {
- return ptr(i)
+func uintPtr[T int | uint](i T) *uint {
+ return ptr(uint(i))
}
func valueIf[T any | uint | int | bool](value *T, condition bool) *T {
diff --git a/services/groupware/pkg/groupware/api_emails.go b/services/groupware/pkg/groupware/api_emails.go
index f8e52f1d04..5f59f61991 100644
--- a/services/groupware/pkg/groupware/api_emails.go
+++ b/services/groupware/pkg/groupware/api_emails.go
@@ -42,8 +42,8 @@ func (g *Groupware) GetAllEmailsInMailbox(w http.ResponseWriter, r *http.Request
fetchBodies := false
withThreads := true
query(Email, w, r, g, g.defaults.emailLimit,
- func(req Request, accountId, containerId string, position int, limit *uint, ctx jmap.Context) (*jmap.EmailSearchResults, jmap.SessionState, jmap.State, jmap.Language, *Error) {
- emails, sessionState, state, lang, jerr := g.jmap.GetAllEmailsInMailbox(accountId, containerId, position, limit, collapseThreads, fetchBodies, g.config.maxBodyValueBytes, withThreads, ctx)
+ func(req Request, accountId, containerId string, position int, anchor string, anchorOffset *int, limit *uint, ctx jmap.Context) (*jmap.EmailSearchResults, jmap.SessionState, jmap.State, jmap.Language, *Error) { //NOSONAR
+ emails, sessionState, state, lang, jerr := g.jmap.GetAllEmailsInMailbox(accountId, containerId, position, anchor, anchorOffset, limit, collapseThreads, fetchBodies, g.config.maxBodyValueBytes, withThreads, ctx)
if jerr != nil {
return emails, sessionState, state, lang, req.apiErrorFromJmap(req.observeJmapError(jerr))
}
diff --git a/services/groupware/pkg/groupware/api_events.go b/services/groupware/pkg/groupware/api_events.go
index b179fd189f..d3ac836622 100644
--- a/services/groupware/pkg/groupware/api_events.go
+++ b/services/groupware/pkg/groupware/api_events.go
@@ -72,10 +72,10 @@ func (g *Groupware) GetEventsInCalendar(w http.ResponseWriter, r *http.Request)
}
func curryMapQuery[SRES jmap.SearchResults[T], T jmap.Foo, FILTER any, COMP any](
- f func(accountIds []string, filter FILTER, sortBy []COMP, position int, limit *uint, calculateTotal bool, ctx jmap.Context) (map[string]SRES, jmap.SessionState, jmap.State, jmap.Language, jmap.Error),
-) func(req Request, accountId string, filter FILTER, sortBy []COMP, position int, limit *uint, ctx jmap.Context) (SRES, jmap.SessionState, jmap.State, jmap.Language, jmap.Error) {
- return func(req Request, accountId string, filter FILTER, sortBy []COMP, position int, limit *uint, ctx jmap.Context) (SRES, jmap.SessionState, jmap.State, jmap.Language, jmap.Error) {
- m, sessionState, state, lang, err := f(single(accountId), filter, sortBy, position, limit, true, ctx)
+ f func(accountIds []string, filter FILTER, sortBy []COMP, position int, anchor string, anchorOffset *int, limit *uint, calculateTotal bool, ctx jmap.Context) (map[string]SRES, jmap.SessionState, jmap.State, jmap.Language, jmap.Error),
+) func(req Request, accountId string, filter FILTER, sortBy []COMP, position int, anchor string, anchorOffset *int, limit *uint, ctx jmap.Context) (SRES, jmap.SessionState, jmap.State, jmap.Language, jmap.Error) {
+ return func(req Request, accountId string, filter FILTER, sortBy []COMP, position int, anchor string, anchorOffset *int, limit *uint, ctx jmap.Context) (SRES, jmap.SessionState, jmap.State, jmap.Language, jmap.Error) { //NOSONAR
+ m, sessionState, state, lang, err := f(single(accountId), filter, sortBy, position, anchor, anchorOffset, limit, true, ctx)
return m[accountId], sessionState, state, lang, err
}
}
diff --git a/services/groupware/pkg/groupware/route.go b/services/groupware/pkg/groupware/route.go
index 5ecc18883d..57f5fe9322 100644
--- a/services/groupware/pkg/groupware/route.go
+++ b/services/groupware/pkg/groupware/route.go
@@ -49,6 +49,8 @@ const (
QueryParamSearchKeyword = "keyword"
QueryParamSearchMessageId = "messageId"
QueryParamPosition = "position"
+ QueryParamAnchor = "anchor"
+ QueryParamAnchorOffset = "offset"
QueryParamLimit = "limit"
QueryParamDays = "days"
QueryParamPartId = "partId"
diff --git a/services/groupware/pkg/groupware/templates.go b/services/groupware/pkg/groupware/templates.go
index 751d7a8625..bd3b1905b8 100644
--- a/services/groupware/pkg/groupware/templates.go
+++ b/services/groupware/pkg/groupware/templates.go
@@ -80,7 +80,7 @@ func getall[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.G
})
}
-var paginationQueryParams = toSupportedQueryParams(QueryParamPosition, QueryParamLimit)
+var paginationQueryParams = toSupportedQueryParams(QueryParamPosition, QueryParamAnchor, QueryParamAnchorOffset, QueryParamLimit)
// Retrieve all the {{.Name}} with support for paging using the {{.QueryParam.QueryParamPosition.Name}} and {{.QueryParam.QueryParamLimit.Name}} query parameters.
// @api:response 200:SEARCHRESULTS returns the {{.Names}} within the requested range, as well as the total amount of {{.Names}}
@@ -91,7 +91,7 @@ func getallpaged[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], FILTER
withContainerId bool,
filterFunc func(containerId string) FILTER,
sortBy []COMP,
- queryFunc func(req Request, accountId string, filter FILTER, sortBy []COMP, position int, limit *uint, ctx jmap.Context) (SEARCHRESULTS, jmap.SessionState, jmap.State, jmap.Language, jmap.Error),
+ queryFunc func(req Request, accountId string, filter FILTER, sortBy []COMP, position int, anchor string, anchorOffset *int, limit *uint, ctx jmap.Context) (SEARCHRESULTS, jmap.SessionState, jmap.State, jmap.Language, jmap.Error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -108,6 +108,23 @@ func getallpaged[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], FILTER
l = l.Int(QueryParamPosition, position)
}
+ anchor, ok := req.getStringParam(QueryParamAnchor, "")
+ if ok {
+ l = l.Str(QueryParamAnchor, log.SafeString(anchor))
+ }
+
+ var anchorOffset *int = nil
+ {
+ v, ok, err := req.parseIntParam(QueryParamAnchorOffset, 0)
+ if err != nil {
+ return req.error(accountId, err)
+ }
+ if ok {
+ l = l.Int(QueryParamAnchorOffset, v)
+ anchorOffset = &v
+ }
+ }
+
var limit *uint = nil
{
v, ok, err := req.parseUIntParam(QueryParamLimit, uint(0))
@@ -143,7 +160,7 @@ func getallpaged[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], FILTER
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
- results, sessionState, state, lang, jerr := queryFunc(req, accountId, filter, sortBy, position, jmaplimit, ctx)
+ results, sessionState, state, lang, jerr := queryFunc(req, accountId, filter, sortBy, position, anchor, anchorOffset, jmaplimit, ctx)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
@@ -164,7 +181,7 @@ func query[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], SEARCHRESULT
w http.ResponseWriter, r *http.Request,
g *Groupware,
defaultLimit uint,
- queryFunc func(req Request, accountId string, containerId string, position int, limit *uint, ctx jmap.Context) (SEARCHRESULTS, jmap.SessionState, jmap.State, jmap.Language, *Error),
+ queryFunc func(req Request, accountId string, containerId string, position int, anchor string, anchorOffset *int, limit *uint, ctx jmap.Context) (SEARCHRESULTS, jmap.SessionState, jmap.State, jmap.Language, *Error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -191,6 +208,23 @@ func query[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], SEARCHRESULT
l = l.Int(QueryParamPosition, position)
}
+ anchor, ok := req.getStringParam(QueryParamAnchor, "")
+ if ok {
+ l = l.Str(QueryParamAnchor, log.SafeString(anchor))
+ }
+
+ var anchorOffset *int = nil
+ {
+ v, ok, err := req.parseIntParam(QueryParamAnchorOffset, 0)
+ if err != nil {
+ return req.error(accountId, err)
+ }
+ if ok {
+ l = l.Int(QueryParamAnchorOffset, v)
+ anchorOffset = &v
+ }
+ }
+
var limit *uint = nil
{
v, ok, err := req.parseUIntParam(QueryParamLimit, defaultLimit)
@@ -213,7 +247,7 @@ func query[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], SEARCHRESULT
jmaplimit = UintPtrOne
}
- results, sessionState, state, lang, err := queryFunc(req, accountId, containerId, position, jmaplimit, ctx)
+ results, sessionState, state, lang, err := queryFunc(req, accountId, containerId, position, anchor, anchorOffset, jmaplimit, ctx)
if err != nil {
return req.error(accountId, err)
}