From 15d708fa3748c7aa92c0d91f3e4d94270a225555 Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Wed, 3 Jun 2026 10:42:47 +0200
Subject: [PATCH] groupware: refactoring for pagination and support for
multiple query suppliers
* refactor APIs in JMAP and Groupware in order to implement pagination
across multiple accountIds and multiple suppliers (currently
implemented using a mock supplier for contacts)
* requires go 1.26 due to use of self-reflecting generics type
constraints
* still missing: query criteria and sorting parameters
* still missing: multi-accountId support for emails
* errors are now all just 'error' in the APIs, instead of the
specialized implementations, and are interpreted dynamically where
necessary in order to transform them into HTTP responses
* remove position, anchor, anchorOffset as individual query parameters
as we now only support a 'next=...' token for subsequent pages
(except in emails for now), and use jmap.QueryParams instead; those
tokens have a header character for the format, followed by a JSON
encoded QueryParams map, all wrapped into base62 to make it clearer
that it is meant to be an opaque token, and not a parameter clients
should tinker with or construct themselves
* introduce QueryParamsSupplier as an interface to provide QueryParams
for various scenarios (single supplier, multiple supplier, ...) per
accountId
* implement multi-supplier template methods slist and squery
---
go.mod | 2 +-
pkg/jmap/api.go | 54 +-
pkg/jmap/api_addressbook.go | 10 +-
pkg/jmap/api_blob.go | 8 +-
pkg/jmap/api_bootstrap.go | 2 +-
pkg/jmap/api_calendar.go | 12 +-
pkg/jmap/api_changes.go | 2 +-
pkg/jmap/api_contact.go | 44 +-
pkg/jmap/api_email.go | 86 +--
pkg/jmap/api_event.go | 40 +-
pkg/jmap/api_identity.go | 14 +-
pkg/jmap/api_mailbox.go | 38 +-
pkg/jmap/api_objects.go | 2 +-
pkg/jmap/api_principal.go | 35 +-
pkg/jmap/api_quota.go | 6 +-
pkg/jmap/api_vacation.go | 4 +-
pkg/jmap/client.go | 7 +-
pkg/jmap/error.go | 2 +
pkg/jmap/export_integration_test.go | 6 +-
pkg/jmap/integration_addressbook_test.go | 14 +-
pkg/jmap/integration_calendar_test.go | 14 +-
pkg/jmap/integration_email_test.go | 8 +-
pkg/jmap/model.go | 243 ++++++--
pkg/jmap/model_examples.go | 26 +-
pkg/jmap/templates.go | 102 ++--
pkg/jmap/tools.go | 7 +-
pkg/structs/structs.go | 50 ++
services/groupware/QueryPagination.md | 247 +++++++++
.../pkg/config/defaults/defaultconfig.go | 1 +
services/groupware/pkg/config/http.go | 11 +-
.../groupware/address_book_supplier_jmap.go | 35 ++
.../groupware/address_book_supplier_mock.go | 150 +++++
.../pkg/groupware/api_addressbooks.go | 12 +-
services/groupware/pkg/groupware/api_blob.go | 30 +-
.../groupware/pkg/groupware/api_contacts.go | 82 +--
.../groupware/pkg/groupware/api_emails.go | 144 ++---
.../groupware/pkg/groupware/api_events.go | 92 +---
services/groupware/pkg/groupware/api_quota.go | 2 +-
.../groupware/pkg/groupware/api_vacation.go | 4 +-
services/groupware/pkg/groupware/base62.go | 97 ++++
services/groupware/pkg/groupware/error.go | 40 +-
services/groupware/pkg/groupware/framework.go | 205 ++++++-
services/groupware/pkg/groupware/next.go | 195 +++++++
.../pkg/groupware/pet_export_test.go | 100 ++++
services/groupware/pkg/groupware/request.go | 8 +-
services/groupware/pkg/groupware/response.go | 26 +
services/groupware/pkg/groupware/route.go | 1 +
services/groupware/pkg/groupware/suppliers.go | 425 ++++++++++++++
.../groupware/pkg/groupware/suppliers_test.go | 223 ++++++++
services/groupware/pkg/groupware/templates.go | 517 +++++++++++-------
services/groupware/pkg/groupware/tools.go | 8 +
51 files changed, 2812 insertions(+), 681 deletions(-)
create mode 100644 services/groupware/QueryPagination.md
create mode 100644 services/groupware/pkg/groupware/address_book_supplier_jmap.go
create mode 100644 services/groupware/pkg/groupware/address_book_supplier_mock.go
create mode 100644 services/groupware/pkg/groupware/base62.go
create mode 100644 services/groupware/pkg/groupware/next.go
create mode 100644 services/groupware/pkg/groupware/pet_export_test.go
create mode 100644 services/groupware/pkg/groupware/suppliers.go
create mode 100644 services/groupware/pkg/groupware/suppliers_test.go
diff --git a/go.mod b/go.mod
index b9df7b677b..6f0b70b18e 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/opencloud-eu/opencloud
-go 1.25.8
+go 1.26.0
require (
dario.cat/mergo v1.0.2
diff --git a/pkg/jmap/api.go b/pkg/jmap/api.go
index 1ff108c8fa..b14aa3cac5 100644
--- a/pkg/jmap/api.go
+++ b/pkg/jmap/api.go
@@ -6,6 +6,7 @@ import (
"net/url"
"github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/opencloud-eu/opencloud/pkg/structs"
)
type Context struct {
@@ -76,13 +77,52 @@ type Result[T any] struct {
Language Language
}
-func RefineResult[A, B any](a Result[A], refiner func(A) B) Result[B] {
- return newResult(
- refiner(a.Payload),
- a.SessionState,
- a.State,
- a.Language,
- )
+func RefineResultPayload[A, B any](a Result[A], refiner func(A) (B, bool, error)) (Result[B], error) {
+ if payloads, ok, err := refiner(a.Payload); err != nil {
+ return ZeroResult[B](), err
+ } else if ok {
+ return newResult(payloads, a.SessionState, a.State, a.Language), nil
+ } else {
+ return ZeroResult[B](), nil
+ }
+}
+
+func RefineResult[A, B any](a Result[A], refiner func(A, SessionState, State, Language) (B, SessionState, State, Language)) Result[B] {
+ b, bss, bs, bl := refiner(a.Payload, a.SessionState, a.State, a.Language)
+ return newResult(b, bss, bs, bl)
+}
+
+func RefineResultSlice[A, B any](a []*Result[A], refiner func([]*A, []*SessionState, []*State, []*Language) (B, SessionState, State, Language, error)) (Result[B], error) {
+ payloads := structs.Map(a, func(e *Result[A]) *A {
+ if e != nil {
+ return &e.Payload
+ } else {
+ return nil
+ }
+ })
+ sessionStates := structs.Map(a, func(e *Result[A]) *SessionState {
+ if e != nil {
+ return &e.SessionState
+ } else {
+ return nil
+ }
+ })
+ states := structs.Map(a, func(e *Result[A]) *State {
+ if e != nil {
+ return &e.State
+ } else {
+ return nil
+ }
+ })
+ languages := structs.Map(a, func(e *Result[A]) *Language {
+ if e != nil {
+ return &e.Language
+ } else {
+ return nil
+ }
+ })
+ b, bss, bs, bl, err := refiner(payloads, sessionStates, states, languages)
+ return newResult(b, bss, bs, bl), err
}
func (r Result[T]) GetSessionState() SessionState {
diff --git a/pkg/jmap/api_addressbook.go b/pkg/jmap/api_addressbook.go
index 2c046a546a..4294ebce55 100644
--- a/pkg/jmap/api_addressbook.go
+++ b/pkg/jmap/api_addressbook.go
@@ -2,7 +2,7 @@ package jmap
var NS_ADDRESSBOOKS = ns(JmapContacts)
-func (j *Client) GetAddressbooks(accountId string, ids []string, ctx Context) (Result[AddressBookGetResponse], Error) {
+func (j *Client) GetAddressbooks(accountId string, ids []string, ctx Context) (Result[AddressBookGetResponse], error) {
return get(j, "GetAddressbooks", MailboxType,
func(accountId string, ids []string) AddressBookGetCommand {
return AddressBookGetCommand{AccountId: accountId, Ids: ids}
@@ -27,7 +27,7 @@ func (c AddressBookChanges) GetDestroyed() []string { return c.Destroyed }
// Retrieve Address Book changes since a given state.
// @apidoc addressbook,changes
-func (j *Client) GetAddressbookChanges(accountId string, sinceState State, maxChanges uint, ctx Context) (Result[AddressBookChanges], Error) {
+func (j *Client) GetAddressbookChanges(accountId string, sinceState State, maxChanges uint, ctx Context) (Result[AddressBookChanges], error) {
return changesA(j, "GetAddressbookChanges", MailboxType,
func() AddressBookChangesCommand {
return AddressBookChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
@@ -58,7 +58,7 @@ func (j *Client) GetAddressbookChanges(accountId string, sinceState State, maxCh
)
}
-func (j *Client) CreateAddressBook(accountId string, addressbook AddressBookChange, ctx Context) (Result[*AddressBook], Error) {
+func (j *Client) CreateAddressBook(accountId string, addressbook AddressBookChange, ctx Context) (Result[*AddressBook], error) {
return create(j, "CreateAddressBook", MailboxType,
func(accountId string, create map[string]AddressBookChange) AddressBookSetCommand {
return AddressBookSetCommand{AccountId: accountId, Create: create}
@@ -77,7 +77,7 @@ func (j *Client) CreateAddressBook(accountId string, addressbook AddressBookChan
)
}
-func (j *Client) DeleteAddressBook(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], Error) {
+func (j *Client) DeleteAddressBook(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
return destroy(j, "DeleteAddressBook", MailboxType,
func(accountId string, destroy []string) AddressBookSetCommand {
return AddressBookSetCommand{AccountId: accountId, Destroy: destroy}
@@ -88,7 +88,7 @@ func (j *Client) DeleteAddressBook(accountId string, destroyIds []string, ctx Co
)
}
-func (j *Client) UpdateAddressBook(accountId string, id string, changes AddressBookChange, ctx Context) (Result[AddressBook], Error) {
+func (j *Client) UpdateAddressBook(accountId string, id string, changes AddressBookChange, ctx Context) (Result[AddressBook], error) {
return update(j, "UpdateAddressBook", MailboxType,
func(update map[string]PatchObject) AddressBookSetCommand {
return AddressBookSetCommand{AccountId: accountId, Update: update}
diff --git a/pkg/jmap/api_blob.go b/pkg/jmap/api_blob.go
index e118961f6d..c094e254e2 100644
--- a/pkg/jmap/api_blob.go
+++ b/pkg/jmap/api_blob.go
@@ -10,7 +10,7 @@ import (
var NS_BLOB = ns(JmapBlob)
-func (j *Client) GetBlobMetadata(accountId string, ids []string, ctx Context) (Result[BlobGetResponse], Error) {
+func (j *Client) GetBlobMetadata(accountId string, ids []string, ctx Context) (Result[BlobGetResponse], error) {
get := BlobGetCommand{
AccountId: accountId,
Ids: ids,
@@ -41,7 +41,7 @@ type UploadedBlobWithHash struct {
Sha512 string `json:"sha:512,omitempty"`
}
-func (j *Client) UploadBlobStream(accountId string, contentType string, body io.Reader, ctx Context) (UploadedBlob, Language, Error) {
+func (j *Client) UploadBlobStream(accountId string, contentType string, body io.Reader, ctx Context) (UploadedBlob, Language, error) {
logger := log.From(ctx.Logger.With().Str(logEndpoint, ctx.Session.UploadEndpoint))
ctx = ctx.WithLogger(logger)
// TODO(pbleser-oc) use a library for proper URL template parsing
@@ -49,7 +49,7 @@ func (j *Client) UploadBlobStream(accountId string, contentType string, body io.
return j.blob.UploadBinary(uploadUrl, ctx.Session.UploadEndpoint, contentType, body, ctx)
}
-func (j *Client) DownloadBlobStream(accountId string, blobId string, name string, typ string, ctx Context) (*BlobDownload, Language, Error) { //NOSONAR
+func (j *Client) DownloadBlobStream(accountId string, blobId string, name string, typ string, ctx Context) (*BlobDownload, Language, error) { //NOSONAR
logger := log.From(ctx.Logger.With().Str(logEndpoint, ctx.Session.DownloadEndpoint))
ctx = ctx.WithLogger(logger)
// TODO(pbleser-oc) use a library for proper URL template parsing
@@ -62,7 +62,7 @@ func (j *Client) DownloadBlobStream(accountId string, blobId string, name string
return j.blob.DownloadBinary(downloadUrl, ctx.Session.DownloadEndpoint, ctx)
}
-func (j *Client) UploadBlob(accountId string, data []byte, contentType string, ctx Context) (Result[UploadedBlobWithHash], Error) {
+func (j *Client) UploadBlob(accountId string, data []byte, contentType string, ctx Context) (Result[UploadedBlobWithHash], error) {
encoded := base64.StdEncoding.EncodeToString(data)
upload := BlobUploadCommand{
diff --git a/pkg/jmap/api_bootstrap.go b/pkg/jmap/api_bootstrap.go
index 6074449703..ae373703aa 100644
--- a/pkg/jmap/api_bootstrap.go
+++ b/pkg/jmap/api_bootstrap.go
@@ -11,7 +11,7 @@ type AccountBootstrapResult struct {
var NS_MAIL_QUOTA = ns(JmapMail, JmapQuota)
-func (j *Client) GetBootstrap(accountIds []string, ctx Context) (Result[map[string]AccountBootstrapResult], Error) { //NOSONAR
+func (j *Client) GetBootstrap(accountIds []string, ctx Context) (Result[map[string]AccountBootstrapResult], error) { //NOSONAR
uniqueAccountIds := structs.Uniq(accountIds)
logger := j.logger("GetBootstrap", ctx)
diff --git a/pkg/jmap/api_calendar.go b/pkg/jmap/api_calendar.go
index 843e52e294..1727501ca0 100644
--- a/pkg/jmap/api_calendar.go
+++ b/pkg/jmap/api_calendar.go
@@ -2,7 +2,7 @@ package jmap
var NS_CALENDARS = ns(JmapCalendars)
-func (j *Client) ParseICalendarBlob(accountId string, blobIds []string, ctx Context) (Result[CalendarEventParseResponse], Error) {
+func (j *Client) ParseICalendarBlob(accountId string, blobIds []string, ctx Context) (Result[CalendarEventParseResponse], error) {
logger := j.logger("ParseICalendarBlob", ctx)
parse := CalendarEventParseCommand{AccountId: accountId, BlobIds: blobIds}
@@ -23,7 +23,7 @@ func (j *Client) ParseICalendarBlob(accountId string, blobIds []string, ctx Cont
})
}
-func (j *Client) GetCalendars(accountId string, ids []string, ctx Context) (Result[CalendarGetResponse], Error) {
+func (j *Client) GetCalendars(accountId string, ids []string, ctx Context) (Result[CalendarGetResponse], error) {
return get(j, "GetCalendars", CalendarType,
func(accountId string, ids []string) CalendarGetCommand {
return CalendarGetCommand{AccountId: accountId, Ids: ids}
@@ -48,7 +48,7 @@ func (c CalendarChanges) GetDestroyed() []string { return c.Destroyed }
// Retrieve Calendar changes since a given state.
// @apidoc calendar,changes
-func (j *Client) GetCalendarChanges(accountId string, sinceState State, maxChanges uint, ctx Context) (Result[CalendarChanges], Error) {
+func (j *Client) GetCalendarChanges(accountId string, sinceState State, maxChanges uint, ctx Context) (Result[CalendarChanges], error) {
return changes(j, "GetCalendarChanges", CalendarType,
func() CalendarChangesCommand {
return CalendarChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
@@ -79,7 +79,7 @@ func (j *Client) GetCalendarChanges(accountId string, sinceState State, maxChang
)
}
-func (j *Client) CreateCalendar(accountId string, calendar CalendarChange, ctx Context) (Result[*Calendar], Error) {
+func (j *Client) CreateCalendar(accountId string, calendar CalendarChange, ctx Context) (Result[*Calendar], error) {
return create(j, "CreateCalendar", CalendarEventType,
func(accountId string, create map[string]CalendarChange) CalendarSetCommand {
return CalendarSetCommand{AccountId: accountId, Create: create}
@@ -98,7 +98,7 @@ func (j *Client) CreateCalendar(accountId string, calendar CalendarChange, ctx C
)
}
-func (j *Client) DeleteCalendar(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], Error) {
+func (j *Client) DeleteCalendar(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
return destroy(j, "DeleteCalendar", CalendarEventType,
func(accountId string, destroy []string) CalendarSetCommand {
return CalendarSetCommand{AccountId: accountId, Destroy: destroy}
@@ -109,7 +109,7 @@ func (j *Client) DeleteCalendar(accountId string, destroyIds []string, ctx Conte
)
}
-func (j *Client) UpdateCalendar(accountId string, id string, changes CalendarChange, ctx Context) (Result[Calendar], Error) {
+func (j *Client) UpdateCalendar(accountId string, id string, changes CalendarChange, ctx Context) (Result[Calendar], error) {
return update(j, "UpdateCalendar", CalendarEventType,
func(update map[string]PatchObject) CalendarSetCommand {
return CalendarSetCommand{AccountId: accountId, Update: update}
diff --git a/pkg/jmap/api_changes.go b/pkg/jmap/api_changes.go
index f51acde6e1..7cc455b687 100644
--- a/pkg/jmap/api_changes.go
+++ b/pkg/jmap/api_changes.go
@@ -74,7 +74,7 @@ func (s StateMap) MarshalZerologObject(e *zerolog.Event) {
// Retrieve the changes in any type of objects at once since a given State.
// @api:tags changes
-func (j *Client) GetChanges(accountId string, stateMap StateMap, maxChanges uint, ctx Context) (Result[ObjectChanges], Error) { //NOSONAR
+func (j *Client) GetChanges(accountId string, stateMap StateMap, maxChanges uint, ctx Context) (Result[ObjectChanges], error) { //NOSONAR
logger := log.From(j.logger("GetChanges", ctx).With().Object("state", stateMap).Uint("maxChanges", maxChanges))
ctx = ctx.WithLogger(logger)
diff --git a/pkg/jmap/api_contact.go b/pkg/jmap/api_contact.go
index f9e97fc2a9..d7172eb5c0 100644
--- a/pkg/jmap/api_contact.go
+++ b/pkg/jmap/api_contact.go
@@ -6,7 +6,7 @@ var NS_CONTACTS = ns(JmapContacts)
var DEFAULT_CONTACT_CARD_VERSION = jscontact.JSContactVersion_1_0
-func (j *Client) GetContactCards(accountId string, contactIds []string, ctx Context) (Result[ContactCardGetResponse], Error) {
+func (j *Client) GetContactCards(accountId string, contactIds []string, ctx Context) (Result[ContactCardGetResponse], error) {
return get(j, "GetContactCards", ContactCardType,
func(accountId string, ids []string) ContactCardGetCommand {
return ContactCardGetCommand{AccountId: accountId, Ids: contactIds}
@@ -31,7 +31,7 @@ func (c ContactCardChanges) GetDestroyed() []string { return c.Destroyed }
// Retrieve the changes in Contact Cards since a given State.
// @api:tags contact,changes
-func (j *Client) GetContactCardChanges(accountId string, sinceState State, maxChanges uint, ctx Context) (Result[ContactCardChanges], Error) {
+func (j *Client) GetContactCardChanges(accountId string, sinceState State, maxChanges uint, ctx Context) (Result[ContactCardChanges], error) {
return changes(j, "GetContactCardChanges", ContactCardType,
func() ContactCardChangesCommand {
return ContactCardChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
@@ -77,34 +77,46 @@ func (r *ContactCardSearchResults) RemoveResults() { r.Results = nil
func (r *ContactCardSearchResults) SetLimit(limit *uint) { r.Limit = limit }
func (r *ContactCardSearchResults) SetPosition(position *uint) { r.Position = position }
-func (j *Client) QueryContactCards(accountIds []string, //NOSONAR
- filter ContactCardFilterElement, sortBy []ContactCardComparator,
- position int, anchor string, anchorOffset *int, limit *uint, calculateTotal bool,
- ctx Context) (Result[map[string]*ContactCardSearchResults], Error) {
+func (j *Client) QueryContactCards(accountIds map[string]QueryParams, //NOSONAR
+ limit *uint, filter ContactCardFilterElement, sortBy []ContactCardComparator, calculateTotal bool,
+ ctx Context) (Result[map[string]*ContactCardSearchResults], error) {
return queryN(j, "QueryContactCards", ContactCardType,
[]ContactCardComparator{{Property: ContactCardPropertyUpdated, IsAscending: false}},
- 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, qp QueryParams, limit *uint, filter ContactCardFilterElement, sortBy []ContactCardComparator) ContactCardQueryCommand {
+ if qp.Anchor != "" {
+ return ContactCardQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Anchor: qp.Anchor, AnchorOffset: qp.AnchorOffset, Limit: limit, CalculateTotal: calculateTotal}
+ } else {
+ return ContactCardQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: qp.Position, 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}}
},
- func(query ContactCardQueryResponse, get ContactCardGetResponse) *ContactCardSearchResults {
+ func(query ContactCardQueryResponse, queryParams QueryParams, limit *uint) *ContactCardSearchResults {
+ return &ContactCardSearchResults{
+ Results: []ContactCard{},
+ CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
+ Position: valueIf(query.Position, queryParams.Anchor == ""),
+ Total: valueIf(query.Total, calculateTotal),
+ Limit: valueIf(query.Limit, limit != nil),
+ }
+ },
+ func(query ContactCardQueryResponse, get ContactCardGetResponse, queryParams QueryParams, limit *uint) *ContactCardSearchResults {
return &ContactCardSearchResults{
Results: get.List,
CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
- Position: ptrIf(query.Position, anchor == ""),
+ Position: valueIf(query.Position, queryParams.Anchor == ""),
Total: valueIf(query.Total, calculateTotal),
- Limit: ptrIf(query.Limit, limit != nil),
+ Limit: valueIf(query.Limit, limit != nil),
}
},
- accountIds,
- filter, sortBy, position, anchor, anchorOffset, limit, ctx,
+ accountIds, limit,
+ filter, sortBy, ctx,
)
}
// @api:example create
-func (j *Client) CreateContactCard(accountId string, contact ContactCardChange, ctx Context) (Result[*ContactCard], Error) {
+func (j *Client) CreateContactCard(accountId string, contact ContactCardChange, ctx Context) (Result[*ContactCard], error) {
if contact.Version == nil {
contact.Version = &DEFAULT_CONTACT_CARD_VERSION
}
@@ -126,7 +138,7 @@ func (j *Client) CreateContactCard(accountId string, contact ContactCardChange,
)
}
-func (j *Client) DeleteContactCard(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], Error) {
+func (j *Client) DeleteContactCard(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
return destroy(j, "DeleteContactCard", ContactCardType,
func(accountId string, destroy []string) ContactCardSetCommand {
return ContactCardSetCommand{AccountId: accountId, Destroy: destroy}
@@ -138,7 +150,7 @@ func (j *Client) DeleteContactCard(accountId string, destroyIds []string, ctx Co
}
// @api:example update
-func (j *Client) UpdateContactCard(accountId string, id string, changes ContactCardChange, ctx Context) (Result[ContactCard], Error) {
+func (j *Client) UpdateContactCard(accountId string, id string, changes ContactCardChange, ctx Context) (Result[ContactCard], error) {
return update(j, "UpdateContactCard", ContactCardType,
func(update map[string]PatchObject) ContactCardSetCommand {
return ContactCardSetCommand{AccountId: accountId, Update: update}
diff --git a/pkg/jmap/api_email.go b/pkg/jmap/api_email.go
index fd06a078cf..41ef7c4f24 100644
--- a/pkg/jmap/api_email.go
+++ b/pkg/jmap/api_email.go
@@ -16,7 +16,7 @@ var NS_MAIL_SUBMISSION = ns(JmapMail, JmapSubmission)
// Retrieve specific Emails by their id.
func (j *Client) GetEmails(accountId string, ids []string, //NOSONAR
fetchBodies bool, maxBodyValueBytes uint, markAsSeen bool, withThreads bool,
- ctx Context) (Result[EmailGetResponse], Error) {
+ ctx Context) (Result[EmailGetResponse], error) {
logger := j.logger("GetEmails", ctx)
ctx = ctx.WithLogger(logger)
@@ -82,7 +82,7 @@ func (j *Client) GetEmails(accountId string, ids []string, //NOSONAR
})
}
-func (j *Client) GetEmailBlobId(accountId string, id string, ctx Context) (Result[string], Error) {
+func (j *Client) GetEmailBlobId(accountId string, id string, ctx Context) (Result[string], error) {
logger := j.logger("GetEmailBlobId", ctx)
ctx = ctx.WithLogger(logger)
@@ -120,10 +120,10 @@ func (r *EmailSearchResults) SetPosition(position *uint) { r.Posi
// Retrieve all the Emails in a given Mailbox by its id.
func (j *Client) GetAllEmailsInMailbox(accountId string, mailboxId string, //NOSONAR
- position int, anchor string, anchorOffset *int, limit *uint, collapseThreads bool, fetchBodies bool, maxBodyValueBytes uint, withThreads bool,
- ctx Context) (Result[*EmailSearchResults], Error) {
+ qp QueryParams, limit *uint, collapseThreads bool, fetchBodies bool, maxBodyValueBytes uint, withThreads bool,
+ ctx Context) (Result[*EmailSearchResults], error) {
logger := j.loggerParams("GetAllEmailsInMailbox", ctx, func(z zerolog.Context) zerolog.Context {
- l := z.Bool(logFetchBodies, fetchBodies).Int(logPosition, position)
+ l := z.Bool(logFetchBodies, fetchBodies)
if limit != nil {
l = l.Uint(logLimit, *limit)
}
@@ -137,9 +137,9 @@ func (j *Client) GetAllEmailsInMailbox(accountId string, mailboxId string, //NOS
Sort: []EmailComparator{{Property: EmailPropertyReceivedAt, IsAscending: false}},
CollapseThreads: collapseThreads,
CalculateTotal: true,
- Position: position,
- Anchor: anchor,
- AnchorOffset: anchorOffset,
+ Position: qp.Position,
+ Anchor: qp.Anchor,
+ AnchorOffset: qp.AnchorOffset,
Limit: limit,
}
@@ -176,7 +176,7 @@ func (j *Client) GetAllEmailsInMailbox(accountId string, mailboxId string, //NOS
}
return command(j, ctx, cmd, func(body *Response) (*EmailSearchResults, State, Error) {
- var queryResponse EmailQueryResponse
+ var queryResponse *EmailQueryResponse
err = retrieveQuery(ctx, body, query, "0", &queryResponse)
if err != nil {
return nil, "", err
@@ -200,8 +200,8 @@ func (j *Client) GetAllEmailsInMailbox(accountId string, mailboxId string, //NOS
return &EmailSearchResults{
Results: getResponse.List,
CanCalculateChanges: ChangeCalculation(queryResponse.CanCalculateChanges),
- Position: ptrIf(queryResponse.Position, anchor == ""),
- Limit: ptrIf(queryResponse.Limit, limit != nil),
+ Position: valueIf(queryResponse.Position, qp.Anchor == ""),
+ Limit: valueIf(queryResponse.Limit, limit != nil),
Total: uintPtr(queryResponse.Total),
}, queryResponse.QueryState, nil
})
@@ -222,7 +222,7 @@ func (c EmailChanges) GetDestroyed() []string { return c.Destroyed }
// @api:tags email,changes
func (j *Client) GetEmailChanges(accountId string,
sinceState State, fetchBodies bool, maxBodyValueBytes uint, maxChanges uint,
- ctx Context) (Result[EmailChanges], Error) { //NOSONAR
+ ctx Context) (Result[EmailChanges], error) { //NOSONAR
logger := j.loggerParams("GetEmailChanges", ctx, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies).Str(logSinceState, string(sinceState))
})
@@ -304,7 +304,7 @@ type EmailSnippetSearchResults SearchResultsTemplate[SearchSnippetWithMeta]
func (j *Client) QueryEmailSnippets(accountIds []string, //NOSONAR
filter EmailFilterElement, position int, anchor string, anchorOffset *int, limit *uint,
- ctx Context) (Result[map[string]EmailSnippetSearchResults], Error) {
+ ctx Context) (Result[map[string]EmailSnippetSearchResults], error) {
logger := j.loggerParams("QueryEmailSnippets", ctx, func(z zerolog.Context) zerolog.Context {
l := z.Int(logPosition, position)
if limit != nil {
@@ -410,14 +410,15 @@ func (j *Client) QueryEmailSnippets(accountIds []string, //NOSONAR
Results: snippets,
CanCalculateChanges: ChangeCalculation(queryResponse.CanCalculateChanges),
Total: uintPtr(queryResponse.Total),
- Limit: ptrIf(queryResponse.Limit, limit != nil),
- Position: ptrIf(queryResponse.Position, anchor == ""),
+ Limit: valueIf(queryResponse.Limit, limit != nil),
+ Position: valueIf(queryResponse.Position, anchor == ""),
}
}
return results, squashState(states), nil
})
}
+/*
type EmailQueryResult struct {
Emails []Email `json:"emails"`
Total uint `json:"total"`
@@ -425,10 +426,11 @@ type EmailQueryResult struct {
Position uint `json:"position,omitzero"`
QueryState State `json:"queryState"`
}
+*/
func (j *Client) QueryEmails(accountIds []string,
filter EmailFilterElement, position int, limit uint, fetchBodies bool, maxBodyValueBytes uint,
- ctx Context) (Result[map[string]EmailQueryResult], Error) { //NOSONAR
+ ctx Context) (Result[map[string]EmailSearchResults], error) { //NOSONAR
logger := j.loggerParams("QueryEmails", ctx, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies)
})
@@ -468,11 +470,12 @@ func (j *Client) QueryEmails(accountIds []string,
cmd, err := j.request(ctx, NS_MAIL, invocations...)
if err != nil {
- return ZeroResult[map[string]EmailQueryResult](), err
+ return ZeroResult[map[string]EmailSearchResults](), err
}
- return command(j, ctx, cmd, func(body *Response) (map[string]EmailQueryResult, State, Error) {
- results := make(map[string]EmailQueryResult, len(uniqueAccountIds))
+ return command(j, ctx, cmd, func(body *Response) (map[string]EmailSearchResults, State, Error) {
+ results := make(map[string]EmailSearchResults, len(uniqueAccountIds))
+ queryStates := map[string]State{}
for _, accountId := range uniqueAccountIds {
var queryResponse EmailQueryResponse
err = retrieveResponseMatchParameters(ctx, body, CommandEmailQuery, mcid(accountId, "0"), &queryResponse)
@@ -486,15 +489,16 @@ func (j *Client) QueryEmails(accountIds []string,
return nil, "", err
}
- results[accountId] = EmailQueryResult{
- Emails: emailsResponse.List,
- Total: queryResponse.Total,
- Limit: queryResponse.Limit,
- Position: queryResponse.Position,
- QueryState: queryResponse.QueryState,
+ results[accountId] = EmailSearchResults{
+ Results: emailsResponse.List,
+ Total: &queryResponse.Total, // no need to valufIf() here, we always request with calculateTotals==true
+ Limit: queryResponse.Limit,
+ Position: queryResponse.Position,
+ CanCalculateChanges: queryResponse.CanCalculateChanges,
}
+ queryStates[accountId] = queryResponse.QueryState
}
- return results, squashStateFunc(results, func(r EmailQueryResult) State { return r.QueryState }), nil
+ return results, squashState(queryStates), nil
})
}
@@ -507,13 +511,13 @@ type EmailQueryWithSnippetsResult struct {
Results []EmailWithSnippets `json:"results"`
Total uint `json:"total"`
Position *uint `json:"position,omitempty"`
- Limit uint `json:"limit"`
+ Limit *uint `json:"limit,omitempty"`
QueryState State `json:"queryState"`
}
func (j *Client) QueryEmailsWithSnippets(accountIds []string, //NOSONAR
filter EmailFilterElement, position int, anchor string, anchorOffset *int, limit *uint, collapseThreads bool, calculateTotal bool, fetchBodies bool, maxBodyValueBytes uint,
- ctx Context) (Result[map[string]EmailQueryWithSnippetsResult], Error) {
+ ctx Context) (Result[map[string]EmailQueryWithSnippetsResult], error) {
logger := j.loggerParams("QueryEmailsWithSnippets", ctx, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies)
})
@@ -610,7 +614,7 @@ func (j *Client) QueryEmailsWithSnippets(accountIds []string, //NOSONAR
Results: results,
Total: queryResponse.Total,
Limit: queryResponse.Limit,
- Position: ptrIf(queryResponse.Position, anchor == ""),
+ Position: valueIf(queryResponse.Position, anchor == ""),
QueryState: queryResponse.QueryState,
}
}
@@ -625,7 +629,7 @@ type UploadedEmail struct {
Sha512 string `json:"sha:512"`
}
-func (j *Client) ImportEmail(accountId string, data []byte, ctx Context) (Result[UploadedEmail], Error) {
+func (j *Client) ImportEmail(accountId string, data []byte, ctx Context) (Result[UploadedEmail], error) {
encoded := base64.StdEncoding.EncodeToString(data)
upload := BlobUploadCommand{
@@ -698,7 +702,7 @@ func (j *Client) ImportEmail(accountId string, data []byte, ctx Context) (Result
}
-func (j *Client) CreateEmail(accountId string, email EmailChange, replaceId string, ctx Context) (Result[*Email], Error) {
+func (j *Client) CreateEmail(accountId string, email EmailChange, replaceId string, ctx Context) (Result[*Email], error) {
set := EmailSetCommand{
AccountId: accountId,
Create: map[string]EmailChange{
@@ -753,7 +757,7 @@ func (j *Client) CreateEmail(accountId string, email EmailChange, replaceId stri
// To create drafts, use the CreateEmail function instead.
//
// To delete mails, use the DeleteEmails function instead.
-func (j *Client) UpdateEmails(accountId string, updates map[string]PatchObject, ctx Context) (Result[map[string]*Email], Error) {
+func (j *Client) UpdateEmails(accountId string, updates map[string]PatchObject, ctx Context) (Result[map[string]*Email], error) {
set := EmailSetCommand{
AccountId: accountId,
Update: updates,
@@ -779,7 +783,7 @@ func (j *Client) UpdateEmails(accountId string, updates map[string]PatchObject,
})
}
-func (j *Client) UpdateEmail(accountId string, id string, changes EmailChange, ctx Context) (Result[Email], Error) {
+func (j *Client) UpdateEmail(accountId string, id string, changes EmailChange, ctx Context) (Result[Email], error) {
return update(j, "UpdateEmail", EmailType,
func(update map[string]PatchObject) EmailSetCommand {
return EmailSetCommand{AccountId: accountId, Update: update}
@@ -794,7 +798,7 @@ func (j *Client) UpdateEmail(accountId string, id string, changes EmailChange, c
)
}
-func (j *Client) DeleteEmails(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], Error) {
+func (j *Client) DeleteEmails(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
return destroy(j, "DeleteEmails", EmailType,
func(accountId string, destroy []string) EmailSetCommand {
return EmailSetCommand{AccountId: accountId, Destroy: destroy}
@@ -835,7 +839,7 @@ type MoveMail struct {
}
func (j *Client) SubmitEmail(accountId string, identityId string, emailId string, move *MoveMail, //NOSONAR
- ctx Context) (Result[EmailSubmission], Error) {
+ ctx Context) (Result[EmailSubmission], error) {
logger := j.logger("SubmitEmail", ctx)
ctx = ctx.WithLogger(logger)
@@ -922,7 +926,7 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string
})
}
-func (j *Client) GetEmailSubmissionStatus(accountId string, submissionIds []string, ctx Context) (Result[EmailSubmissionGetResponse], Error) {
+func (j *Client) GetEmailSubmissionStatus(accountId string, submissionIds []string, ctx Context) (Result[EmailSubmissionGetResponse], error) {
logger := j.logger("GetEmailSubmissionStatus", ctx)
ctx = ctx.WithLogger(logger)
@@ -990,7 +994,7 @@ func (j *Client) EmailsInThread(accountId string, threadId string,
type EmailsSummary struct {
Emails []Email `json:"emails"`
Total uint `json:"total"`
- Limit uint `json:"limit"`
+ Limit *uint `json:"limit,omitempty"`
Position *uint `json:"position,omitempty"`
State State `json:"state"`
}
@@ -1016,7 +1020,7 @@ var EmailSummaryProperties = []string{
func (j *Client) QueryEmailSummaries(accountIds []string, //NOSONAR
filter EmailFilterElement, position int, anchor string, anchorOffset *int, limit *uint, withThreads bool, calculateTotal bool,
- ctx Context) (Result[map[string]EmailsSummary], Error) {
+ ctx Context) (Result[map[string]EmailsSummary], error) {
logger := j.logger("QueryEmailSummaries", ctx)
ctx = ctx.WithLogger(logger)
@@ -1039,7 +1043,7 @@ func (j *Client) QueryEmailSummaries(accountIds []string, //NOSONAR
AnchorOffset: anchorOffset,
Limit: limit,
}
- invocations[i*factor+0] = invocation(get, mcid(accountId, "0"))
+ invocations[i*factor+0] = invocation(&get, mcid(accountId, "0"))
invocations[i*factor+1] = invocation(EmailGetRefCommand{
AccountId: accountId,
@@ -1096,7 +1100,7 @@ func (j *Client) QueryEmailSummaries(accountIds []string, //NOSONAR
Emails: response.List,
Total: queryResponse.Total,
Limit: queryResponse.Limit,
- Position: ptrIf(queryResponse.Position, anchor == ""),
+ Position: valueIf(queryResponse.Position, anchor == ""),
State: response.State,
}
}
@@ -1109,7 +1113,7 @@ type EmailSubmissionChanges = ChangesTemplate[EmailSubmission]
// Retrieve the changes in Email Submissions since a given State.
// @api:tags email,changes
func (j *Client) GetEmailSubmissionChanges(accountId string, sinceState State, maxChanges uint,
- ctx Context) (Result[EmailSubmissionChanges], Error) {
+ ctx Context) (Result[EmailSubmissionChanges], error) {
return changes(j, "GetEmailSubmissionChanges", EmailSubmissionType,
func() EmailSubmissionChangesCommand {
return EmailSubmissionChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
diff --git a/pkg/jmap/api_event.go b/pkg/jmap/api_event.go
index 7127d3281d..8e7d8469d1 100644
--- a/pkg/jmap/api_event.go
+++ b/pkg/jmap/api_event.go
@@ -17,7 +17,7 @@ func (r *CalendarEventSearchResults) RemoveResults() { r.Results = n
func (r *CalendarEventSearchResults) SetLimit(limit *uint) { r.Limit = limit }
func (r *CalendarEventSearchResults) SetPosition(position *uint) { r.Position = position }
-func (j *Client) GetCalendarEvents(accountId string, eventIds []string, ctx Context) (Result[CalendarEventGetResponse], Error) {
+func (j *Client) GetCalendarEvents(accountId string, eventIds []string, ctx Context) (Result[CalendarEventGetResponse], error) {
return get(j, "GetCalendarEvents", CalendarEventType,
func(accountId string, ids []string) CalendarEventGetCommand {
return CalendarEventGetCommand{AccountId: accountId, Ids: eventIds}
@@ -29,29 +29,37 @@ func (j *Client) GetCalendarEvents(accountId string, eventIds []string, ctx Cont
)
}
-func (j *Client) QueryCalendarEvents(accountIds []string, //NOSONAR
- filter CalendarEventFilterElement, sortBy []CalendarEventComparator,
- position int, anchor string, anchorOffset *int, limit *uint, calculateTotal bool,
- ctx Context) (Result[map[string]*CalendarEventSearchResults], Error) {
+func (j *Client) QueryCalendarEvents(accountIds map[string]QueryParams, limit *uint, //NOSONAR
+ filter CalendarEventFilterElement, sortBy []CalendarEventComparator, calculateTotal bool,
+ ctx Context) (Result[map[string]*CalendarEventSearchResults], error) {
return queryN(j, "QueryCalendarEvents", CalendarEventType,
[]CalendarEventComparator{{Property: CalendarEventPropertyStart, IsAscending: false}},
- 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, queryParams QueryParams, limit *uint, filter CalendarEventFilterElement, sortBy []CalendarEventComparator) CalendarEventQueryCommand {
+ return CalendarEventQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: queryParams.Position, Anchor: queryParams.Anchor, AnchorOffset: queryParams.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}}
},
- func(query CalendarEventQueryResponse, get CalendarEventGetResponse) *CalendarEventSearchResults {
+ func(query CalendarEventQueryResponse, queryParams QueryParams, limit *uint) *CalendarEventSearchResults {
+ return &CalendarEventSearchResults{
+ Results: []CalendarEvent{},
+ CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
+ Position: valueIf(query.Position, queryParams.Anchor == ""),
+ Total: valueIf(query.Total, calculateTotal),
+ Limit: valueIf(query.Limit, limit != nil),
+ }
+ },
+ func(query CalendarEventQueryResponse, get CalendarEventGetResponse, queryParams QueryParams, limit *uint) *CalendarEventSearchResults {
return &CalendarEventSearchResults{
Results: get.List,
CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
- Position: ptrIf(query.Position, anchor == ""),
+ Position: valueIf(query.Position, queryParams.Anchor == ""),
Total: valueIf(query.Total, calculateTotal),
- Limit: ptrIf(query.Limit, limit != nil),
+ Limit: valueIf(query.Limit, limit != nil),
}
},
- accountIds,
- filter, sortBy, position, anchor, anchorOffset, limit, ctx,
+ accountIds, limit,
+ filter, sortBy, ctx,
)
}
@@ -69,7 +77,7 @@ func (c CalendarEventChanges) GetDestroyed() []string { return c.Destroyed
// Retrieve the changes in Calendar Events since a given State.
// @api:tags event,changes
func (j *Client) GetCalendarEventChanges(accountId string, sinceState State, maxChanges uint,
- ctx Context) (Result[CalendarEventChanges], Error) {
+ ctx Context) (Result[CalendarEventChanges], error) {
return changes(j, "GetCalendarEventChanges", CalendarEventType,
func() CalendarEventChangesCommand {
return CalendarEventChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
@@ -100,7 +108,7 @@ func (j *Client) GetCalendarEventChanges(accountId string, sinceState State, max
)
}
-func (j *Client) CreateCalendarEvent(accountId string, event CalendarEventChange, ctx Context) (Result[*CalendarEvent], Error) {
+func (j *Client) CreateCalendarEvent(accountId string, event CalendarEventChange, ctx Context) (Result[*CalendarEvent], error) {
return create(j, "CreateCalendarEvent", CalendarEventType,
func(accountId string, create map[string]CalendarEventChange) CalendarEventSetCommand {
return CalendarEventSetCommand{AccountId: accountId, Create: create}
@@ -119,7 +127,7 @@ func (j *Client) CreateCalendarEvent(accountId string, event CalendarEventChange
)
}
-func (j *Client) DeleteCalendarEvent(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], Error) {
+func (j *Client) DeleteCalendarEvent(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
return destroy(j, "DeleteCalendarEvent", CalendarEventType,
func(accountId string, destroy []string) CalendarEventSetCommand {
return CalendarEventSetCommand{AccountId: accountId, Destroy: destroy}
@@ -130,7 +138,7 @@ func (j *Client) DeleteCalendarEvent(accountId string, destroyIds []string, ctx
)
}
-func (j *Client) UpdateCalendarEvent(accountId string, id string, changes CalendarEventChange, ctx Context) (Result[CalendarEvent], Error) {
+func (j *Client) UpdateCalendarEvent(accountId string, id string, changes CalendarEventChange, ctx Context) (Result[CalendarEvent], error) {
return update(j, "UpdateCalendarEvent", CalendarEventType,
func(update map[string]PatchObject) CalendarEventSetCommand {
return CalendarEventSetCommand{AccountId: accountId, Update: update}
diff --git a/pkg/jmap/api_identity.go b/pkg/jmap/api_identity.go
index f0e7e6c13e..da13c549a3 100644
--- a/pkg/jmap/api_identity.go
+++ b/pkg/jmap/api_identity.go
@@ -8,7 +8,7 @@ import (
var NS_IDENTITY = ns(JmapMail)
-func (j *Client) GetIdentities(accountId string, identityIds []string, ctx Context) (Result[IdentityGetResponse], Error) {
+func (j *Client) GetIdentities(accountId string, identityIds []string, ctx Context) (Result[IdentityGetResponse], error) {
return get(j, "GetIdentities", IdentityType,
func(accountId string, ids []string) IdentityGetCommand {
return IdentityGetCommand{AccountId: accountId, Ids: ids}
@@ -20,7 +20,7 @@ func (j *Client) GetIdentities(accountId string, identityIds []string, ctx Conte
)
}
-func (j *Client) GetIdentitiesForAllAccounts(accountIds []string, ctx Context) (Result[map[string][]Identity], Error) {
+func (j *Client) GetIdentitiesForAllAccounts(accountIds []string, ctx Context) (Result[map[string][]Identity], error) {
return getN(j, "GetIdentitiesForAllAccounts", IdentityType,
func(accountId string, ids []string) IdentityGetCommand {
return IdentityGetCommand{AccountId: accountId}
@@ -39,7 +39,7 @@ type IdentitiesAndMailboxesGetResponse struct {
Mailboxes []Mailbox `json:"mailboxes"`
}
-func (j *Client) GetIdentitiesAndMailboxes(mailboxAccountId string, accountIds []string, ctx Context) (Result[IdentitiesAndMailboxesGetResponse], Error) {
+func (j *Client) GetIdentitiesAndMailboxes(mailboxAccountId string, accountIds []string, ctx Context) (Result[IdentitiesAndMailboxesGetResponse], error) {
uniqueAccountIds := structs.Uniq(accountIds)
logger := j.logger("GetIdentitiesAndMailboxes", ctx)
@@ -85,7 +85,7 @@ func (j *Client) GetIdentitiesAndMailboxes(mailboxAccountId string, accountIds [
})
}
-func (j *Client) CreateIdentity(accountId string, identity IdentityChange, ctx Context) (Result[*Identity], Error) {
+func (j *Client) CreateIdentity(accountId string, identity IdentityChange, ctx Context) (Result[*Identity], error) {
return create(j, "CreateIdentity", IdentityType,
func(accountId string, create map[string]IdentityChange) IdentitySetCommand {
return IdentitySetCommand{AccountId: accountId, Create: create}
@@ -104,7 +104,7 @@ func (j *Client) CreateIdentity(accountId string, identity IdentityChange, ctx C
)
}
-func (j *Client) UpdateIdentity(accountId string, id string, changes IdentityChange, ctx Context) (Result[Identity], Error) {
+func (j *Client) UpdateIdentity(accountId string, id string, changes IdentityChange, ctx Context) (Result[Identity], error) {
return update(j, "UpdateIdentity", IdentityType,
func(update map[string]PatchObject) IdentitySetCommand {
return IdentitySetCommand{AccountId: accountId, Update: update}
@@ -119,7 +119,7 @@ func (j *Client) UpdateIdentity(accountId string, id string, changes IdentityCha
)
}
-func (j *Client) DeleteIdentity(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], Error) {
+func (j *Client) DeleteIdentity(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
return destroy(j, "DeleteIdentity", IdentityType,
func(accountId string, destroy []string) IdentitySetCommand {
return IdentitySetCommand{AccountId: accountId, Destroy: destroyIds}
@@ -144,7 +144,7 @@ func (c IdentityChanges) GetDestroyed() []string { return c.Destroyed }
// Retrieve the changes in Email Identities since a given State.
// @api:tags email,changes
func (j *Client) GetIdentityChanges(accountId string, sinceState State, maxChanges uint,
- ctx Context) (Result[IdentityChanges], Error) {
+ ctx Context) (Result[IdentityChanges], error) {
return changes(j, "GetIdentityChanges", IdentityType,
func() IdentityChangesCommand {
return IdentityChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
diff --git a/pkg/jmap/api_mailbox.go b/pkg/jmap/api_mailbox.go
index 6c214d9a51..7e5fb72393 100644
--- a/pkg/jmap/api_mailbox.go
+++ b/pkg/jmap/api_mailbox.go
@@ -8,7 +8,7 @@ import (
var NS_MAILBOX = ns(JmapMail)
-func (j *Client) GetMailbox(accountId string, ids []string, ctx Context) (Result[MailboxGetResponse], Error) {
+func (j *Client) GetMailbox(accountId string, ids []string, ctx Context) (Result[MailboxGetResponse], error) {
return get(j, "GetMailbox", MailboxType,
func(accountId string, ids []string) MailboxGetCommand {
return MailboxGetCommand{AccountId: accountId, Ids: ids}
@@ -20,7 +20,7 @@ func (j *Client) GetMailbox(accountId string, ids []string, ctx Context) (Result
)
}
-func (j *Client) GetAllMailboxes(accountIds []string, ctx Context) (Result[map[string][]Mailbox], Error) {
+func (j *Client) GetAllMailboxes(accountIds []string, ctx Context) (Result[map[string][]Mailbox], error) {
return getAN(j, "GetAllMailboxes", MailboxType,
func(accountId string, ids []string) MailboxGetCommand {
return MailboxGetCommand{AccountId: accountId}
@@ -32,7 +32,7 @@ func (j *Client) GetAllMailboxes(accountIds []string, ctx Context) (Result[map[s
)
}
-func (j *Client) SearchMailboxes(accountIds []string, filter MailboxFilterElement, ctx Context) (Result[map[string][]Mailbox], Error) {
+func (j *Client) SearchMailboxes(accountIds []string, filter MailboxFilterElement, ctx Context) (Result[map[string][]Mailbox], error) {
logger := j.logger("SearchMailboxes", ctx)
ctx = ctx.WithLogger(logger)
@@ -40,7 +40,7 @@ func (j *Client) SearchMailboxes(accountIds []string, filter MailboxFilterElemen
invocations := make([]Invocation, len(uniqueAccountIds)*2)
for i, accountId := range uniqueAccountIds {
- invocations[i*2+0] = invocation(MailboxQueryCommand{AccountId: accountId, Filter: filter}, mcid(accountId, "0"))
+ invocations[i*2+0] = invocation(&MailboxQueryCommand{AccountId: accountId, Filter: filter}, mcid(accountId, "0"))
invocations[i*2+1] = invocation(MailboxGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
@@ -72,7 +72,7 @@ func (j *Client) SearchMailboxes(accountIds []string, filter MailboxFilterElemen
})
}
-func (j *Client) SearchMailboxIdsPerRole(accountIds []string, roles []string, ctx Context) (Result[map[string]map[string]string], Error) { //NOSONAR
+func (j *Client) SearchMailboxIdsPerRole(accountIds []string, roles []string, ctx Context) (Result[map[string]map[string]string], error) { //NOSONAR
logger := j.logger("SearchMailboxIdsPerRole", ctx)
ctx = ctx.WithLogger(logger)
@@ -81,7 +81,7 @@ func (j *Client) SearchMailboxIdsPerRole(accountIds []string, roles []string, ct
invocations := make([]Invocation, len(uniqueAccountIds)*len(roles))
for i, accountId := range uniqueAccountIds {
for j, role := range roles {
- invocations[i*len(roles)+j] = invocation(MailboxQueryCommand{AccountId: accountId, Filter: MailboxFilterCondition{Role: role}}, mcid(accountId, role))
+ invocations[i*len(roles)+j] = invocation(&MailboxQueryCommand{AccountId: accountId, Filter: MailboxFilterCondition{Role: role}}, mcid(accountId, role))
}
}
cmd, err := j.request(ctx, NS_MAILBOX, invocations...)
@@ -137,8 +137,7 @@ func newMailboxChanges(oldState, newState State, hasMoreChanges bool, created, u
// Retrieve Mailbox changes since a given state.
// @apidoc mailboxes,changes
-func (j *Client) GetMailboxChanges(accountId string, sinceState State, maxChanges uint,
- ctx Context) (Result[MailboxChanges], Error) {
+func (j *Client) GetMailboxChanges(accountId string, sinceState State, maxChanges uint, ctx Context) (Result[MailboxChanges], error) {
return changesA(j, "GetMailboxChanges", MailboxType,
func() MailboxChangesCommand {
return MailboxChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
@@ -164,7 +163,7 @@ func (j *Client) GetMailboxChanges(accountId string, sinceState State, maxChange
// @api:tags email,changes
func (j *Client) GetMailboxChangesForMultipleAccounts(accountIds []string, //NOSONAR
sinceStateMap map[string]State, maxChanges uint,
- ctx Context) (Result[map[string]MailboxChanges], Error) {
+ ctx Context) (Result[map[string]MailboxChanges], error) {
return changesN(j, "GetMailboxChangesForMultipleAccounts", MailboxType,
accountIds, sinceStateMap,
func(accountId string, state State) MailboxChangesCommand {
@@ -182,26 +181,29 @@ func (j *Client) GetMailboxChangesForMultipleAccounts(accountIds []string, //NOS
)
}
-func (j *Client) GetMailboxRolesForMultipleAccounts(accountIds []string, ctx Context) (Result[map[string]*[]string], Error) {
+func (j *Client) GetMailboxRolesForMultipleAccounts(accountIds []string, ctx Context) (Result[map[string]*[]string], error) {
return queryN(j, "GetMailboxRolesForMultipleAccounts", MailboxType,
[]MailboxComparator{{Property: MailboxPropertySortOrder, IsAscending: true}},
- func(accountId string, filter MailboxFilterCondition, sortBy []MailboxComparator, _ int, _ string, _ *int, _ *uint) MailboxQueryCommand {
+ func(accountId string, _ QueryParams, _ *uint, filter MailboxFilterCondition, sortBy []MailboxComparator) 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}}
},
- func(_ MailboxQueryResponse, get MailboxGetResponse) *[]string {
+ func(_ MailboxQueryResponse, _ QueryParams, _ *uint) *[]string {
+ return nil // TODO this should never be called
+ },
+ func(_ MailboxQueryResponse, get MailboxGetResponse, _ QueryParams, _ *uint) *[]string {
roles := structs.Map(get.List, func(m Mailbox) string { return m.Role })
slices.Sort(roles)
return &roles
},
- accountIds, MailboxFilterCondition{HasAnyRole: truep}, nil, 0, "", nil, nil,
+ toNullQueryParams(accountIds), nil, MailboxFilterCondition{HasAnyRole: truep}, nil,
ctx,
)
}
-func (j *Client) GetInboxNameForMultipleAccounts(accountIds []string, ctx Context) (Result[map[string]string], Error) {
+func (j *Client) GetInboxNameForMultipleAccounts(accountIds []string, ctx Context) (Result[map[string]string], error) {
logger := j.logger("GetInboxNameForMultipleAccounts", ctx)
ctx = ctx.WithLogger(logger)
@@ -213,7 +215,7 @@ func (j *Client) GetInboxNameForMultipleAccounts(accountIds []string, ctx Contex
invocations := make([]Invocation, n*2)
for i, accountId := range uniqueAccountIds {
- invocations[i*2+0] = invocation(MailboxQueryCommand{
+ invocations[i*2+0] = invocation(&MailboxQueryCommand{
AccountId: accountId,
Filter: MailboxFilterCondition{
Role: JmapMailboxRoleInbox,
@@ -252,7 +254,7 @@ func (j *Client) GetInboxNameForMultipleAccounts(accountIds []string, ctx Contex
}
func (j *Client) UpdateMailbox(accountId string, mailboxId string, change MailboxChange, //NOSONAR
- ctx Context) (Result[Mailbox], Error) {
+ ctx Context) (Result[Mailbox], error) {
return update(j, "UpdateMailbox", MailboxType,
func(update map[string]PatchObject) MailboxSetCommand {
return MailboxSetCommand{AccountId: accountId, Update: update}
@@ -267,7 +269,7 @@ func (j *Client) UpdateMailbox(accountId string, mailboxId string, change Mailbo
)
}
-func (j *Client) CreateMailbox(accountId string, mailbox MailboxChange, ctx Context) (Result[*Mailbox], Error) {
+func (j *Client) CreateMailbox(accountId string, mailbox MailboxChange, ctx Context) (Result[*Mailbox], error) {
return create(j, "CreateMailbox", MailboxType,
func(accountId string, create map[string]MailboxChange) MailboxSetCommand {
return MailboxSetCommand{AccountId: accountId, Create: create}
@@ -286,7 +288,7 @@ func (j *Client) CreateMailbox(accountId string, mailbox MailboxChange, ctx Cont
)
}
-func (j *Client) DeleteMailboxes(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], Error) {
+func (j *Client) DeleteMailboxes(accountId string, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
return destroy(j, "DeleteMailboxes", MailboxType,
func(accountId string, destroy []string) MailboxSetCommand {
return MailboxSetCommand{AccountId: accountId, Destroy: destroyIds}
diff --git a/pkg/jmap/api_objects.go b/pkg/jmap/api_objects.go
index bb8dde7c7a..401523e110 100644
--- a/pkg/jmap/api_objects.go
+++ b/pkg/jmap/api_objects.go
@@ -27,7 +27,7 @@ func (j *Client) GetObjects(accountId string, //NOSONAR
quotaIds []string, identityIds []string,
emailSubmissionIds []string,
ctx Context,
-) (Result[Objects], Error) {
+) (Result[Objects], error) {
l := j.logger("GetObjects", ctx).With()
if len(mailboxIds) > 0 {
l = l.Array("mailboxIds", log.SafeStringArray(mailboxIds))
diff --git a/pkg/jmap/api_principal.go b/pkg/jmap/api_principal.go
index 9010c1a707..9f3470bcdb 100644
--- a/pkg/jmap/api_principal.go
+++ b/pkg/jmap/api_principal.go
@@ -2,7 +2,7 @@ package jmap
var NS_PRINCIPALS = ns(JmapPrincipals)
-func (j *Client) GetPrincipals(accountId string, ids []string, ctx Context) (Result[PrincipalGetResponse], Error) {
+func (j *Client) GetPrincipals(accountId string, ids []string, ctx Context) (Result[PrincipalGetResponse], error) {
return get(j, "GetPrincipals", PrincipalType,
func(accountId string, ids []string) PrincipalGetCommand {
return PrincipalGetCommand{AccountId: accountId, Ids: ids}
@@ -29,27 +29,36 @@ func (r *PrincipalSearchResults) RemoveResults() { r.Results = nil }
func (r *PrincipalSearchResults) SetLimit(limit *uint) { r.Limit = limit }
func (r *PrincipalSearchResults) SetPosition(position *uint) { r.Position = position }
-func (j *Client) QueryPrincipals(accountId string, //NOSONAR
- filter PrincipalFilterElement, sortBy []PrincipalComparator,
- position int, anchor string, anchorOffset *int, limit *uint, calculateTotal bool,
- ctx Context) (Result[*PrincipalSearchResults], Error) {
- return query(j, "QueryPrincipals", PrincipalType,
+func (j *Client) QueryPrincipals(accountIds map[string]QueryParams, limit *uint, //NOSONAR
+ filter PrincipalFilterElement, sortBy []PrincipalComparator, calculateTotal bool,
+ ctx Context) (Result[map[string]*PrincipalSearchResults], error) {
+ return queryN(j, "QueryPrincipals", PrincipalType,
[]PrincipalComparator{{Property: PrincipalPropertyName, IsAscending: true}},
- 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(accountId string, p QueryParams, limit *uint, filter PrincipalFilterElement, sortBy []PrincipalComparator) PrincipalQueryCommand {
+ return PrincipalQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: p.Position, Anchor: p.Anchor, AnchorOffset: p.AnchorOffset, Limit: limit, CalculateTotal: calculateTotal}
},
- func(cmd Command, path string, rof string) PrincipalGetRefCommand {
+ func(accountId string, cmd Command, path, rof string) PrincipalGetRefCommand {
return PrincipalGetRefCommand{AccountId: accountId, IdsRef: &ResultReference{Name: cmd, Path: path, ResultOf: rof}}
},
- func(query PrincipalQueryResponse, get PrincipalGetResponse) *PrincipalSearchResults {
+ func(query PrincipalQueryResponse, queryParams QueryParams, limit *uint) *PrincipalSearchResults {
+ return &PrincipalSearchResults{
+ Results: []Principal{},
+ CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
+ Position: valueIf(query.Position, queryParams.Anchor == ""),
+ Total: ptrIf(query.Total, calculateTotal),
+ Limit: valueIf(query.Limit, limit != nil),
+ }
+ },
+ func(query PrincipalQueryResponse, get PrincipalGetResponse, queryParams QueryParams, limit *uint) *PrincipalSearchResults {
return &PrincipalSearchResults{
Results: get.List,
CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
- Position: ptrIf(query.Position, anchor == ""),
+ Position: valueIf(query.Position, queryParams.Anchor == ""),
Total: ptrIf(query.Total, calculateTotal),
- Limit: ptrIf(query.Limit, limit != nil),
+ Limit: valueIf(query.Limit, limit != nil),
}
},
- filter, sortBy, position, anchor, anchorOffset, limit, ctx,
+ accountIds, limit, filter, sortBy,
+ ctx,
)
}
diff --git a/pkg/jmap/api_quota.go b/pkg/jmap/api_quota.go
index 7a925f1926..3950402c7d 100644
--- a/pkg/jmap/api_quota.go
+++ b/pkg/jmap/api_quota.go
@@ -2,7 +2,7 @@ package jmap
var NS_QUOTA = ns(JmapQuota)
-func (j *Client) GetQuotas(accountIds []string, ctx Context) (Result[map[string]QuotaGetResponse], Error) {
+func (j *Client) GetQuotas(accountIds []string, ctx Context) (Result[map[string]QuotaGetResponse], error) {
return getN(j, "GetQuotas", QuotaType,
func(accountId string, ids []string) QuotaGetCommand {
return QuotaGetCommand{AccountId: accountId}
@@ -29,7 +29,7 @@ func (c QuotaChanges) GetDestroyed() []string { return c.Destroyed }
// Retrieve the changes in Quotas since a given State.
// @api:tags quota,changes
func (j *Client) GetQuotaChanges(accountId string, sinceState State, maxChanges uint,
- ctx Context) (Result[QuotaChanges], Error) {
+ ctx Context) (Result[QuotaChanges], error) {
return changesA(j, "GetQuotaChanges", QuotaType,
func() QuotaChangesCommand {
return QuotaChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
@@ -61,7 +61,7 @@ func (j *Client) GetQuotaChanges(accountId string, sinceState State, maxChanges
}
func (j *Client) GetQuotaUsageChanges(accountId string, sinceState State, maxChanges uint,
- ctx Context) (Result[QuotaChanges], Error) {
+ ctx Context) (Result[QuotaChanges], error) {
return updates(j, "GetQuotaUsageChanges", QuotaType,
func() QuotaChangesCommand {
return QuotaChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
diff --git a/pkg/jmap/api_vacation.go b/pkg/jmap/api_vacation.go
index e3113e07c9..21eb6ad1ce 100644
--- a/pkg/jmap/api_vacation.go
+++ b/pkg/jmap/api_vacation.go
@@ -11,7 +11,7 @@ const (
vacationResponseId = "singleton"
)
-func (j *Client) GetVacationResponse(accountId string, ctx Context) (Result[VacationResponseGetResponse], Error) {
+func (j *Client) GetVacationResponse(accountId string, ctx Context) (Result[VacationResponseGetResponse], error) {
return get(j, "GetVacationResponse", VacationResponseType,
func(accountId string, ids []string) VacationResponseGetCommand {
return VacationResponseGetCommand{AccountId: accountId}
@@ -65,7 +65,7 @@ func (c VacationResponseChanges) GetUpdated() []VacationResponse { return c.Upda
func (c VacationResponseChanges) GetDestroyed() []string { return c.Destroyed }
func (j *Client) SetVacationResponse(accountId string, vacation VacationResponseChange,
- ctx Context) (Result[VacationResponse], Error) {
+ ctx Context) (Result[VacationResponse], error) {
logger := j.logger("SetVacationResponse", ctx)
ctx = ctx.WithLogger(logger)
diff --git a/pkg/jmap/client.go b/pkg/jmap/client.go
index e7380b8139..5a28075b10 100644
--- a/pkg/jmap/client.go
+++ b/pkg/jmap/client.go
@@ -8,6 +8,7 @@ import (
"slices"
"github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/opencloud-eu/opencloud/pkg/structs"
"github.com/rs/zerolog"
)
@@ -107,7 +108,9 @@ func (j *Client) maxCallsCheck(calls int, ctx Context) Error {
//
// If an issue occurs, then it is logged prior to returning it.
func (j *Client) request(ctx Context, using []JmapNamespace, methodCalls ...Invocation) (Request, Error) {
- err := j.maxCallsCheck(len(methodCalls), ctx)
+ sanitized := structs.Filter(methodCalls, func(inv Invocation) bool { return inv.Command != "" })
+
+ err := j.maxCallsCheck(len(sanitized), ctx)
if err != nil {
return Request{}, err
}
@@ -120,7 +123,7 @@ func (j *Client) request(ctx Context, using []JmapNamespace, methodCalls ...Invo
}
return Request{
Using: using,
- MethodCalls: methodCalls,
+ MethodCalls: sanitized,
CreatedIds: nil,
}, nil
}
diff --git a/pkg/jmap/error.go b/pkg/jmap/error.go
index 82d55c6d01..d632b7bbfd 100644
--- a/pkg/jmap/error.go
+++ b/pkg/jmap/error.go
@@ -60,6 +60,8 @@ type JmapError struct {
}
var _ Error = &JmapError{}
+var _ error = &JmapError{}
+var _ error = JmapError{}
func (e JmapError) Code() int {
return e.code
diff --git a/pkg/jmap/export_integration_test.go b/pkg/jmap/export_integration_test.go
index 40fbcd7b66..3d8dc02721 100644
--- a/pkg/jmap/export_integration_test.go
+++ b/pkg/jmap/export_integration_test.go
@@ -1157,9 +1157,9 @@ func containerTest[OBJ Idable, RESP GetResponse[OBJ], BOXES any, CHANGE Change](
acc func(session *Session) string,
obj func(RESP) []OBJ,
id func(OBJ) string,
- get func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[RESP], Error),
- update func(s *StalwartTest, accountId string, id string, change CHANGE, ctx Context) (Result[OBJ], Error),
- destroy func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[map[string]SetError], Error),
+ get func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[RESP], error),
+ update func(s *StalwartTest, accountId string, id string, change CHANGE, ctx Context) (Result[OBJ], error),
+ destroy func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[map[string]SetError], error),
fill func(s *StalwartTest, t *testing.T, accountId string, count uint, ctx Context, _ User, principalIds []string) (BOXES, []OBJ, SessionState, State, error),
change func(OBJ) CHANGE,
checkChanged func(t *testing.T, orig OBJ, change CHANGE, changed OBJ),
diff --git a/pkg/jmap/integration_addressbook_test.go b/pkg/jmap/integration_addressbook_test.go
index 7de4bb0dc3..50261d59cd 100644
--- a/pkg/jmap/integration_addressbook_test.go
+++ b/pkg/jmap/integration_addressbook_test.go
@@ -45,13 +45,13 @@ func TestAddressBooks(t *testing.T) {
func(session *Session) string { return session.PrimaryAccounts.Contacts },
list,
getid,
- func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[AddressBookGetResponse], Error) {
+ func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[AddressBookGetResponse], error) {
return s.client.GetAddressbooks(accountId, ids, ctx)
},
- func(s *StalwartTest, accountId string, id string, change AddressBookChange, ctx Context) (Result[AddressBook], Error) { //NOSONAR
+ func(s *StalwartTest, accountId string, id string, change AddressBookChange, ctx Context) (Result[AddressBook], error) { //NOSONAR
return s.client.UpdateAddressBook(accountId, id, change, ctx)
},
- func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[map[string]SetError], Error) { //NOSONAR
+ func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[map[string]SetError], error) { //NOSONAR
return s.client.DeleteAddressBook(accountId, ids, ctx)
},
func(s *StalwartTest, t *testing.T, accountId string, count uint, ctx Context, user User, principalIds []string) (AddressBookBoxes, []AddressBook, SessionState, State, error) {
@@ -104,7 +104,7 @@ func TestContacts(t *testing.T) {
ss := EmptySessionState
os := EmptyState
{
- result, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, "", nil, nil, true, ctx)
+ result, err := s.client.QueryContactCards(toNullQueryParams([]string{accountId}), nil, filter, sortBy, true, ctx)
require.NoError(err)
require.Len(result.Payload, 1)
@@ -161,7 +161,7 @@ func TestContacts(t *testing.T) {
for i := range slices {
position := int(i * limit)
page := min(remainder, limit)
- result, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, position, "", nil, &limit, true, ctx)
+ result, err := s.client.QueryContactCards(map[string]QueryParams{accountId: {Position: position}}, &limit, filter, sortBy, true, ctx)
require.NoError(err)
require.Len(result.Payload, 1)
require.Contains(result.Payload, accountId)
@@ -186,7 +186,7 @@ func TestContacts(t *testing.T) {
offset := 0
i := 0
for chunk := range slices.Chunk(results.Results, chunkSize) {
- result, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, anchor, &offset, uintPtr(chunkSize), true, ctx)
+ result, err := s.client.QueryContactCards(map[string]QueryParams{accountId: {Anchor: anchor, AnchorOffset: &offset}}, uintPtr(chunkSize), filter, sortBy, true, ctx)
require.Equal(ss, result.GetSessionState())
require.NoError(err)
require.Len(result.Payload, 1)
@@ -236,7 +236,7 @@ func TestContacts(t *testing.T) {
os = result.GetState()
}
{
- result, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, "", nil, nil, true, ctx)
+ result, err := s.client.QueryContactCards(toNullQueryParams([]string{accountId}), nil, filter, sortBy, true, ctx)
require.NoError(err)
require.Contains(result.Payload, accountId)
resp := result.Payload[accountId]
diff --git a/pkg/jmap/integration_calendar_test.go b/pkg/jmap/integration_calendar_test.go
index 1411524c3a..4978097851 100644
--- a/pkg/jmap/integration_calendar_test.go
+++ b/pkg/jmap/integration_calendar_test.go
@@ -35,13 +35,13 @@ func TestCalendars(t *testing.T) { //NOSONAR
func(session *Session) string { return session.PrimaryAccounts.Calendars },
func(resp CalendarGetResponse) []Calendar { return resp.List },
func(obj Calendar) string { return obj.Id },
- func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[CalendarGetResponse], Error) {
+ func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[CalendarGetResponse], error) {
return s.client.GetCalendars(accountId, ids, ctx)
},
- func(s *StalwartTest, accountId string, id string, change CalendarChange, ctx Context) (Result[Calendar], Error) { //NOSONAR
+ func(s *StalwartTest, accountId string, id string, change CalendarChange, ctx Context) (Result[Calendar], error) { //NOSONAR
return s.client.UpdateCalendar(accountId, id, change, ctx)
},
- func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[map[string]SetError], Error) { //NOSONAR
+ func(s *StalwartTest, accountId string, ids []string, ctx Context) (Result[map[string]SetError], error) { //NOSONAR
return s.client.DeleteCalendar(accountId, ids, ctx)
},
func(s *StalwartTest, t *testing.T, accountId string, count uint, ctx Context, user User, principalIds []string) (CalendarBoxes, []Calendar, SessionState, State, error) {
@@ -94,7 +94,7 @@ func TestEvents(t *testing.T) {
os := EmptyState
var results *CalendarEventSearchResults
{
- result, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, "", nil, nil, true, ctx)
+ result, err := s.client.QueryCalendarEvents(toNullQueryParams([]string{accountId}), nil, filter, sortBy, true, ctx)
require.NoError(err)
require.Len(result.Payload, 1)
@@ -127,7 +127,7 @@ func TestEvents(t *testing.T) {
for i := range slices {
position := int(i * limit)
page := min(remainder, limit)
- result, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, position, "", nil, &limit, true, ctx)
+ result, err := s.client.QueryCalendarEvents(map[string]QueryParams{accountId: {Position: position}}, &limit, filter, sortBy, true, ctx)
require.NoError(err)
require.Len(result.Payload, 1)
require.Contains(result.Payload, accountId)
@@ -152,7 +152,7 @@ func TestEvents(t *testing.T) {
offset := 0
i := 0
for chunk := range slices.Chunk(results.Results, chunkSize) {
- result, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, anchor, &offset, uintPtr(chunkSize), true, ctx)
+ result, err := s.client.QueryCalendarEvents(map[string]QueryParams{accountId: {Anchor: anchor, AnchorOffset: &offset}}, uintPtr(chunkSize), filter, sortBy, true, ctx)
require.Equal(ss, result.GetSessionState())
require.NoError(err)
require.Len(result.Payload, 1)
@@ -207,7 +207,7 @@ func TestEvents(t *testing.T) {
}
{
- result, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, "", nil, nil, true, ctx)
+ result, err := s.client.QueryCalendarEvents(toNullQueryParams([]string{accountId}), nil, filter, sortBy, true, ctx)
require.NoError(err)
require.Contains(result.Payload, accountId)
resp := result.Payload[accountId]
diff --git a/pkg/jmap/integration_email_test.go b/pkg/jmap/integration_email_test.go
index 227fb8b361..b85dfa3b0a 100644
--- a/pkg/jmap/integration_email_test.go
+++ b/pkg/jmap/integration_email_test.go
@@ -81,7 +81,7 @@ func TestEmails(t *testing.T) {
}
{
- result, err := s.client.GetAllEmailsInMailbox(accountId, inboxId, 0, "", nil, nil, true, false, 0, true, ctx)
+ result, err := s.client.GetAllEmailsInMailbox(accountId, inboxId, NullQueryParams, nil, true, false, 0, true, ctx)
require.NoError(err)
require.Equal(session.State, result.GetSessionState())
@@ -95,7 +95,7 @@ func TestEmails(t *testing.T) {
}
{
- result, err := s.client.GetAllEmailsInMailbox(accountId, inboxId, 0, "", nil, nil, false, false, 0, true, ctx)
+ result, err := s.client.GetAllEmailsInMailbox(accountId, inboxId, NullQueryParams, nil, false, false, 0, true, ctx)
require.NoError(err)
require.Equal(session.State, result.GetSessionState())
@@ -331,8 +331,8 @@ func TestSendingEmails(t *testing.T) {
result, err := s.client.QueryEmails([]string{r.accountId}, EmailFilterCondition{InMailbox: inboxId}, 0, 0, true, 0, rctx)
require.NoError(err)
require.Contains(result.Payload, r.accountId)
- require.Len(result.Payload[r.accountId].Emails, 1)
- received := result.Payload[r.accountId].Emails[0]
+ require.Len(result.Payload[r.accountId].Results, 1)
+ received := result.Payload[r.accountId].Results[0]
require.Len(received.From, 1)
require.Equal(from.email, received.From[0].Email)
require.Equal(fromName, received.From[0].Name)
diff --git a/pkg/jmap/model.go b/pkg/jmap/model.go
index 8191fd8c8c..ddc3846261 100644
--- a/pkg/jmap/model.go
+++ b/pkg/jmap/model.go
@@ -8,6 +8,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/jscalendar"
"github.com/opencloud-eu/opencloud/pkg/jscontact"
+ "github.com/opencloud-eu/opencloud/pkg/structs"
)
// https://www.iana.org/assignments/jmap/jmap.xml#jmap-data-types
@@ -1177,6 +1178,10 @@ func invocation(parameters JmapCommand, tag string) Invocation {
}
}
+func skipInvocation() Invocation {
+ return Invocation{Command: ""}
+}
+
type TypeOfRequest string
const RequestType = TypeOfRequest("Request")
@@ -1376,9 +1381,14 @@ type ChangesResponse[T Foo] interface {
GetDestroyed() []string
}
-type QueryCommand[T Foo] interface {
+type QueryCommand[T Foo, SELF QueryCommand[T, SELF]] interface {
JmapCommand
GetResponse() QueryResponse[T]
+ GetPosition() int
+ GetAnchor() string
+ GetAnchorOffset() *int
+ GetLimit() *uint
+ WithLimit(limit *uint) SELF
}
type QueryResponse[T Foo] interface {
@@ -1422,12 +1432,37 @@ type Changes[T Foo] interface {
GetDestroyed() []string
}
+type QueryParams struct {
+ Position int `json:"p,omitzero"`
+ Anchor string `json:"a,omitempty"`
+ AnchorOffset *int `json:"o,omitempty"`
+}
+
+var NullQueryParams = QueryParams{Position: 0, Anchor: "", AnchorOffset: nil}
+
+func toNullQueryParams(accountIds []string) map[string]QueryParams {
+ return structs.ToMap(accountIds, func(k string) (string, QueryParams) { return k, NullQueryParams })
+}
+
+type FilterElement[T Foo] interface {
+ GetMarker() T
+}
+
+type Comparator[T Foo] interface {
+ GetMarker() T
+}
+
// using a custom type for the "canCalculateChanges" attribute in order to customize its rendering:
// as it's going to be "true" in 99% if not 100% of cases with Stalwart, we only want to render this
// attribute if its value is "false"
type ChangeCalculation bool
+const (
+ CapableOfChangeCalculation = ChangeCalculation(true)
+ IncapableOfChangeCalculation = ChangeCalculation(false)
+)
+
func (cc *ChangeCalculation) IsZero() bool {
return *cc == true
}
@@ -1788,22 +1823,27 @@ type MailboxFilterCondition struct {
IsSubscribed *bool `json:"isSubscribed,omitempty"`
}
+var _ FilterElement[Mailbox] = &MailboxFilterCondition{}
+var _ MailboxFilterElement = &MailboxFilterCondition{}
+
func (c MailboxFilterCondition) _isAMailboxFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
-var _ MailboxFilterElement = &MailboxFilterCondition{}
+func (c MailboxFilterCondition) GetMarker() Mailbox { return Mailbox{} }
type MailboxFilterOperator struct {
Operator FilterOperatorTerm `json:"operator"`
Conditions []MailboxFilterElement `json:"conditions,omitempty"`
}
+var _ FilterElement[Mailbox] = &MailboxFilterCondition{}
+var _ MailboxFilterElement = &MailboxFilterOperator{}
+
func (c MailboxFilterOperator) _isAMailboxFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
-
-var _ MailboxFilterElement = &MailboxFilterOperator{}
+func (c MailboxFilterOperator) GetMarker() Mailbox { return Mailbox{} }
type MailboxComparator struct {
Property string `json:"property"`
@@ -1812,6 +1852,10 @@ type MailboxComparator struct {
CalculateTotal bool `json:"calculateTotal,omitempty"`
}
+var _ Comparator[Mailbox] = &MailboxComparator{}
+
+func (c MailboxComparator) GetMarker() Mailbox { return Mailbox{} }
+
type MailboxQueryCommand struct {
AccountId string `json:"accountId"`
Filter MailboxFilterElement `json:"filter,omitempty"`
@@ -1863,11 +1907,29 @@ type MailboxQueryCommand struct {
CalculateTotal bool `json:"calculateTotal,omitempty"`
}
-var _ QueryCommand[Mailbox] = &MailboxQueryCommand{}
+var _ QueryCommand[Mailbox, MailboxQueryCommand] = &MailboxQueryCommand{}
func (c MailboxQueryCommand) GetCommand() Command { return CommandMailboxQuery }
func (c MailboxQueryCommand) GetObjectType() ObjectType { return MailboxType }
-func (c MailboxQueryCommand) GetResponse() QueryResponse[Mailbox] { return MailboxQueryResponse{} }
+func (c MailboxQueryCommand) GetResponse() QueryResponse[Mailbox] { return &MailboxQueryResponse{} }
+func (c MailboxQueryCommand) GetPosition() int { return c.Position }
+func (c MailboxQueryCommand) GetAnchor() string { return c.Anchor }
+func (c MailboxQueryCommand) GetAnchorOffset() *int { return c.AnchorOffset }
+func (c MailboxQueryCommand) GetLimit() *uint { return c.Limit }
+func (c MailboxQueryCommand) WithLimit(limit *uint) MailboxQueryCommand {
+ return MailboxQueryCommand{
+ AccountId: c.AccountId,
+ Filter: c.Filter,
+ Sort: c.Sort,
+ SortAsTree: c.SortAsTree,
+ FilterAsTree: c.FilterAsTree,
+ Position: c.Position,
+ Anchor: c.Anchor,
+ AnchorOffset: c.AnchorOffset,
+ Limit: limit,
+ CalculateTotal: c.CalculateTotal,
+ }
+}
type EmailFilterElement interface {
_isAnEmailFilterElement() // marker method
@@ -1970,6 +2032,11 @@ type EmailFilterCondition struct {
Header []string `json:"header,omitempty"`
}
+var _ EmailFilterElement = &EmailFilterCondition{}
+var _ FilterElement[Email] = &EmailFilterCondition{}
+
+func (f EmailFilterCondition) GetMarker() Email { return Email{} }
+
func (f EmailFilterCondition) _isAnEmailFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
@@ -2038,13 +2105,16 @@ func (f EmailFilterCondition) IsNotEmpty() bool { //NOSONAR
return false
}
-var _ EmailFilterElement = &EmailFilterCondition{}
-
type EmailFilterOperator struct {
Operator FilterOperatorTerm `json:"operator"`
Conditions []EmailFilterElement `json:"conditions,omitempty"`
}
+var _ EmailFilterElement = &EmailFilterOperator{}
+var _ FilterElement[Email] = &EmailFilterCondition{}
+
+func (f EmailFilterOperator) GetMarker() Email { return Email{} }
+
func (o EmailFilterOperator) _isAnEmailFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
@@ -2053,8 +2123,6 @@ func (o EmailFilterOperator) IsNotEmpty() bool {
return len(o.Conditions) > 0
}
-var _ EmailFilterElement = &EmailFilterOperator{}
-
type EmailComparator struct {
// The name of the property on the objects to compare.
Property string `json:"property,omitempty"`
@@ -2080,6 +2148,10 @@ type EmailComparator struct {
Keyword string `json:"keyword,omitempty"`
}
+var _ Comparator[Email] = &EmailComparator{}
+
+func (c EmailComparator) GetMarker() Email { return Email{} }
+
// If an anchor argument is given, the anchor is looked for in the results after filtering
// and sorting.
//
@@ -2159,11 +2231,28 @@ type EmailQueryCommand struct {
CalculateTotal bool `json:"calculateTotal,omitempty"`
}
-var _ QueryCommand[Email] = &EmailQueryCommand{}
+var _ QueryCommand[Email, EmailQueryCommand] = &EmailQueryCommand{}
func (c EmailQueryCommand) GetCommand() Command { return CommandEmailQuery }
func (c EmailQueryCommand) GetObjectType() ObjectType { return MailboxType }
-func (c EmailQueryCommand) GetResponse() QueryResponse[Email] { return EmailQueryResponse{} }
+func (c EmailQueryCommand) GetResponse() QueryResponse[Email] { return &EmailQueryResponse{} }
+func (c EmailQueryCommand) GetPosition() int { return c.Position }
+func (c EmailQueryCommand) GetAnchor() string { return c.Anchor }
+func (c EmailQueryCommand) GetAnchorOffset() *int { return c.AnchorOffset }
+func (c EmailQueryCommand) GetLimit() *uint { return c.Limit }
+func (c EmailQueryCommand) WithLimit(limit *uint) EmailQueryCommand {
+ return EmailQueryCommand{
+ AccountId: c.AccountId,
+ Filter: c.Filter,
+ Sort: c.Sort,
+ Position: c.Position,
+ Anchor: c.Anchor,
+ AnchorOffset: c.AnchorOffset,
+ Limit: limit,
+ CalculateTotal: c.CalculateTotal,
+ CollapseThreads: c.CollapseThreads,
+ }
+}
type EmailGetCommand struct {
// The ids of the Email objects to return.
@@ -3189,7 +3278,7 @@ type EmailQueryResponse struct {
CanCalculateChanges ChangeCalculation `json:"canCalculateChanges,omitzero"`
// The zero-based index of the first result in the ids array within the complete list of query results.
- Position uint `json:"position"`
+ Position *uint `json:"position,omitempty"`
// The list of ids for each Email in the query results, starting at the index given by the position argument of this
// response and continuing until it hits the end of the results or reaches the limit number of ids.
@@ -3207,7 +3296,7 @@ type EmailQueryResponse struct {
// The limit enforced by the server on the maximum number of results to return (if set by the server).
//
// This is only returned if the server set a limit or used a different limit than that given in the request.
- Limit uint `json:"limit,omitempty,omitzero"`
+ Limit *uint `json:"limit,omitempty,omitzero"`
}
var _ QueryResponse[Email] = &EmailQueryResponse{}
@@ -3414,7 +3503,7 @@ type MailboxQueryResponse struct {
CanCalculateChanges ChangeCalculation `json:"canCalculateChanges,omitzero"`
// The zero-based index of the first result in the ids array within the complete list of query results.
- Position int `json:"position"`
+ Position *uint `json:"position,omitempty"`
// The list of ids for each Mailbox in the query results, starting at the index given by the position argument
// of this response and continuing until it hits the end of the results or reaches the limit number of ids.
@@ -3430,7 +3519,7 @@ type MailboxQueryResponse struct {
// The limit enforced by the server on the maximum number of results to return (if set by the server).
//
// This is only returned if the server set a limit or used a different limit than that given in the request.
- Limit int `json:"limit,omitzero"`
+ Limit *uint `json:"limit,omitzero"`
}
var _ QueryResponse[Mailbox] = &MailboxQueryResponse{}
@@ -6890,9 +6979,14 @@ type ContactCardComparator struct {
Updated time.Time `json:"updated,omitzero"`
}
+var _ Comparator[ContactCard] = &ContactCardComparator{}
+
+func (c ContactCardComparator) GetMarker() ContactCard { return ContactCard{} }
+
type ContactCardFilterElement interface {
_isAContactCardFilterElement() // marker method
IsNotEmpty() bool
+ FilterElement[ContactCard]
}
type ContactCardFilterCondition struct {
@@ -6968,6 +7062,12 @@ type ContactCardFilterCondition struct {
Note string `json:"note,omitempty"`
}
+var _ ContactCardFilterElement = &ContactCardFilterCondition{}
+
+var _ FilterElement[ContactCard] = &ContactCardFilterCondition{}
+
+func (f ContactCardFilterCondition) GetMarker() ContactCard { return ContactCard{} }
+
func (f ContactCardFilterCondition) _isAContactCardFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
@@ -7036,13 +7136,17 @@ func (f ContactCardFilterCondition) IsNotEmpty() bool { //NOSONAR
return false
}
-var _ ContactCardFilterElement = &ContactCardFilterCondition{}
-
type ContactCardFilterOperator struct {
Operator FilterOperatorTerm `json:"operator"`
Conditions []ContactCardFilterElement `json:"conditions,omitempty"`
}
+var _ ContactCardFilterElement = &ContactCardFilterOperator{}
+
+var _ FilterElement[ContactCard] = &ContactCardFilterOperator{}
+
+func (f ContactCardFilterOperator) GetMarker() ContactCard { return ContactCard{} }
+
func (o ContactCardFilterOperator) _isAContactCardFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
@@ -7051,8 +7155,6 @@ func (o ContactCardFilterOperator) IsNotEmpty() bool {
return len(o.Conditions) > 0
}
-var _ ContactCardFilterElement = &ContactCardFilterOperator{}
-
type ContactCardQueryCommand struct {
AccountId string `json:"accountId"`
@@ -7104,12 +7206,28 @@ type ContactCardQueryCommand struct {
CalculateTotal bool `json:"calculateTotal,omitzero"`
}
-var _ QueryCommand[ContactCard] = &ContactCardQueryCommand{}
+var _ QueryCommand[ContactCard, ContactCardQueryCommand] = &ContactCardQueryCommand{}
func (c ContactCardQueryCommand) GetCommand() Command { return CommandContactCardQuery }
func (c ContactCardQueryCommand) GetObjectType() ObjectType { return ContactCardType }
func (c ContactCardQueryCommand) GetResponse() QueryResponse[ContactCard] {
- return ContactCardQueryResponse{}
+ return &ContactCardQueryResponse{}
+}
+func (c ContactCardQueryCommand) GetPosition() int { return c.Position }
+func (c ContactCardQueryCommand) GetAnchor() string { return c.Anchor }
+func (c ContactCardQueryCommand) GetAnchorOffset() *int { return c.AnchorOffset }
+func (c ContactCardQueryCommand) GetLimit() *uint { return c.Limit }
+func (c ContactCardQueryCommand) WithLimit(limit *uint) ContactCardQueryCommand {
+ return ContactCardQueryCommand{
+ AccountId: c.AccountId,
+ Filter: c.Filter,
+ Sort: c.Sort,
+ Position: c.Position,
+ Anchor: c.Anchor,
+ AnchorOffset: c.AnchorOffset,
+ Limit: limit,
+ CalculateTotal: c.CalculateTotal,
+ }
}
type ContactCardQueryResponse struct {
@@ -7141,7 +7259,7 @@ type ContactCardQueryResponse struct {
CanCalculateChanges ChangeCalculation `json:"canCalculateChanges,omitzero"`
// The zero-based index of the first result in the ids array within the complete list of query results.
- Position uint `json:"position"`
+ Position *uint `json:"position,omitempty"`
// The list of ids for each ContactCard in the query results, starting at the index given by the position argument of this
// response and continuing until it hits the end of the results or reaches the limit number of ids.
@@ -7159,7 +7277,7 @@ type ContactCardQueryResponse struct {
// The limit enforced by the server on the maximum number of results to return (if set by the server).
//
// This is only returned if the server set a limit or used a different limit than that given in the request.
- Limit uint `json:"limit,omitzero"`
+ Limit *uint `json:"limit,omitzero"`
}
var _ QueryResponse[ContactCard] = &ContactCardQueryResponse{}
@@ -7673,6 +7791,10 @@ type CalendarEventComparator struct {
TimeZone string `json:"timeZone,omitempty" doc:"opt" default:"Etc/UTC"`
}
+var _ Comparator[CalendarEvent] = &CalendarEventComparator{}
+
+func (c CalendarEventComparator) GetMarker() CalendarEvent { return CalendarEvent{} }
+
type CalendarEventFilterElement interface {
_isACalendarEventFilterElement() // marker method
IsNotEmpty() bool
@@ -7725,6 +7847,11 @@ type CalendarEventFilterCondition struct {
Uid string `json:"uid,omitempty" doc:"opt"`
}
+var _ CalendarEventFilterElement = &CalendarEventFilterCondition{}
+var _ FilterElement[CalendarEvent] = &CalendarEventFilterCondition{}
+
+func (f CalendarEventFilterCondition) GetMarker() CalendarEvent { return CalendarEvent{} }
+
func (f CalendarEventFilterCondition) _isACalendarEventFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
@@ -7766,8 +7893,6 @@ func (f CalendarEventFilterCondition) IsNotEmpty() bool {
return false
}
-var _ CalendarEventFilterElement = &CalendarEventFilterCondition{}
-
type CalendarEventFilterOperator struct {
Operator FilterOperatorTerm `json:"operator"`
Conditions []CalendarEventFilterElement `json:"conditions,omitempty"`
@@ -7834,12 +7959,28 @@ type CalendarEventQueryCommand struct {
CalculateTotal bool `json:"calculateTotal,omitempty" doc:"opt" default:"false"`
}
-var _ QueryCommand[CalendarEvent] = &CalendarEventQueryCommand{}
+var _ QueryCommand[CalendarEvent, CalendarEventQueryCommand] = &CalendarEventQueryCommand{}
func (c CalendarEventQueryCommand) GetCommand() Command { return CommandCalendarEventQuery }
func (c CalendarEventQueryCommand) GetObjectType() ObjectType { return CalendarEventType }
func (c CalendarEventQueryCommand) GetResponse() QueryResponse[CalendarEvent] {
- return CalendarEventQueryResponse{}
+ return &CalendarEventQueryResponse{}
+}
+func (c CalendarEventQueryCommand) GetPosition() int { return c.Position }
+func (c CalendarEventQueryCommand) GetAnchor() string { return c.Anchor }
+func (c CalendarEventQueryCommand) GetAnchorOffset() *int { return c.AnchorOffset }
+func (c CalendarEventQueryCommand) GetLimit() *uint { return c.Limit }
+func (c CalendarEventQueryCommand) WithLimit(limit *uint) CalendarEventQueryCommand {
+ return CalendarEventQueryCommand{
+ AccountId: c.AccountId,
+ Filter: c.Filter,
+ Sort: c.Sort,
+ Position: c.Position,
+ Anchor: c.Anchor,
+ AnchorOffset: c.AnchorOffset,
+ Limit: limit,
+ CalculateTotal: c.CalculateTotal,
+ }
}
type CalendarEventQueryResponse struct {
@@ -7871,7 +8012,7 @@ type CalendarEventQueryResponse struct {
CanCalculateChanges ChangeCalculation `json:"canCalculateChanges,omitzero"`
// The zero-based index of the first result in the ids array within the complete list of query results.
- Position uint `json:"position"`
+ Position *uint `json:"position,omitempty"`
// The list of ids for each ContactCard in the query results, starting at the index given by the position argument of this
// response and continuing until it hits the end of the results or reaches the limit number of ids.
@@ -7889,7 +8030,7 @@ type CalendarEventQueryResponse struct {
// The limit enforced by the server on the maximum number of results to return (if set by the server).
//
// This is only returned if the server set a limit or used a different limit than that given in the request.
- Limit uint `json:"limit,omitzero"`
+ Limit *uint `json:"limit,omitzero"`
}
var _ QueryResponse[CalendarEvent] = &CalendarEventQueryResponse{}
@@ -8238,23 +8379,29 @@ type PrincipalFilterCondition struct {
TimeZone string `json:"timeZone,omitempty"`
}
+var _ PrincipalFilterElement = &PrincipalFilterCondition{}
+var _ FilterElement[Principal] = &PrincipalFilterCondition{}
+
+func (f PrincipalFilterCondition) GetMarker() Principal { return Principal{} }
+
func (c PrincipalFilterCondition) _isAPrincipalFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
-var _ PrincipalFilterElement = &PrincipalFilterCondition{}
-
type PrincipalFilterOperator struct {
Operator FilterOperatorTerm `json:"operator"`
Conditions []PrincipalFilterElement `json:"conditions,omitempty"`
}
+var _ PrincipalFilterElement = &PrincipalFilterOperator{}
+var _ FilterElement[Principal] = &PrincipalFilterOperator{}
+
+func (f PrincipalFilterOperator) GetMarker() Principal { return Principal{} }
+
func (c PrincipalFilterOperator) _isAPrincipalFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
-var _ PrincipalFilterElement = &PrincipalFilterOperator{}
-
type PrincipalComparator struct {
Property string `json:"property"`
IsAscending bool `json:"isAscending,omitempty"`
@@ -8262,6 +8409,10 @@ type PrincipalComparator struct {
CalculateTotal bool `json:"calculateTotal,omitempty"`
}
+var _ Comparator[Principal] = &PrincipalComparator{}
+
+func (c PrincipalComparator) GetMarker() Principal { return Principal{} }
+
type PrincipalQueryCommand struct {
AccountId string `json:"accountId"`
Filter PrincipalFilterElement `json:"filter,omitempty"`
@@ -8311,12 +8462,28 @@ type PrincipalQueryCommand struct {
CalculateTotal bool `json:"calculateTotal,omitzero"`
}
-var _ QueryCommand[Principal] = PrincipalQueryCommand{}
+var _ QueryCommand[Principal, PrincipalQueryCommand] = &PrincipalQueryCommand{}
func (c PrincipalQueryCommand) GetCommand() Command { return CommandPrincipalQuery }
func (c PrincipalQueryCommand) GetObjectType() ObjectType { return PrincipalType }
func (c PrincipalQueryCommand) GetResponse() QueryResponse[Principal] {
- return PrincipalQueryResponse{}
+ return &PrincipalQueryResponse{}
+}
+func (c PrincipalQueryCommand) GetPosition() int { return c.Position }
+func (c PrincipalQueryCommand) GetAnchor() string { return c.Anchor }
+func (c PrincipalQueryCommand) GetAnchorOffset() *int { return c.AnchorOffset }
+func (c PrincipalQueryCommand) GetLimit() *uint { return c.Limit }
+func (c PrincipalQueryCommand) WithLimit(limit *uint) PrincipalQueryCommand {
+ return PrincipalQueryCommand{
+ AccountId: c.AccountId,
+ Filter: c.Filter,
+ Sort: c.Sort,
+ Position: c.Position,
+ Anchor: c.Anchor,
+ AnchorOffset: c.AnchorOffset,
+ Limit: limit,
+ CalculateTotal: c.CalculateTotal,
+ }
}
type PrincipalQueryResponse struct {
@@ -8347,7 +8514,7 @@ type PrincipalQueryResponse struct {
CanCalculateChanges ChangeCalculation `json:"canCalculateChanges,omitzero"`
// The zero-based index of the first result in the ids array within the complete list of query results.
- Position uint `json:"position"`
+ Position *uint `json:"position,omitempty"`
// The list of ids for each Principal in the query results, starting at the index given by the position argument
// of this response and continuing until it hits the end of the results or reaches the limit number of ids.
@@ -8363,7 +8530,7 @@ type PrincipalQueryResponse struct {
// The limit enforced by the server on the maximum number of results to return (if set by the server).
//
// This is only returned if the server set a limit or used a different limit than that given in the request.
- Limit uint `json:"limit,omitzero"`
+ Limit *uint `json:"limit,omitzero"`
}
var _ QueryResponse[Principal] = &PrincipalQueryResponse{}
diff --git a/pkg/jmap/model_examples.go b/pkg/jmap/model_examples.go
index 699de596bf..1c763bcabd 100644
--- a/pkg/jmap/model_examples.go
+++ b/pkg/jmap/model_examples.go
@@ -14,6 +14,19 @@ import (
c "github.com/opencloud-eu/opencloud/pkg/jscontact"
)
+func copyTo[B any, A any](a A) B {
+ if b, err := json.Marshal(a); err != nil {
+ panic(err)
+ } else {
+ var t B
+ if err := json.Unmarshal(b, &t); err != nil {
+ panic(err)
+ } else {
+ return t
+ }
+ }
+}
+
func SerializeExamples(e any) { //NOSONAR
type example struct {
Type string `json:"type"`
@@ -2329,16 +2342,3 @@ func (e Exemplar) ContactCardSearchResults() ContactCardSearchResults {
Total: ptr(uint(4)),
}
}
-
-func copyTo[B any, A any](a A) B {
- if b, err := json.Marshal(a); err != nil {
- panic(err)
- } else {
- var t B
- if err := json.Unmarshal(b, &t); err != nil {
- panic(err)
- } else {
- return t
- }
- }
-}
diff --git a/pkg/jmap/templates.go b/pkg/jmap/templates.go
index ffe556c611..fc8ba115be 100644
--- a/pkg/jmap/templates.go
+++ b/pkg/jmap/templates.go
@@ -429,13 +429,15 @@ 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
+/*
+func query[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T], QUERYRESP QueryResponse[T], GETRESP GetResponse[T], RESP SearchResults[T]]( //NOSONAR
client *Client, name string, objType ObjectType,
defaultSortBy []SORT,
- queryCommandFactory func(filter FILTER, sortBy []SORT, position int, anchor string, anchorOffset *int, limit *uint) QUERY,
+ queryCommandFactory func(filter FILTER, sortBy []SORT) QUERY,
getCommandFactory func(cmd Command, path string, rof string) GET,
+ respMapper0 func(query QUERYRESP) *RESP,
respMapper func(query QUERYRESP, get GETRESP) *RESP,
- filter FILTER, sortBy []SORT, position int, anchor string, anchorOffset *int, limit *uint,
+ filter FILTER, sortBy []SORT,
ctx Context) (Result[*RESP], Error) {
logger := client.logger(name, ctx)
@@ -445,8 +447,14 @@ func query[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T]
sortBy = defaultSortBy
}
- query := queryCommandFactory(filter, sortBy, position, anchor, anchorOffset, limit)
- get := getCommandFactory(query.GetCommand(), "/ids/*", "0")
+ query := queryCommandFactory(filter, sortBy)
+ var get GET
+ limit := query.GetLimit()
+ if limit != nil && *limit == 0 {
+ query.SetLimit(UintPtrOne)
+ } else {
+ get = getCommandFactory(query.GetCommand(), "/ids/*", "0")
+ }
cmd, err := client.request(ctx, objType.Namespaces, invocation(query, "0"), invocation(get, "1"))
if err != nil {
@@ -459,43 +467,60 @@ func query[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T]
if err != nil {
return nil, EmptyState, err
}
- var getResponse GETRESP
- err = retrieveGet(ctx, body, get, "1", &getResponse)
- if err != nil {
- return nil, EmptyState, err
+ if limit == nil && *limit == 0 {
+ result := respMapper0(queryResponse)
+ if query.GetAnchor() != "" && (*result).GetPosition() != nil && *(*result).GetPosition() == 0 {
+ (*result).SetPosition(nil)
+ }
+ return result, queryResponse.GetQueryState(), nil
+ } else {
+ var getResponse GETRESP
+ err = retrieveGet(ctx, body, get, "1", &getResponse)
+ if err != nil {
+ return nil, EmptyState, err
+ }
+ return respMapper(queryResponse, getResponse), queryResponse.GetQueryState(), nil
}
- return respMapper(queryResponse, getResponse), queryResponse.GetQueryState(), nil
})
}
+*/
-func queryN[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T], QUERYRESP QueryResponse[T], GETRESP GetResponse[T], RESP any]( //NOSONAR
+func queryN[T Foo, FILTER any, SORT any, QUERY QueryCommand[T, QUERY], 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, anchor string, anchorOffset *int, imit *uint) QUERY,
+ queryCommandFactory func(accountId string, queryParams QueryParams, limit *uint, filter FILTER, sortBy []SORT) 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, position int, anchor string, anchorOffset *int, limit *uint,
+ respMapper0 func(query QUERYRESP, queryParams QueryParams, limit *uint) *RESP,
+ respMapper func(query QUERYRESP, get GETRESP, queryParams QueryParams, limit *uint) *RESP,
+ accountIds map[string]QueryParams,
+ limit *uint, filter FILTER, sortBy []SORT,
ctx Context) (Result[map[string]*RESP], Error) {
logger := client.logger(name, ctx)
ctx = ctx.WithLogger(logger)
- uniqueAccountIds := structs.Uniq(accountIds)
-
if sortBy == nil {
sortBy = defaultSortBy
}
- invocations := make([]Invocation, len(uniqueAccountIds)*2)
+ invocations := make([]Invocation, len(accountIds)*2)
var g GET
var q QUERY
- for i, accountId := range uniqueAccountIds {
- 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"))
- q = query
- g = get
+ {
+ i := 0
+ for accountId, queryParams := range accountIds {
+ query := queryCommandFactory(accountId, queryParams, limit, filter, sortBy)
+ q = query
+ invocations[i*2+0] = invocation(query, mcid(accountId, "0"))
+ if limit != nil && *limit == 0 {
+ query = query.WithLimit(UintPtrOne)
+ invocations[i*2+1] = skipInvocation()
+ } else {
+ get := getCommandFactory(accountId, query.GetCommand(), "/ids/*", mcid(accountId, "0"))
+ invocations[i*2+1] = invocation(get, mcid(accountId, "1"))
+ g = get
+ }
+ i++
+ }
}
cmd, err := client.request(ctx, objType.Namespaces, invocations...)
@@ -506,22 +531,27 @@ func queryN[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T
return command(client, ctx, cmd, func(body *Response) (map[string]*RESP, State, Error) {
resp := map[string]*RESP{}
stateByAccountId := map[string]State{}
- for _, accountId := range uniqueAccountIds {
+ for accountId, queryParams := range accountIds {
var queryResponse QUERYRESP
err = retrieveQuery(ctx, body, q, mcid(accountId, "0"), &queryResponse)
if err != nil {
- return nil, "", err
+ return nil, EmptyState, err
}
- var getResponse GETRESP
- err = retrieveGet(ctx, body, g, mcid(accountId, "1"), &getResponse)
- if err != nil {
- return nil, "", err
+ if limit != nil && *limit == 0 {
+ resp[accountId] = respMapper0(queryResponse, queryParams, limit)
+ stateByAccountId[accountId] = queryResponse.GetQueryState()
+ } else {
+ var getResponse GETRESP
+ err = retrieveGet(ctx, body, g, mcid(accountId, "1"), &getResponse)
+ if err != nil {
+ return nil, EmptyState, err
+ }
+ if len(getResponse.GetNotFound()) > 0 {
+ // TODO what to do when there are not-found calendarevents here? potentially nothing, they could have been deleted between query and get?
+ }
+ resp[accountId] = respMapper(queryResponse, getResponse, queryParams, limit)
+ stateByAccountId[accountId] = getResponse.GetState()
}
- if len(getResponse.GetNotFound()) > 0 {
- // TODO what to do when there are not-found calendarevents here? potentially nothing, they could have been deleted between query and get?
- }
- resp[accountId] = respMapper(queryResponse, getResponse)
- stateByAccountId[accountId] = getResponse.GetState()
}
return resp, squashState(stateByAccountId), nil
})
diff --git a/pkg/jmap/tools.go b/pkg/jmap/tools.go
index 7d4a96d1e7..3956aa3673 100644
--- a/pkg/jmap/tools.go
+++ b/pkg/jmap/tools.go
@@ -14,6 +14,9 @@ import (
"github.com/opencloud-eu/opencloud/pkg/jscalendar"
)
+var UintPtrOne *uint = uintPtr(1)
+var UintPtrZero *uint = uintPtr(0)
+
type eventListeners[T any] struct {
listeners []T
m sync.Mutex
@@ -141,7 +144,7 @@ func mapstructStringToTimeHook() mapstructure.DecodeHookFunc {
// mapstruct isn't able to properly map RFC3339 date strings into Time
// objects, which is why we require this custom hook,
// see https://github.com/mitchellh/mapstructure/issues/41
- wanted := reflect.TypeOf(time.Time{})
+ wanted := reflect.TypeFor[time.Time]()
return func(from reflect.Type, to reflect.Type, data any) (any, error) {
if to != wanted {
return data, nil
@@ -238,7 +241,7 @@ func retrieveSet[T Foo, C SetCommand[T], R SetResponse[T]](ctx Context, data *Re
return retrieveResponseMatchParameters(ctx, data, command.GetCommand(), tag, target)
}
-func retrieveQuery[T Foo, C QueryCommand[T], R QueryResponse[T]](ctx Context, data *Response, command C, tag string, target *R) Error {
+func retrieveQuery[T Foo, C QueryCommand[T, C], R QueryResponse[T]](ctx Context, data *Response, command C, tag string, target *R) Error {
return retrieveResponseMatchParameters(ctx, data, command.GetCommand(), tag, target)
}
diff --git a/pkg/structs/structs.go b/pkg/structs/structs.go
index 457270aed1..ff7cc3cdaf 100644
--- a/pkg/structs/structs.go
+++ b/pkg/structs/structs.go
@@ -2,6 +2,7 @@
package structs
import (
+ "fmt"
"iter"
"maps"
"slices"
@@ -309,3 +310,52 @@ func FilterSeq[T any](it iter.Seq[T], predicate func(T) bool) iter.Seq[T] {
}
}
}
+
+func FilterKeys[K comparable, V any](m map[K]V, predicate func(K, V) bool) []K {
+ if m == nil {
+ return []K{}
+ }
+ r := []K{}
+ for k, v := range m {
+ if predicate(k, v) {
+ r = append(r, k)
+ }
+ }
+ return r
+}
+
+func FilterValues[K comparable, V any](m map[K]V, predicate func(K, V) bool) []V {
+ if m == nil {
+ return []V{}
+ }
+ r := []V{}
+ for k, v := range m {
+ if predicate(k, v) {
+ r = append(r, v)
+ }
+ }
+ return r
+}
+
+func MeshMap[A any, B any, K comparable, V any](keys []A, values []B, mapper func(A, B) (K, V, bool)) (map[K]V, error) {
+ m := map[K]V{}
+ if len(keys) != len(values) {
+ return nil, fmt.Errorf("different length for slices")
+ }
+ for i := range keys {
+ if k, v, b := mapper(keys[i], values[i]); b {
+ m[k] = v
+ }
+ }
+ return m, nil
+}
+
+func First[T any](values []T, predicate func(T) bool) (T, bool) {
+ for _, value := range values {
+ if predicate(value) {
+ return value, true
+ }
+ }
+ var zero T
+ return zero, false
+}
diff --git a/services/groupware/QueryPagination.md b/services/groupware/QueryPagination.md
new file mode 100644
index 0000000000..d65a203046
--- /dev/null
+++ b/services/groupware/QueryPagination.md
@@ -0,0 +1,247 @@
+# Query Pagination
+
+## Single JMAP Backend
+
+Paginating queries, or just plain lists of objects without query filters
+(that end up being queries as well, since those are the only JMAP operations that allow
+specifying an offset ("position") and limit), are fairly simple to deal with if we are
+only considering delegating those to a single JMAP backend, such as Stalwart.
+
+In those cases, we can either make use of
+
+* `position` and `limit`,
+* or `anchor` and `anchorOffset`
+
+Considering the following list of elements:
+
+| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
+|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
+| `a` | `b` | `c` | `d` | `e` | `f` | `g` | `h` |
+
+### Using `position` and `limit`
+
+The first option simply uses a numeric offset, e.g.
+
+| Page | `position` | `limit` | results |
+|------|------------|---------|---------------|
+| 1 | `0` | `3` | `a`, `b`, `c` |
+| 2 | `3` | `3` | `d`, `e`, `f` |
+| 3 | `6` | `3` | `g`, `h` |
+
+### Using `anchor` and `anchorOffset`
+
+While the second references the unique identifier of the first element to return, with an offset against that element, e.g.:
+
+| Page | `anchor` | `anchorOffset` | `limit` | results |
+|------|----------|----------------|---------|---------------|
+| 1 | | | `3` | `a`, `b`, `c` |
+| 2 | `c` | `1` | `3` | `d`, `e`, `f` |
+| 3 | `f` | `1` | `3` | `g`, `h` |
+
+## Non-JMAP Backends
+
+In case of multiple backends, not all will be JMAP, and they won't all support the
+same pagination mechanics either.
+
+Paginating over results that come from multiple sources is already a lot more complex
+in its own right, since it requires
+
+* performing the query over each backend
+* merging the results into a single collection
+* ordering them in memory
+* capping the results at the requested page size (`limit`)
+
+To illustrate this process, we have a JMAP backend that has the following elements:
+
+| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
+|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
+| `a` | `b` | `c` | `d` | `e` | `f` | `g` | `h` |
+
+and an OpenCloud backend that has these:
+
+| 0 | 1 | 2 | 3 | 4 | 5 |
+|:---:|:---:|:---:|:---:|:---:|:---:|
+| `U` | `V` | `W` | `X` | `Y` | `Z` |
+
+When we perform a query across those:
+
+| Page | `position` | `limit` | JMAP results | OC results | ordered | capped |
+| --- | --- | --- | --- | --- | --- | --- |
+| 1 | `0` | `3` | `a`, `b`, `c` | `U`, `V`, `W` | `a`, `b`, `U`, `c`, `V`, `W` | `a`, `b`, `U` |
+| 2 | `3` | `3` | `d`, `e`, `f` | `X`, `Y`, `Z` | `d`, `X`, `Y`, `e`, `f`, `Z` | `d`, `X`, `Y` |
+
+:thinking: wait a minute...
+
+If we just naively apply this algorithm, we will skip and lose results (in this example, where have `c`, `V`, `W` gone?).
+
+The thus cannot use the `position` and `limit` "globally" as parameters for queries for each backend.
+
+The approach using `anchor` and `anchorOffset` seems a lot more promising, although it comes with a limitation: it's only ever possible to go to the next page, one by one, and not to jump to e.g. page 5 or page 10 at once.
+
+| Page | `anchor` | `offset` | `limit` | JMAP results | OC results | ordered | capped |
+| --- | --- | --- | --- | --- | --- | --- | --- |
+| 1 | | | `3` | `a`, `b`, `c` | `U`, `V`, `W` | `a`, `b`, `U`, `c`, `V`, `W` | `a`, `b`, `U` |
+| 2 | `b`, `U` | `1`, `1` | `3` | `c`, `d`, `e` | `V`, `W`, `X` | `c`, `V`, `W`, `d`, `e`, `X` | `c`, `V`, `W` |
+| 3 | `c`, `W` | `1`, `1` | `3` | `d`, `e`, `f` | `X`, `Y`, `Z` | `d`, `e`, `X`, `Y`, `f`, `Z` | `d`, `e`, `X` |
+| 4 | `e`, `X` | `1`, `1` | `3` | `f` | `Y`, `Z` | `Y`, `f`, `Z` | `Y`, `f`, `Z` |
+
+Note that, as is apparent in the example above, we cannot simply have *one* anchor but, instead, require having *one anchor per backend* -- in this case, one for the JMAP backend, and one for the OpenCloud backend, in order to perform the next query for each of them using the correct and respective anchor.
+
+In order to conceal that complexity as well as the fact that there are multiple backends in the first place, the best (and possibly only) approach is to use an opaque object that needs to be passed on for the "next page".
+
+A simple and straightforward approach could be to just encode a JSON object that contains per-backend information using base64, e.g.:
+
+```json
+{
+ "jmap": {
+ "anchor": "b",
+ "anchorOffset": 1
+ },
+ "oc": {
+ "anchor": "U",
+ "anchorOffset": 1
+ }
+}
+```
+
+When thrown into a base64 encoder, this results in:
+
+```text
+eyJqbWFwIjp7ImFuY2hvciI6ImIiLCJhbmNob3JPZmZzZXQiOjF9LCJvYyI6eyJhbmNob3IiOiJVIiwiYW5jaG9yT2Zmc2V0IjoxfX0K
+```
+
+A more optimized and compact object like this:
+
+```json
+{
+ "jmap": "b",
+ "oc": "U"
+}
+```
+
+would result in a smaller base64'd opaque object:
+
+```text
+eyJqbWFwIjoiYiIsIm9jIjoiVSJ9Cg==
+```
+
+Encoding into base64 is not going to make that opaque object any smaller, to the contrary, but it will make it somewhat opaque to avoid conveying the idea that a client should be able to play around with it.
+
+If we want to aim for compactness, a custom encoding would be most efficient, since even JSON is unnecessarily verbose as the only thing we actually want to keep track of is the `anchorOffset` for each backend.
+
+Something like this would indeed be smaller, but not very opaque:
+
+```text
+jmap:b,oc:U
+```
+
+This approach can also handle with backends that don't all support the same pagination mechanisms: if, say, the OpenCloud backend is unable to use `anchor` and `anchorOffset` but only supports `position` and `limit` instead.
+
+In that case, the opaque "next page" object could look like this:
+
+```text
+jmap:b,oc:2
+```
+
+And could be made opaque and URL safe using [base62 encoding](https://en.wikipedia.org/wiki/Base62):
+
+```text
+R2Wgx8cUhsw2lc
+```
+
+But in order to future-proof the format and allow more flexibility/complexity for each backend to store information that is relevant to whatever it needs to perform the pagination, JSON and base62 are the combination of choice:
+
+```json
+{"jmap":"b","oc":2}
+```
+
+Results in the following opaque object:
+
+```text
+1bt1nSpH79BZ7ZI6TK1PH1MrX
+```
+
+## API
+
+In order to present a consistent REST API for all paginated endpoints, we should not differentiate between ones that have a single backend and those that have multiple ones, as that might also evolve over time, requiring API changes.
+
+The common denominator is a more restricted API that allows for two operations:
+
+* query the initial page with a given page size (limit): `/groupware/accounts/a/things?first=50`
+* query the next page: `/groupware/accounts/a/things?next=...`
+* query the next page: `/groupware/accounts/a/things?next=...`
+* etc...
+
+It should also be possible to query the first *zero* items in order to only calculate the total amount of items: `/groupware/accounts/a/things?first=0`
+
+The same approach is also applicable to operations that perform queries across multiple accounts, as they pose the same issue as ones that perform queries across multiple backends.
+
+* query the initial page: `/groupware/accounts/all/emails?first=50`
+* query the next page: `/groupware/accounts/all/emails?next=...`
+
+It is up to the endpoint to encode the "next" token appropriately, depending on the use-case:
+
+* single account, single backend:
+
+```json
+{"p": 50, "l": 50}
+```
+
+* multiple accounts, single backend:
+
+```json
+{
+ "a1": {
+ "a": "naijuh7u", "o": 1, "l": 50
+ },
+ "a2": {
+ "a": "isaicoi0", "o": 1, "l": 50
+ }
+}
+```
+
+* single account, multiple backends:
+
+```json
+{
+ "jmap": {
+ "a1": {
+ "a": "naijuh7u", "o": 1, "l": 50
+ }
+ },
+ "oc": {
+ "a1": {
+ "a": "oc:12291"
+ }
+ }
+}
+```
+
+* multiple accounts, multiple backends:
+
+```json
+{
+ "jmap": {
+ "a1": {
+ "a": "naijuh7u", "o": 1, "l": 50
+ },
+ "a2": {
+ "a": "isaicoi0", "o": 1, "l": 50
+ }
+ },
+ "oc": {
+ "a1": {
+ "a": "oc:12291"
+ },
+ "a2": {
+ "a": "oc:8182"
+ }
+ }
+}
+```
+
+The alternative would be to have different APIs depending on what each endpoint supports.
+
+* endpoint with multiple backends or multiple accounts:
same as above with `?first=...` and `?next=...`
+* endpoint with a single backend and a single account:
`?position=...&anchor=...&offset=...&limit=...`
+
diff --git a/services/groupware/pkg/config/defaults/defaultconfig.go b/services/groupware/pkg/config/defaults/defaultconfig.go
index 03ce93c693..b91bb1c943 100644
--- a/services/groupware/pkg/config/defaults/defaultconfig.go
+++ b/services/groupware/pkg/config/defaults/defaultconfig.go
@@ -52,6 +52,7 @@ func DefaultConfig() *config.Config {
AllowedHeaders: []string{"Authorization", "Origin", "Content-Type", "Accept", "X-Requested-With", "X-Request-Id", "Trace-Id", "Cache-Control"},
AllowCredentials: true,
},
+ OpenCloudPublicURL: "https://localhost:9200/",
},
Service: config.Service{
Name: "groupware",
diff --git a/services/groupware/pkg/config/http.go b/services/groupware/pkg/config/http.go
index 2907f26601..312cffc677 100644
--- a/services/groupware/pkg/config/http.go
+++ b/services/groupware/pkg/config/http.go
@@ -12,9 +12,10 @@ type CORS struct {
// HTTP defines the available http configuration.
type HTTP struct {
- Addr string `yaml:"addr" env:"GROUPWARE_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
- TLS shared.HTTPServiceTLS `yaml:"tls"`
- Root string `yaml:"root" env:"GROUPWARE_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
- Namespace string `yaml:"-"`
- CORS CORS `yaml:"cors"`
+ Addr string `yaml:"addr" env:"GROUPWARE_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
+ TLS shared.HTTPServiceTLS `yaml:"tls"`
+ Root string `yaml:"root" env:"GROUPWARE_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
+ Namespace string `yaml:"-"`
+ CORS CORS `yaml:"cors"`
+ OpenCloudPublicURL string `yaml:"opencloud_public_url" env:"OC_URL;OC_PUBLIC_URL;GROUPWARE_PUBLIC_URL"`
}
diff --git a/services/groupware/pkg/groupware/address_book_supplier_jmap.go b/services/groupware/pkg/groupware/address_book_supplier_jmap.go
new file mode 100644
index 0000000000..25e1d962f4
--- /dev/null
+++ b/services/groupware/pkg/groupware/address_book_supplier_jmap.go
@@ -0,0 +1,35 @@
+package groupware
+
+import (
+ "strings"
+
+ "github.com/opencloud-eu/opencloud/pkg/jmap"
+)
+
+type JmapContactCardSupplier struct {
+ client *jmap.Client
+}
+
+var _ ListSupplier[jmap.AddressBook, jmap.AddressBookGetResponse] = &JmapContactCardSupplier{}
+var _ QuerySupplier[jmap.ContactCard, *jmap.ContactCardSearchResults, jmap.ContactCardFilterElement, jmap.ContactCardComparator] = &JmapContactCardSupplier{}
+
+func newJmapContactCardSupplier(client *jmap.Client) *JmapContactCardSupplier {
+ return &JmapContactCardSupplier{client: client}
+}
+
+func (c *JmapContactCardSupplier) GetId() string {
+ return "jmap"
+}
+func (c *JmapContactCardSupplier) IsMine(id string) bool {
+ return id != "" && !strings.Contains(id, ":")
+}
+func (c *JmapContactCardSupplier) GetAll(accountId string, ids []string, ctx jmap.Context) (jmap.Result[jmap.AddressBookGetResponse], error) {
+ return c.client.GetAddressbooks(accountId, ids, ctx)
+}
+func (c *JmapContactCardSupplier) Query(accountIds []string, qps QueryParamsSupplier, limit *uint, filter jmap.ContactCardFilterElement, sortBy []jmap.ContactCardComparator, calculateTotal bool, ctx jmap.Context) (jmap.Result[map[string]*jmap.ContactCardSearchResults], error) { //NOSONAR
+ if m, err := mapQueryParams(c.GetId(), accountIds, qps); err != nil {
+ return jmap.ZeroResult[map[string]*jmap.ContactCardSearchResults](), err
+ } else {
+ return c.client.QueryContactCards(m, limit, filter, sortBy, calculateTotal, ctx)
+ }
+}
diff --git a/services/groupware/pkg/groupware/address_book_supplier_mock.go b/services/groupware/pkg/groupware/address_book_supplier_mock.go
new file mode 100644
index 0000000000..ca8f9ea441
--- /dev/null
+++ b/services/groupware/pkg/groupware/address_book_supplier_mock.go
@@ -0,0 +1,150 @@
+package groupware
+
+import (
+ "slices"
+ "strings"
+
+ "github.com/opencloud-eu/opencloud/pkg/jmap"
+ "github.com/opencloud-eu/opencloud/pkg/jscontact"
+ "github.com/opencloud-eu/opencloud/pkg/structs"
+)
+
+type MockContactCardSupplier struct {
+ addressBook jmap.AddressBook
+ contacts []jmap.ContactCard
+}
+
+var MockContactCardSupplierInstance *MockContactCardSupplier = &MockContactCardSupplier{
+ addressBook: jmap.AddressBook{
+ Id: "mock:1",
+ Name: "Automatic Addressbook",
+ Description: "Users",
+ IsDefault: false,
+ IsSubscribed: true,
+ MyRights: jmap.AddressBookRights{
+ MayRead: true,
+ MayWrite: false,
+ MayAdmin: false,
+ MayDelete: false,
+ },
+ },
+ contacts: []jmap.ContactCard{
+ {
+ Id: "alan",
+ AddressBookIds: map[string]bool{"mock:1": true},
+ Type: jscontact.ContactCardType,
+ Version: jmap.DEFAULT_CONTACT_CARD_VERSION,
+ Created: mustParseTime("2026-05-26T10:21:00.000Z"),
+ Kind: jscontact.ContactCardKindIndividual,
+ ProdId: "OC",
+ Uid: "dc2858d2-4826-412d-afc9-c4492f8f84bc",
+ Updated: mustParseTime("2026-05-26T10:21:00.000Z"),
+ Name: &jscontact.Name{
+ Type: jscontact.NameType,
+ Components: []jscontact.NameComponent{
+ {Kind: jscontact.NameComponentKindGiven, Value: "Alan"},
+ {Kind: jscontact.NameComponentKindSurname, Value: "Turing"},
+ },
+ DefaultSeparator: " ",
+ IsOrdered: true,
+ },
+ Emails: map[string]jscontact.EmailAddress{
+ "eedujae1": {
+ Address: "alan@example.com",
+ },
+ },
+ },
+ },
+}
+
+var _ ListSupplier[jmap.AddressBook, jmap.AddressBookGetResponse] = &MockContactCardSupplier{}
+var _ QuerySupplier[jmap.ContactCard, *jmap.ContactCardSearchResults, jmap.ContactCardFilterElement, jmap.ContactCardComparator] = &MockContactCardSupplier{}
+
+func newMockContactCardSupplier() *MockContactCardSupplier {
+ return MockContactCardSupplierInstance
+}
+
+func (c *MockContactCardSupplier) GetId() string {
+ return "mock"
+}
+func (c *MockContactCardSupplier) IsMine(id string) bool {
+ return strings.HasPrefix(id, "mock:")
+}
+func (c *MockContactCardSupplier) GetAll(accountId string, ids []string, ctx jmap.Context) (jmap.Result[jmap.AddressBookGetResponse], error) {
+ abooks := []jmap.AddressBook{c.addressBook}
+ if len(ids) > 0 {
+ abooks = structs.Filter(abooks, func(a jmap.AddressBook) bool { return slices.Contains(ids, a.Id) })
+ }
+ return jmap.Result[jmap.AddressBookGetResponse]{
+ SessionState: jmap.EmptySessionState,
+ State: "mock",
+ Language: jmap.NoLanguage,
+ Payload: jmap.AddressBookGetResponse{
+ AccountId: accountId,
+ State: "mock",
+ List: abooks,
+ },
+ }, nil
+}
+func (c *MockContactCardSupplier) Query(accountIds []string, qps QueryParamsSupplier, limit *uint, filter jmap.ContactCardFilterElement, sortBy []jmap.ContactCardComparator, calculateTotal bool, ctx jmap.Context) (jmap.Result[map[string]*jmap.ContactCardSearchResults], error) { //NOSONAR
+ payload := make(map[string]*jmap.ContactCardSearchResults, len(accountIds))
+ total := len(c.contacts)
+ for _, accountId := range accountIds {
+ all := []jmap.ContactCard{}
+ var qp jmap.QueryParams
+ if q, ok, err := qps.ForSupplier(c.GetId(), accountId); err != nil {
+ return jmap.ZeroResult[map[string]*jmap.ContactCardSearchResults](), err
+ } else if ok {
+ qp = q
+ } else {
+ qp = jmap.NullQueryParams
+ }
+
+ p := uint(qp.Position)
+ if qp.Position < total {
+ all = c.contacts[qp.Position:]
+ }
+ if qp.Anchor != "" {
+ a := slices.IndexFunc(all, func(e jmap.ContactCard) bool { return e.Id == qp.Anchor })
+ if a >= 0 {
+ if qp.AnchorOffset != nil {
+ a += int(*qp.AnchorOffset)
+ } else {
+ a += 1
+ }
+ p += uint(a)
+ if a < len(all) {
+ all = all[a:]
+ } else {
+ all = []jmap.ContactCard{}
+ }
+ } else {
+ all = []jmap.ContactCard{}
+ }
+ }
+ if limit != nil {
+ if len(all) > int(*limit) {
+ all = all[:int(*limit)]
+ }
+ }
+
+ res := &jmap.ContactCardSearchResults{
+ Results: all,
+ CanCalculateChanges: false,
+ Position: &p,
+ Limit: limit,
+ }
+ if calculateTotal {
+ t := uint(total)
+ res.Total = &t
+ }
+ payload[accountId] = res
+ }
+
+ return jmap.Result[map[string]*jmap.ContactCardSearchResults]{
+ Payload: payload,
+ SessionState: jmap.EmptySessionState,
+ State: jmap.EmptyState,
+ Language: jmap.NoLanguage,
+ }, nil
+}
diff --git a/services/groupware/pkg/groupware/api_addressbooks.go b/services/groupware/pkg/groupware/api_addressbooks.go
index 98a1cf0875..38f9a880d3 100644
--- a/services/groupware/pkg/groupware/api_addressbooks.go
+++ b/services/groupware/pkg/groupware/api_addressbooks.go
@@ -2,16 +2,18 @@ package groupware
import (
"net/http"
+
+ "github.com/opencloud-eu/opencloud/pkg/jmap"
)
// Get all addressbooks of an account.
func (g *Groupware) GetAddressbooks(w http.ResponseWriter, r *http.Request) {
- getall(AddressBook, w, r, g, g.jmap.GetAddressbooks)
+ getall(AddressBook, w, r, g, g.addressbooks)
}
// Get an addressbook of an account by its identifier.
func (g *Groupware) GetAddressbookById(w http.ResponseWriter, r *http.Request) {
- get(AddressBook, w, r, g, g.jmap.GetAddressbooks)
+ get(AddressBook, w, r, g, g.addressbooks)
}
// Get the changes to Address Books since a certain State.
@@ -31,3 +33,9 @@ func (g *Groupware) DeleteAddressBook(w http.ResponseWriter, r *http.Request) {
func (g *Groupware) ModifyAddressBook(w http.ResponseWriter, r *http.Request) {
modify(AddressBook, w, r, g, g.jmap.UpdateAddressBook)
}
+
+func (g *Groupware) addressbooks(accountId string, ids []string, ctx jmap.Context) (jmap.Result[jmap.AddressBookGetResponse], error) {
+ return slist(g.addressBookListSuppliers, accountId, ids, ctx, func(accountId string, state jmap.State, notFound []string, list []jmap.AddressBook) jmap.AddressBookGetResponse {
+ return jmap.AddressBookGetResponse{AccountId: accountId, State: state, NotFound: notFound, List: list}
+ })
+}
diff --git a/services/groupware/pkg/groupware/api_blob.go b/services/groupware/pkg/groupware/api_blob.go
index d613850c4c..8d33ab78ee 100644
--- a/services/groupware/pkg/groupware/api_blob.go
+++ b/services/groupware/pkg/groupware/api_blob.go
@@ -47,29 +47,34 @@ func (g *Groupware) UploadBlob(w http.ResponseWriter, r *http.Request) {
// Download a BLOB by its identifier.
func (g *Groupware) DownloadBlob(w http.ResponseWriter, r *http.Request) {
- g.stream(w, r, func(req Request, w http.ResponseWriter) *Error {
+ g.stream(w, r, func(req Request, w http.ResponseWriter) (bool, Response) {
+ ok, accountId, resp := req.needBloblWithAccount()
+ if !ok {
+ return false, resp
+ }
+
blobId, err := req.PathParam(UriParamBlobId) // the unique identifier of the blob to download
if err != nil {
- return err
+ return false, req.error(accountId, err)
}
name, err := req.PathParam(UriParamBlobName) // the filename of the blob to download, which is then used in the response and may be arbitrary if unknown
if err != nil {
- return err
+ return false, req.error(accountId, err)
}
typ, _ := req.getStringParam(QueryParamBlobType, "") // optionally, the Content-Type of the blob, which is then used in the response
- accountId, gwerr := req.GetAccountIdForBlob()
- if gwerr != nil {
- return gwerr
- }
logger := log.From(req.logger.With().Str(logAccountId, accountId).Str(UriParamBlobId, blobId))
ctx := req.ctx.WithLogger(logger)
- return req.serveBlob(blobId, name, typ, ctx, accountId, w)
+ if err := req.serveBlob(blobId, name, typ, ctx, accountId, w); err != nil {
+ return false, req.error(accountId, err)
+ } else {
+ return true, req.noop(accountId)
+ }
})
}
-func (r *Request) serveBlob(blobId string, name string, typ string, ctx jmap.Context, accountId string, w http.ResponseWriter) *Error {
+func (r *Request) serveBlob(blobId string, name string, typ string, ctx jmap.Context, accountId string, w http.ResponseWriter) *Error { //NOSONAR
if typ == "" {
typ = DefaultBlobDownloadType
}
@@ -83,7 +88,12 @@ func (r *Request) serveBlob(blobId string, name string, typ string, ctx jmap.Con
}(blob.Body)
}
if jerr != nil {
- return r.apiErrorFromJmap(jerr)
+ switch e := jerr.(type) {
+ case jmap.Error:
+ return r.apiErrorFromJmap(e)
+ default:
+ return apiError(r.errorId(), ErrorGeneric, withDetail(e.Error()))
+ }
}
if blob == nil {
w.WriteHeader(http.StatusNotFound)
diff --git a/services/groupware/pkg/groupware/api_contacts.go b/services/groupware/pkg/groupware/api_contacts.go
index d3d4c22b30..e5ee8f2906 100644
--- a/services/groupware/pkg/groupware/api_contacts.go
+++ b/services/groupware/pkg/groupware/api_contacts.go
@@ -45,66 +45,9 @@ func (g *Groupware) GetContactsInAddressbook(w http.ResponseWriter, r *http.Requ
func(addressbookId string) jmap.ContactCardFilterElement {
return jmap.ContactCardFilterCondition{InAddressBook: addressbookId}
},
- []jmap.ContactCardComparator{{Property: jmap.ContactCardPropertyUpdated, IsAscending: true}},
- curryMapQuery(g.jmap.QueryContactCards),
+ []jmap.ContactCardComparator{{Property: jmap.ContactCardPropertyCreated, IsAscending: true}},
+ curryQueryFunc(g.contacts),
)
-
- /*
- g.respond(w, r, func(req Request) Response {
- ok, accountId, resp := req.needContactWithAccount()
- if !ok {
- return resp
- }
- accountIds := single(accountId)
-
- l := req.logger.With()
-
- addressBookId, err := req.PathParam(UriParamAddressBookId)
- if err != nil {
- return req.errorN(accountIds, err)
- }
- l = l.Str(UriParamAddressBookId, log.SafeString(addressBookId))
-
- position, ok, err := req.parseIntParam(QueryParamPosition, 0)
- if err != nil {
- return req.errorN(accountIds, err)
- }
- if ok {
- l = l.Int(QueryParamPosition, position)
- }
-
- limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaults.contactLimit)
- if err != nil {
- return req.errorN(accountIds, err)
- }
- if ok {
- l = l.Uint(QueryParamLimit, limit)
- }
-
- filter := jmap.ContactCardFilterCondition{
- InAddressBook: addressBookId,
- }
- var sortBy []jmap.ContactCardComparator
- if sort, ok, resp := mapSort(accountIds, &req, DefaultContactSort, SupportedContactSortingProperties, mapContactCardSort); !ok {
- return resp
- } else {
- sortBy = sort
- }
-
- logger := log.From(l)
- ctx := req.ctx.WithLogger(logger)
- contactsByAccountId, sessionState, state, lang, jerr := g.jmap.QueryContactCards(accountIds, filter, sortBy, position, limit, true, ctx)
- if jerr != nil {
- return req.jmapErrorN(accountIds, jerr, sessionState, lang)
- }
-
- if contacts, ok := contactsByAccountId[accountId]; ok {
- return req.respondN(accountIds, contacts, sessionState, ContactResponseObjectType, state, lang)
- } else {
- return req.notFoundN(accountIds, sessionState, ContactResponseObjectType, state)
- }
- })
- */
}
func (g *Groupware) GetContactById(w http.ResponseWriter, r *http.Request) {
@@ -116,8 +59,8 @@ func (g *Groupware) GetAllContacts(w http.ResponseWriter, r *http.Request) {
func(_ string) jmap.ContactCardFilterElement {
return jmap.ContactCardFilterCondition{}
},
- []jmap.ContactCardComparator{{Property: jmap.ContactCardPropertyUpdated, IsAscending: true}},
- curryMapQuery(g.jmap.QueryContactCards),
+ []jmap.ContactCardComparator{{Property: jmap.ContactCardPropertyCreated, IsAscending: true}},
+ curryQueryFunc(g.contacts),
)
}
@@ -138,3 +81,20 @@ func (g *Groupware) DeleteContact(w http.ResponseWriter, r *http.Request) {
func (g *Groupware) ModifyContact(w http.ResponseWriter, r *http.Request) {
modify(Contact, w, r, g, g.jmap.UpdateContactCard)
}
+
+func (g *Groupware) contacts(accountIds []string, qps QueryParamsSupplier, limit *uint, //NOSONAR
+ filter jmap.ContactCardFilterElement, sortBy []jmap.ContactCardComparator, calculateTotal bool,
+ ctx jmap.Context) (jmap.Result[*jmap.ContactCardSearchResults], NextToken, error) {
+ return squery(g.contactCardQuerySuppliers, accountIds, qps, limit, filter, sortBy, calculateTotal, ctx,
+ func(a, b jmap.ContactCard) int { return a.Created.Compare(b.Created) },
+ func(canCalculateChanges jmap.ChangeCalculation, position, limit, total *uint, results []jmap.ContactCard) *jmap.ContactCardSearchResults {
+ return &jmap.ContactCardSearchResults{
+ Results: results,
+ CanCalculateChanges: canCalculateChanges,
+ Position: position,
+ Limit: limit,
+ Total: total,
+ }
+ },
+ )
+}
diff --git a/services/groupware/pkg/groupware/api_emails.go b/services/groupware/pkg/groupware/api_emails.go
index 5b2f3c22bc..9e6f6d5eac 100644
--- a/services/groupware/pkg/groupware/api_emails.go
+++ b/services/groupware/pkg/groupware/api_emails.go
@@ -24,7 +24,7 @@ import (
// Get the changes tp Emails since a certain State.
// @api:tags email,changes
func (g *Groupware) GetEmailChanges(w http.ResponseWriter, r *http.Request) {
- changes(Email, w, r, g, func(accountId string, sinceState jmap.State, maxChanges uint, ctx jmap.Context) (jmap.Result[jmap.EmailChanges], jmap.Error) {
+ changes(Email, w, r, g, func(accountId string, sinceState jmap.State, maxChanges uint, ctx jmap.Context) (jmap.Result[jmap.EmailChanges], error) {
return g.jmap.GetEmailChanges(accountId, sinceState, true, g.config.maxBodyValueBytes, maxChanges, ctx)
})
}
@@ -42,26 +42,24 @@ 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, anchor string, anchorOffset *int, limit *uint, ctx jmap.Context) (jmap.Result[*jmap.EmailSearchResults], *Error) { //NOSONAR
- result, jerr := g.jmap.GetAllEmailsInMailbox(accountId, containerId, position, anchor, anchorOffset, limit, collapseThreads, fetchBodies, g.config.maxBodyValueBytes, withThreads, ctx)
+ func(req Request, accountId string, containerId string, qp jmap.QueryParams, limit *uint, ctx jmap.Context) (jmap.Result[*jmap.EmailSearchResults], error) { //NOSONAR
+ result, jerr := g.jmap.GetAllEmailsInMailbox(accountId, containerId, qp, limit, collapseThreads, fetchBodies, g.config.maxBodyValueBytes, withThreads, ctx)
if jerr != nil {
- return jmap.ZeroResult[*jmap.EmailSearchResults](), req.apiErrorFromJmap(req.observeJmapError(jerr))
+ return jmap.ZeroResult[*jmap.EmailSearchResults](), jerr
}
-
sanitized, err := req.sanitizeEmails(result.Payload.Results)
if err != nil {
return jmap.ZeroResult[*jmap.EmailSearchResults](), err
}
-
- return jmap.RefineResult(result, func(orig *jmap.EmailSearchResults) *jmap.EmailSearchResults {
+ return jmap.RefineResultPayload(result, func(orig *jmap.EmailSearchResults) (*jmap.EmailSearchResults, bool, error) {
return &jmap.EmailSearchResults{
Results: sanitized,
Total: orig.Total,
Limit: orig.Limit,
Position: orig.Position,
CanCalculateChanges: orig.CanCalculateChanges,
- }
- }), nil
+ }, true, nil
+ })
},
)
}
@@ -69,45 +67,49 @@ func (g *Groupware) GetAllEmailsInMailbox(w http.ResponseWriter, r *http.Request
func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) { //NOSONAR
accept := r.Header.Get("Accept")
if accept == "message/rfc822" {
- g.stream(w, r, func(req Request, w http.ResponseWriter) *Error {
+ g.stream(w, r, func(req Request, w http.ResponseWriter) (bool, Response) {
+ accountId, err := req.GetAccountIdForMail()
+ if err != nil {
+ return false, req.error(accountId, err)
+ }
+
id, err := req.PathParam(UriParamEmailId)
if err != nil {
- return err
+ return false, req.error(accountId, err)
}
ids := strings.Split(id, ",")
if len(ids) != 1 {
- return req.parameterError(UriParamEmailId, fmt.Sprintf("when the Accept header is set to '%s', the API only supports serving a single email id", accept))
- }
-
- accountId, err := req.GetAccountIdForMail()
- if err != nil {
- return err
+ return false, req.parameterErrorResponse(accountId, UriParamEmailId, fmt.Sprintf("when the Accept header is set to '%s', the API only supports serving a single email id", accept))
}
_, ok, err := req.parseBoolParam(QueryParamMarkAsSeen, false)
if err != nil {
- return err
+ return false, req.error(accountId, err)
}
if ok {
- return req.parameterError(QueryParamMarkAsSeen, fmt.Sprintf("when the Accept header is set to '%s', the API does not support setting %s", accept, QueryParamMarkAsSeen))
+ return false, req.parameterErrorResponse(accountId, QueryParamMarkAsSeen, fmt.Sprintf("when the Accept header is set to '%s', the API does not support setting %s", accept, QueryParamMarkAsSeen))
}
logger := log.From(req.logger.With().Str(logAccountId, log.SafeString(accountId)).Str("id", log.SafeString(id)).Str("accept", log.SafeString(accept)))
ctx := req.ctx.WithLogger(logger)
result, jerr := g.jmap.GetEmailBlobId(accountId, id, ctx)
if jerr != nil {
- return req.apiErrorFromJmap(req.observeJmapError(jerr))
+ return false, req.jmapError(accountId, jerr, result)
}
if result.Payload == "" {
- return nil
+ return true, req.noop(accountId)
} else {
name := result.Payload + ".eml"
typ := accept
accountId, gwerr := req.GetAccountIdForBlob()
if gwerr != nil {
- return gwerr
+ return false, req.error(accountId, gwerr)
+ }
+ if err := req.serveBlob(result.Payload, name, typ, ctx, accountId, w); err != nil {
+ return false, req.error(accountId, err)
+ } else {
+ return true, req.noop(accountId)
}
- return req.serveBlob(result.Payload, name, typ, ctx, accountId, w)
}
})
} else {
@@ -124,7 +126,7 @@ func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) { //NO
}
ids := strings.Split(id, ",")
if len(ids) < 1 {
- return req.parameterErrorResponse(single(accountId), UriParamEmailId, fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamEmailId, log.SafeString(id), "empty list of mail ids"))
+ return req.parameterErrorResponse(accountId, UriParamEmailId, fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamEmailId, log.SafeString(id), "empty list of mail ids"))
}
markAsSeen, ok, err := req.parseBoolParam(QueryParamMarkAsSeen, false)
@@ -147,7 +149,7 @@ func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) { //NO
} else {
sanitized, err := req.sanitizeEmail(result.Payload.List[0])
if err != nil {
- return req.error(accountId, err)
+ return req.error(accountId, req.apiError(err))
}
return req.respond(accountId, sanitized, EmailResponseObjectType, result)
}
@@ -163,7 +165,7 @@ func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) { //NO
} else {
sanitized, err := req.sanitizeEmails(result.Payload.List)
if err != nil {
- return req.error(accountId, err)
+ return req.error(accountId, req.apiError(err))
}
return req.respond(accountId, sanitized, EmailResponseObjectType, result)
}
@@ -199,9 +201,9 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request)
if attachmentSelector == nil {
g.respond(w, r, func(req Request) Response {
- accountId, err := req.GetAccountIdForMail()
- if err != nil {
- return req.error(accountId, err)
+ ok, accountId, resp := req.needMailWithAccount()
+ if !ok {
+ return resp
}
l := req.logger.With().Str(logAccountId, log.SafeString(accountId))
@@ -219,27 +221,27 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request)
if len(result.Payload.List) < 1 {
return req.notFound(accountId, EmailResponseObjectType, result)
}
- email, err := req.sanitizeEmail(result.Payload.List[0])
- if err != nil {
- return req.error(accountId, err)
+ if email, err := req.sanitizeEmail(result.Payload.List[0]); err != nil {
+ return req.error(accountId, req.apiError(err))
+ } else {
+ var body []jmap.EmailBodyPart = email.Attachments
+ return req.respond(accountId, body, EmailResponseObjectType, result)
}
- var body []jmap.EmailBodyPart = email.Attachments
- return req.respond(accountId, body, EmailResponseObjectType, result)
})
} else {
- g.stream(w, r, func(req Request, w http.ResponseWriter) *Error {
- mailAccountId, err := req.GetAccountIdForMail()
- if err != nil {
- return err
+ g.stream(w, r, func(req Request, w http.ResponseWriter) (bool, Response) {
+ ok, mailAccountId, resp := req.needMailWithAccount()
+ if !ok {
+ return false, resp
}
- blobAccountId, err := req.GetAccountIdForBlob()
- if err != nil {
- return err
+ ok, blobAccountId, resp := req.needBloblWithAccount()
+ if !ok {
+ return false, resp
}
id, err := req.PathParam(UriParamEmailId)
if err != nil {
- return err
+ return false, req.error(mailAccountId, err)
}
l := req.logger.With().
@@ -251,15 +253,15 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request)
ctx := req.ctx.WithLogger(logger)
result, jerr := g.jmap.GetEmails(mailAccountId, single(id), false, 0, false, false, ctx)
if jerr != nil {
- return req.apiErrorFromJmap(req.observeJmapError(jerr))
+ return false, req.jmapError(mailAccountId, jerr, result)
}
if len(result.Payload.List) < 1 {
- return nil
+ return true, req.noop(mailAccountId)
}
- email, err := req.sanitizeEmail(result.Payload.List[0])
- if err != nil {
- return err
+ email, gwerr := req.sanitizeEmail(result.Payload.List[0])
+ if gwerr != nil {
+ return false, req.error(mailAccountId, req.apiError(gwerr))
}
var attachment *jmap.EmailBodyPart = nil
for _, part := range email.Attachments {
@@ -269,7 +271,7 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request)
}
}
if attachment == nil {
- return nil
+ return true, req.noop(mailAccountId)
}
blob, lang, jerr := g.jmap.DownloadBlobStream(blobAccountId, attachment.BlobId, attachment.Name, attachment.Type, ctx)
@@ -282,11 +284,11 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request)
}(blob.Body)
}
if jerr != nil {
- return req.apiErrorFromJmap(jerr)
+ return false, req.jmapError(blobAccountId, jerr, nil)
}
if blob == nil {
w.WriteHeader(http.StatusNotFound)
- return nil
+ return true, req.noop(blobAccountId)
}
if blob.Type != "" {
@@ -306,10 +308,10 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request)
}
_, cerr := io.Copy(w, blob.Body)
if cerr != nil {
- return req.observedParameterError(ErrorStreamingResponse)
+ return false, req.error(blobAccountId, req.observedParameterError(ErrorStreamingResponse))
}
- return nil
+ return true, req.noop(blobAccountId)
})
}
}
@@ -413,13 +415,15 @@ func (g *Groupware) buildEmailFilter(req Request) (bool, jmap.EmailFilterElement
var limit *uint = nil
{
- v, ok, err := req.parseUIntParam(QueryParamLimit, g.defaults.emailLimit) // maximum number of results (size of a page)
+ v, ok, err := req.parseUIntParam(QueryParamLimit, uint(0)) // maximum number of results (size of a page)
if err != nil {
return false, nil, snippets, 0, "", nil, nil, nil, err
}
if ok {
l = l.Uint(QueryParamLimit, v)
limit = &v
+ } else {
+ limit = g.defaults.emailLimit
}
}
@@ -610,7 +614,7 @@ func (g *Groupware) GetEmails(w http.ResponseWriter, r *http.Request) { //NOSONA
}
sanitized, err := req.sanitizeEmail(result.Email)
if err != nil {
- return req.error(accountId, err)
+ return req.error(accountId, req.apiError(err))
}
flattened[i] = EmailWithSnippets{
Email: sanitized,
@@ -619,7 +623,7 @@ func (g *Groupware) GetEmails(w http.ResponseWriter, r *http.Request) { //NOSONA
}
}
- rlimit := &results.Limit
+ rlimit := results.Limit
if limit != nil && *limit == 0 {
rlimit = UintPtrZero
}
@@ -822,7 +826,7 @@ func (g *Groupware) CreateEmail(w http.ResponseWriter, r *http.Request) {
}
return true, Response{}
},
- func(accountId string, body jmap.EmailChange, ctx jmap.Context) (jmap.Result[*jmap.Email], jmap.Error) {
+ func(accountId string, body jmap.EmailChange, ctx jmap.Context) (jmap.Result[*jmap.Email], error) {
return g.jmap.CreateEmail(accountId, body, "", ctx)
},
)
@@ -848,7 +852,7 @@ func (g *Groupware) ReplaceEmail(w http.ResponseWriter, r *http.Request) {
return true, Response{}
},
- func(accountId string, body jmap.EmailChange, ctx jmap.Context) (jmap.Result[*jmap.Email], jmap.Error) {
+ func(accountId string, body jmap.EmailChange, ctx jmap.Context) (jmap.Result[*jmap.Email], error) {
ctx = ctx.WithLogger(log.From(ctx.Logger.With().Str("replaceId", replaceId)))
return g.jmap.CreateEmail(accountId, body, replaceId, ctx)
},
@@ -1255,11 +1259,13 @@ func (g *Groupware) RelatedToEmail(w http.ResponseWriter, r *http.Request) { //N
if results, ok := results.Payload[accountId]; ok {
duration := time.Since(before)
if jerr != nil {
- _ = req.observeJmapError(jerr)
+ if j, ok := jerr.(jmap.Error); ok {
+ _ = req.observeJmapError(j)
+ }
l.Error().Err(jerr).Msgf("failed to query %v emails", RelationTypeSameSender)
} else {
req.observe(g.metrics.EmailSameSenderDuration.WithLabelValues(req.session.JmapEndpoint), duration.Seconds())
- related, err := req.sanitizeEmails(filterEmails(results.Emails, email))
+ related, err := req.sanitizeEmails(filterEmails(results.Results, email))
if err == nil {
l.Trace().Msgf("'%v' found %v other emails", RelationTypeSameSender, len(related))
if len(related) > 0 {
@@ -1290,14 +1296,14 @@ func (g *Groupware) RelatedToEmail(w http.ResponseWriter, r *http.Request) { //N
}
})
- sanitized, err := req.sanitizeEmail(email)
- if err != nil {
- return req.error(accountId, err)
+ if sanitized, err := req.sanitizeEmail(email); err != nil {
+ return req.error(accountId, req.apiError(err))
+ } else {
+ return req.respond(accountId, AboutEmailResponse{
+ Email: sanitized,
+ RequestId: reqId,
+ }, EmailResponseObjectType, result)
}
- return req.respond(accountId, AboutEmailResponse{
- Email: sanitized,
- RequestId: reqId,
- }, EmailResponseObjectType, result)
})
}
@@ -1637,7 +1643,7 @@ var sanitizableMediaTypes = []string{
"text/xhtml",
}
-func (r *Request) sanitizeEmail(source jmap.Email) (jmap.Email, *Error) { //NOSONAR
+func (r *Request) sanitizeEmail(source jmap.Email) (jmap.Email, *GroupwareError) { //NOSONAR
if !r.g.config.sanitize {
return source, nil
}
@@ -1649,7 +1655,7 @@ func (r *Request) sanitizeEmail(source jmap.Email) (jmap.Email, *Error) { //NOSO
if err != nil {
msg := fmt.Sprintf("failed to parse the mime type '%s'", p.Type)
r.logger.Error().Str("type", log.SafeString(p.Type)).Msg(msg)
- return source, r.apiError(&ErrorFailedToSanitizeEmail, withDetail(msg))
+ return source, &ErrorFailedToSanitizeEmail
}
if slices.Contains(sanitizableMediaTypes, t) {
if already, done := memory[p.PartId]; !done {
@@ -1685,7 +1691,7 @@ func (r *Request) sanitizeEmail(source jmap.Email) (jmap.Email, *Error) { //NOSO
return source, nil
}
-func (r *Request) sanitizeEmails(source []jmap.Email) ([]jmap.Email, *Error) {
+func (r *Request) sanitizeEmails(source []jmap.Email) ([]jmap.Email, *GroupwareError) {
if !r.g.config.sanitize {
return source, nil
}
diff --git a/services/groupware/pkg/groupware/api_events.go b/services/groupware/pkg/groupware/api_events.go
index 4cf146a5b0..8467242d98 100644
--- a/services/groupware/pkg/groupware/api_events.go
+++ b/services/groupware/pkg/groupware/api_events.go
@@ -16,72 +16,20 @@ func (g *Groupware) GetEventsInCalendar(w http.ResponseWriter, r *http.Request)
return jmap.CalendarEventFilterCondition{InCalendar: calendarId}
},
[]jmap.CalendarEventComparator{{Property: jmap.CalendarEventPropertyStart, IsAscending: true}},
- curryMapQuery(g.jmap.QueryCalendarEvents),
+ curryNoNextMapQuery(
+ g.jmap.QueryCalendarEvents,
+ func(a, b jmap.CalendarEvent) int { return 0 }, // TODO
+ func(canCalculateChanges jmap.ChangeCalculation, position, limit, total *uint, results []jmap.CalendarEvent) *jmap.CalendarEventSearchResults {
+ return &jmap.CalendarEventSearchResults{
+ Results: results,
+ CanCalculateChanges: canCalculateChanges,
+ Position: position,
+ Limit: limit,
+ Total: total,
+ }
+ },
+ ),
)
-
- /*
- g.respond(w, r, func(req Request) Response {
- ok, accountId, resp := req.needCalendarWithAccount()
- if !ok {
- return resp
- }
-
- l := req.logger.With()
-
- calendarId, err := req.PathParam(UriParamCalendarId)
- if err != nil {
- return req.error(accountId, err)
- }
- l = l.Str(UriParamCalendarId, log.SafeString(calendarId))
-
- position, ok, err := req.parseIntParam(QueryParamPosition, 0)
- if err != nil {
- return req.error(accountId, err)
- }
- if ok {
- l = l.Int(QueryParamPosition, position)
- }
-
- limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaults.contactLimit)
- if err != nil {
- return req.error(accountId, err)
- }
- if ok {
- l = l.Uint(QueryParamLimit, limit)
- }
-
- filter := jmap.CalendarEventFilterCondition{
- InCalendar: calendarId,
- }
- sortBy := []jmap.CalendarEventComparator{{Property: jmap.CalendarEventPropertyStart, IsAscending: false}}
-
- logger := log.From(l)
- ctx := req.ctx.WithLogger(logger)
- eventsByAccountId, sessionState, state, lang, jerr := g.jmap.QueryCalendarEvents(single(accountId), filter, sortBy, position, limit, true, ctx)
- if jerr != nil {
- return req.jmapError(accountId, jerr, sessionState, lang)
- }
-
- if events, ok := eventsByAccountId[accountId]; ok {
- return req.respond(accountId, events, sessionState, EventResponseObjectType, state, lang)
- } else {
- return req.notFound(accountId, sessionState, EventResponseObjectType, state)
- }
- })
- */
-}
-
-func curryMapQuery[SRES jmap.SearchResults[T], T jmap.Foo, FILTER any, COMP any](
- f func(accountIds []string, filter FILTER, sortBy []COMP, position int, anchor string, anchorOffset *int, limit *uint, calculateTotal bool, ctx jmap.Context) (jmap.Result[map[string]SRES], jmap.Error),
-) func(req Request, accountId string, filter FILTER, sortBy []COMP, position int, anchor string, anchorOffset *int, limit *uint, ctx jmap.Context) (jmap.Result[SRES], jmap.Error) {
- return func(req Request, accountId string, filter FILTER, sortBy []COMP, position int, anchor string, anchorOffset *int, limit *uint, ctx jmap.Context) (jmap.Result[SRES], jmap.Error) { //NOSONAR
- result, err := f(single(accountId), filter, sortBy, position, anchor, anchorOffset, limit, true, ctx)
- if err != nil {
- return jmap.ZeroResult[SRES](), err
- } else {
- return jmap.RefineResult(result, func(m map[string]SRES) SRES { return m[accountId] }), err
- }
- }
}
func (g *Groupware) GetAllEvents(w http.ResponseWriter, r *http.Request) {
@@ -89,7 +37,19 @@ func (g *Groupware) GetAllEvents(w http.ResponseWriter, r *http.Request) {
false,
func(_ string) jmap.CalendarEventFilterElement { return jmap.CalendarEventFilterCondition{} },
[]jmap.CalendarEventComparator{{Property: jmap.CalendarEventPropertyStart, IsAscending: true}},
- curryMapQuery(g.jmap.QueryCalendarEvents),
+ curryNoNextMapQuery(
+ g.jmap.QueryCalendarEvents,
+ func(a, b jmap.CalendarEvent) int { return 0 }, // TODO
+ func(canCalculateChanges jmap.ChangeCalculation, position, limit, total *uint, results []jmap.CalendarEvent) *jmap.CalendarEventSearchResults {
+ return &jmap.CalendarEventSearchResults{
+ Results: results,
+ CanCalculateChanges: canCalculateChanges,
+ Position: position,
+ Limit: limit,
+ Total: total,
+ }
+ },
+ ),
)
}
diff --git a/services/groupware/pkg/groupware/api_quota.go b/services/groupware/pkg/groupware/api_quota.go
index 4d07e53ec2..a751a71b87 100644
--- a/services/groupware/pkg/groupware/api_quota.go
+++ b/services/groupware/pkg/groupware/api_quota.go
@@ -13,7 +13,7 @@ import (
//
// Note that there may be multiple Quota objects for different resource types.
func (g *Groupware) GetQuota(w http.ResponseWriter, r *http.Request) {
- getFromMap(Quota, w, r, g, func(accountIds, _ []string, ctx jmap.Context) (jmap.Result[map[string]jmap.QuotaGetResponse], jmap.Error) {
+ getFromMap(Quota, w, r, g, func(accountIds, _ []string, ctx jmap.Context) (jmap.Result[map[string]jmap.QuotaGetResponse], error) {
return g.jmap.GetQuotas(accountIds, ctx)
})
}
diff --git a/services/groupware/pkg/groupware/api_vacation.go b/services/groupware/pkg/groupware/api_vacation.go
index 19c81fe519..0f8f2c344e 100644
--- a/services/groupware/pkg/groupware/api_vacation.go
+++ b/services/groupware/pkg/groupware/api_vacation.go
@@ -13,7 +13,7 @@ import (
//
// The VacationResponse object represents the state of vacation-response-related settings for an account.
func (g *Groupware) GetVacation(w http.ResponseWriter, r *http.Request) {
- get(VacationResponse, w, r, g, func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[jmap.VacationResponseGetResponse], jmap.Error) {
+ get(VacationResponse, w, r, g, func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[jmap.VacationResponseGetResponse], error) {
return g.jmap.GetVacationResponse(accountId, ctx)
})
}
@@ -23,7 +23,7 @@ func (g *Groupware) GetVacation(w http.ResponseWriter, r *http.Request) {
// A vacation response sends an automatic reply when a message is delivered to the mail store, informing the original
// sender that their message may not be read for some time.
func (g *Groupware) SetVacation(w http.ResponseWriter, r *http.Request) {
- modify(VacationResponse, w, r, g, func(accountId string, id string, change jmap.VacationResponseChange, ctx jmap.Context) (jmap.Result[jmap.VacationResponse], jmap.Error) {
+ modify(VacationResponse, w, r, g, func(accountId string, id string, change jmap.VacationResponseChange, ctx jmap.Context) (jmap.Result[jmap.VacationResponse], error) {
return g.jmap.SetVacationResponse(accountId, change, ctx)
})
}
diff --git a/services/groupware/pkg/groupware/base62.go b/services/groupware/pkg/groupware/base62.go
new file mode 100644
index 0000000000..b820ce02e0
--- /dev/null
+++ b/services/groupware/pkg/groupware/base62.go
@@ -0,0 +1,97 @@
+package groupware
+
+import (
+ "errors"
+ "math/big"
+)
+
+const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+
+var base = big.NewInt(int64(len(alphabet)))
+
+// EncodeBytes converts any byte slice into a Base62 string
+func EncodeBytesToBase62(src []byte) string {
+ if len(src) == 0 {
+ return ""
+ }
+
+ // 1. Convert the bytes into a single large big.Int
+ n := new(big.Int).SetBytes(src)
+ if n.Cmp(big.NewInt(0)) == 0 {
+ return string(alphabet[0])
+ }
+
+ // 2. Convert the big.Int to Base62
+ var result []byte
+ mod := new(big.Int)
+ for n.Cmp(big.NewInt(0)) > 0 {
+ n.DivMod(n, base, mod)
+ result = append(result, alphabet[mod.Int64()])
+ }
+
+ // 3. Handle leading zeros in the original byte array.
+ // Because math/big drops leading zeros, we must explicitly preserve them.
+ for _, b := range src {
+ if b != 0x00 {
+ break
+ }
+ result = append(result, alphabet[0])
+ }
+
+ // Reverse the result slice to get the correct order
+ for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
+ result[i], result[j] = result[j], result[i]
+ }
+
+ return string(result)
+}
+
+// DecodeBytes converts a Base62 string back into its original byte slice
+func DecodeBytesFromBase62(src string) ([]byte, error) {
+ if len(src) == 0 {
+ return nil, nil
+ }
+
+ n := big.NewInt(0)
+ idx := big.NewInt(0)
+
+ // 1. Reconstruct the large integer from the Base62 characters
+ for i := 0; i < len(src); i++ {
+ pos := findAlphabetIndex(src[i])
+ if pos == -1 {
+ return nil, errors.New("invalid character in base62 string")
+ }
+ idx.SetInt64(int64(pos))
+ n.Mul(n, base)
+ n.Add(n, idx)
+ }
+
+ // 2. Extract the raw bytes from the big.Int
+ decoded := n.Bytes()
+
+ // 3. Re-prepend the leading zeros that were stripped during encoding
+ var leadingZeros int
+ for i := 0; i < len(src); i++ {
+ if src[i] != alphabet[0] {
+ break
+ }
+ leadingZeros++
+ }
+
+ if leadingZeros > 0 {
+ zeroBytes := make([]byte, leadingZeros)
+ decoded = append(zeroBytes, decoded...)
+ }
+
+ return decoded, nil
+}
+
+// Helper to find the index of a character in our alphabet
+func findAlphabetIndex(char byte) int {
+ for i := 0; i < len(alphabet); i++ {
+ if alphabet[i] == char {
+ return i
+ }
+ }
+ return -1
+}
diff --git a/services/groupware/pkg/groupware/error.go b/services/groupware/pkg/groupware/error.go
index a6749c7d08..e9de913a92 100644
--- a/services/groupware/pkg/groupware/error.go
+++ b/services/groupware/pkg/groupware/error.go
@@ -2,6 +2,7 @@ package groupware
import (
"context"
+ "fmt"
"net/http"
"strconv"
@@ -109,6 +110,11 @@ type GroupwareError struct {
Code string
Title string
Detail string
+ error
+}
+
+func (g GroupwareError) Error() string {
+ return fmt.Sprintf("[%s] %s: %s", g.Code, g.Title, g.Detail)
}
func groupwareErrorFromJmap(j jmap.Error) *GroupwareError {
@@ -756,10 +762,36 @@ func (r *Request) errorN(accountIds []string, err *Error) Response {
return errorResponse(accountIds, err, r.session.State, jmap.NoLanguage)
}
-func (r *Request) jmapError(accountId string, err jmap.Error, result jmap.ResultMetadata) Response {
- return errorResponse(single(accountId), r.apiErrorFromJmap(r.observeJmapError(err)), result.GetSessionState(), result.GetLanguage())
+func (r *Request) jmapError(accountId string, err error, result jmap.ResultMetadata) Response {
+ switch e := err.(type) {
+ case jmap.Error:
+ if result != nil {
+ return errorResponse(single(accountId), r.apiErrorFromJmap(r.observeJmapError(e)), result.GetSessionState(), result.GetLanguage())
+ } else {
+ return errorResponse(single(accountId), r.apiErrorFromJmap(r.observeJmapError(e)), r.session.GetSessionState(), jmap.NoLanguage)
+ }
+ case GroupwareError:
+ errorId := r.errorId()
+ return r.error(accountId, apiError(errorId, e))
+ default:
+ errorId := r.errorId()
+ return r.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
+ }
}
-func (r *Request) jmapErrorN(accountIds []string, err jmap.Error, result jmap.ResultMetadata) Response {
- return errorResponse(accountIds, r.apiErrorFromJmap(r.observeJmapError(err)), result.GetSessionState(), result.GetLanguage())
+func (r *Request) jmapErrorN(accountIds []string, err error, result jmap.ResultMetadata) Response {
+ switch e := err.(type) {
+ case jmap.Error:
+ if result != nil {
+ return errorResponse(accountIds, r.apiErrorFromJmap(r.observeJmapError(e)), result.GetSessionState(), result.GetLanguage())
+ } else {
+ return errorResponse(accountIds, r.apiErrorFromJmap(r.observeJmapError(e)), r.session.GetSessionState(), jmap.NoLanguage)
+ }
+ case GroupwareError:
+ errorId := r.errorId()
+ return r.errorN(accountIds, apiError(errorId, e))
+ default:
+ errorId := r.errorId()
+ return r.errorN(accountIds, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
+ }
}
diff --git a/services/groupware/pkg/groupware/framework.go b/services/groupware/pkg/groupware/framework.go
index 5707d16642..27074171fc 100644
--- a/services/groupware/pkg/groupware/framework.go
+++ b/services/groupware/pkg/groupware/framework.go
@@ -83,11 +83,12 @@ type Job struct {
type groupwareConfig struct {
maxBodyValueBytes uint
sanitize bool
+ publicUrl *url.URL
}
type groupwareDefaults struct {
- emailLimit uint
- contactLimit uint
+ emailLimit *uint
+ contactLimit *uint
}
type Groupware struct {
@@ -112,6 +113,9 @@ type Groupware struct {
jobsChannel chan Job
// A threadsafe counter to generate the job IDs.
jobCounter atomic.Uint64
+
+ addressBookListSuppliers []ListSupplier[jmap.AddressBook, jmap.AddressBookGetResponse]
+ contactCardQuerySuppliers []QuerySupplier[jmap.ContactCard, *jmap.ContactCardSearchResults, jmap.ContactCardFilterElement, jmap.ContactCardComparator]
}
// An error during the Groupware initialization.
@@ -180,9 +184,9 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
sessionUrl := baseUrl.JoinPath(".well-known", "jmap")
- defaultEmailLimit := max(config.Mail.DefaultEmailLimit, 0)
+ defaultEmailLimit := ptrIfNot(max(config.Mail.DefaultEmailLimit, 0), 0)
maxBodyValueBytes := max(config.Mail.MaxBodyValueBytes, 0)
- defaultContactLimit := max(config.Mail.DefaultContactLimit, 0)
+ defaultContactLimit := ptrIfNot(max(config.Mail.DefaultContactLimit, 0), 0)
responseHeaderTimeout := max(config.Mail.ResponseHeaderTimeout, 0)
sessionCacheMaxCapacity := uint64(max(config.Mail.SessionCache.MaxCapacity, 0))
sessionCacheTtl := max(config.Mail.SessionCache.Ttl, 0)
@@ -383,6 +387,30 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
}
}
+ addressBookListSuppliers := []ListSupplier[jmap.AddressBook, jmap.AddressBookGetResponse]{}
+ contactCardQuerySuppliers := []QuerySupplier[jmap.ContactCard, *jmap.ContactCardSearchResults, jmap.ContactCardFilterElement, jmap.ContactCardComparator]{}
+ {
+ {
+ j := newJmapContactCardSupplier(&jmapClient)
+ addressBookListSuppliers = append(addressBookListSuppliers, j)
+ contactCardQuerySuppliers = append(contactCardQuerySuppliers, j)
+ }
+ {
+ m := newMockContactCardSupplier()
+ addressBookListSuppliers = append(addressBookListSuppliers, m)
+ contactCardQuerySuppliers = append(contactCardQuerySuppliers, m)
+ }
+ }
+
+ var publicUrl *url.URL
+ {
+ if u, err := url.Parse(config.HTTP.OpenCloudPublicURL); err != nil {
+ return nil, GroupwareInitializationError{Message: "failed to parse the public URL configuration setting", Err: err}
+ } else {
+ publicUrl = u
+ }
+ }
+
g := &Groupware{
mux: mux,
metrics: m,
@@ -399,10 +427,13 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
config: groupwareConfig{
maxBodyValueBytes: maxBodyValueBytes,
sanitize: sanitize,
+ publicUrl: publicUrl,
},
- eventChannel: eventChannel,
- jobsChannel: jobsChannel,
- jobCounter: atomic.Uint64{},
+ eventChannel: eventChannel,
+ jobsChannel: jobsChannel,
+ jobCounter: atomic.Uint64{},
+ addressBookListSuppliers: addressBookListSuppliers,
+ contactCardQuerySuppliers: contactCardQuerySuppliers,
}
for w := 1; w <= workerPoolSize; w++ {
@@ -696,6 +727,36 @@ func (g *Groupware) sendResponse(w http.ResponseWriter, r *http.Request, respons
w.Header().Add("Content-Language", string(response.contentLanguage))
}
+ if response.next != "" && response.next != NoNextToken {
+ w.Header().Add("next", string(response.next)) // TODO add RESTful links for next?
+
+ base := g.config.publicUrl.JoinPath("/groupware/contacts")
+
+ limit := r.URL.Query().Get(QueryParamLimit)
+ // TODO add all the other query parameters for filter and sort
+
+ // options for rel: self, first, prev, next
+ {
+ u := base
+ q := u.Query()
+ q.Set(QueryParamNext, string(response.next))
+ if limit != "" {
+ q.Set(QueryParamLimit, limit)
+ }
+ u.RawQuery = q.Encode()
+ w.Header().Add("Link", fmt.Sprintf(`<%s>; rel="next">`, u.String()))
+ }
+ {
+ u := base
+ q := u.Query()
+ if limit != "" {
+ q.Set(QueryParamLimit, limit)
+ }
+ u.RawQuery = q.Encode()
+ w.Header().Add("Link", fmt.Sprintf(`<%s>; rel="first">`, u.String()))
+ }
+ }
+
notModified := false
{
etag := string(response.etag)
@@ -747,7 +808,7 @@ func (g *Groupware) respond(w http.ResponseWriter, r *http.Request, handler func
g.sendResponse(w, r, response)
}
-func (g *Groupware) stream(w http.ResponseWriter, r *http.Request, handler func(r Request, w http.ResponseWriter) *Error) {
+func (g *Groupware) stream(w http.ResponseWriter, r *http.Request, handler func(r Request, w http.ResponseWriter) (bool, Response)) {
cotx := r.Context()
sl := g.logger.SubloggerWithRequestID(cotx)
logger := &sl
@@ -796,15 +857,9 @@ func (g *Groupware) stream(w http.ResponseWriter, r *http.Request, handler func(
ctx: ctx,
}
- apierr := handler(req, w)
- if apierr != nil {
- g.log(apierr)
- w.Header().Add("Content-Type", ContentTypeJsonApi)
- render.Status(r, apierr.NumStatus)
- w.WriteHeader(apierr.NumStatus)
- if err := render.Render(w, r, errorResponses(*apierr)); err != nil {
- logger.Error().Err(err).Msgf("failed to render error response")
- }
+ ok, resp := handler(req, w)
+ if !ok {
+ g.sendResponse(w, r, resp)
}
}
@@ -833,3 +888,119 @@ func (g *Groupware) MethodNotAllowed(w http.ResponseWriter, r *http.Request) {
func single[S any](s S) []S {
return []S{s}
}
+
+type QueryParamsSupplier interface {
+ ForSupplier(supplierId string, accountId string) (jmap.QueryParams, bool, error)
+ ForAccountId(accountId string) (jmap.QueryParams, bool, error)
+}
+
+func mapQueryParams(supplierId string, accountIds []string, qps QueryParamsSupplier) (map[string]jmap.QueryParams, error) {
+ m := map[string]jmap.QueryParams{}
+ if supplierId == "" {
+ for _, accountId := range accountIds {
+ if q, ok, err := qps.ForAccountId(accountId); err != nil {
+ return nil, err
+ } else if ok {
+ m[accountId] = q
+ }
+ }
+ } else {
+ for _, accountId := range accountIds {
+ if q, ok, err := qps.ForSupplier(supplierId, accountId); err != nil {
+ return nil, err
+ } else if ok {
+ m[accountId] = q
+ }
+ }
+ }
+ return m, nil
+}
+
+type ErrorQueryParamsSupplier struct {
+ err error
+}
+
+func (s ErrorQueryParamsSupplier) ForSupplier(supplierId string, accountId string) (jmap.QueryParams, bool, error) {
+ return jmap.NullQueryParams, false, s.err
+}
+
+func (s ErrorQueryParamsSupplier) ForAccountId(accountId string) (jmap.QueryParams, bool, error) {
+ return jmap.NullQueryParams, false, s.err
+}
+
+var _ QueryParamsSupplier = ErrorQueryParamsSupplier{}
+
+type FirstQueryParamsSupplier struct {
+}
+
+func (s FirstQueryParamsSupplier) ForSupplier(supplierId string, accountId string) (jmap.QueryParams, bool, error) {
+ return jmap.NullQueryParams, true, nil
+}
+
+func (s FirstQueryParamsSupplier) ForAccountId(accountId string) (jmap.QueryParams, bool, error) {
+ return jmap.NullQueryParams, true, nil
+}
+
+var _ QueryParamsSupplier = FirstQueryParamsSupplier{}
+
+type StaticQueryParamsSupplier struct {
+ qp jmap.QueryParams
+}
+
+func (s StaticQueryParamsSupplier) ForSupplier(supplierId string, accountId string) (jmap.QueryParams, bool, error) {
+ return s.qp, true, nil
+}
+
+func (s StaticQueryParamsSupplier) ForAccountId(accountId string) (jmap.QueryParams, bool, error) {
+ return s.qp, true, nil
+}
+
+var _ QueryParamsSupplier = StaticQueryParamsSupplier{}
+
+type MultiSupplierQueryParamsSupplier struct {
+ m map[string]map[string]jmap.QueryParams
+}
+
+func (s MultiSupplierQueryParamsSupplier) ForSupplier(supplierId string, accountId string) (jmap.QueryParams, bool, error) {
+ if a, ok := s.m[supplierId]; ok {
+ if b, ok := a[accountId]; ok {
+ return b, true, nil
+ }
+ }
+ return jmap.NullQueryParams, false, nil
+}
+
+func (s MultiSupplierQueryParamsSupplier) ForAccountId(accountId string) (jmap.QueryParams, bool, error) {
+ switch len(s.m) {
+ case 1:
+ for _, v := range s.m {
+ if b, ok := v[accountId]; ok {
+ return b, true, nil
+ }
+ }
+ return jmap.NullQueryParams, false, nil
+ case 0:
+ return jmap.NullQueryParams, false, nil
+ default:
+ return jmap.NullQueryParams, false, fmt.Errorf("unable to provide for account with multi supplier token")
+ }
+}
+
+var _ QueryParamsSupplier = MultiSupplierQueryParamsSupplier{}
+
+type SingleSupplierQueryParamsSupplier struct {
+ m map[string]jmap.QueryParams
+}
+
+func (s SingleSupplierQueryParamsSupplier) ForSupplier(supplierId string, accountId string) (jmap.QueryParams, bool, error) {
+ return jmap.NullQueryParams, false, fmt.Errorf("unable to provide for supplier with single supplier token")
+}
+
+func (s SingleSupplierQueryParamsSupplier) ForAccountId(accountId string) (jmap.QueryParams, bool, error) {
+ if b, ok := s.m[accountId]; ok {
+ return b, true, nil
+ }
+ return jmap.NullQueryParams, false, nil
+}
+
+var _ QueryParamsSupplier = SingleSupplierQueryParamsSupplier{}
diff --git a/services/groupware/pkg/groupware/next.go b/services/groupware/pkg/groupware/next.go
new file mode 100644
index 0000000000..c03b4cfcce
--- /dev/null
+++ b/services/groupware/pkg/groupware/next.go
@@ -0,0 +1,195 @@
+package groupware
+
+import (
+ "encoding/json"
+ "fmt"
+ "slices"
+
+ "github.com/opencloud-eu/opencloud/pkg/jmap"
+ "github.com/opencloud-eu/opencloud/pkg/structs"
+)
+
+type NextToken string
+
+const NoNextToken = NextToken("")
+
+func nextSingle(m map[string]jmap.QueryParams) (NextToken, error) {
+ if b, err := json.Marshal(m); err != nil {
+ return NoNextToken, err
+ } else {
+ return NextToken("S" + EncodeBytesToBase62(b)), nil
+ }
+}
+
+func nextMulti(m map[string]map[string]jmap.QueryParams) (NextToken, error) {
+ if b, err := json.Marshal(m); err != nil {
+ return NoNextToken, err
+ } else {
+ return NextToken("M" + EncodeBytesToBase62(b)), nil
+ }
+}
+
+func unnext(n NextToken) (QueryParamsSupplier, error) {
+ s := string(n)
+ if len(s) < 1 {
+ err := fmt.Errorf("invalid next token: is empty")
+ return ErrorQueryParamsSupplier{err: err}, err
+ }
+ t := s[0:1]
+ switch t {
+ case "S":
+ payload := s[1:]
+ if b, err := DecodeBytesFromBase62(payload); err != nil {
+ return ErrorQueryParamsSupplier{err: err}, err
+ } else {
+ var m map[string]jmap.QueryParams
+ if err := json.Unmarshal(b, &m); err != nil {
+ return ErrorQueryParamsSupplier{err: err}, err
+ } else {
+ return SingleSupplierQueryParamsSupplier{m: m}, nil
+ }
+ }
+ case "M":
+ payload := s[1:]
+ if b, err := DecodeBytesFromBase62(payload); err != nil {
+ return ErrorQueryParamsSupplier{err: err}, err
+ } else {
+ var m map[string]map[string]jmap.QueryParams
+ if err := json.Unmarshal(b, &m); err != nil {
+ return ErrorQueryParamsSupplier{err: err}, err
+ } else {
+ return MultiSupplierQueryParamsSupplier{m: m}, nil
+ }
+ }
+ default:
+ err := fmt.Errorf("invalid next token: unsupported type header '%s'", t)
+ return ErrorQueryParamsSupplier{err: err}, err
+ }
+}
+
+func curryNoNextMapQuery[SRES jmap.SearchResults[T], T jmap.Idable, FILTER any, COMP any](
+ f func(accountIds map[string]jmap.QueryParams, limit *uint, filter FILTER, sortBy []COMP, calculateTotal bool, ctx jmap.Context) (jmap.Result[map[string]SRES], error),
+ sorter func(a, b T) int,
+ searchResultCtor func(canCalculateChanges jmap.ChangeCalculation, position *uint, limit *uint, total *uint, results []T) SRES,
+) func(req Request, accountIds []string, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, ctx jmap.Context) (jmap.Result[SRES], NextToken, error) {
+ return func(_ Request, accountIds []string, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, ctx jmap.Context) (jmap.Result[SRES], NextToken, error) { //NOSONAR
+ if m, err := mapQueryParams("", accountIds, qps); err != nil {
+ return jmap.ZeroResult[SRES](), NoNextToken, err
+ } else {
+ result, err := f(m, limit, filter, sortBy, true, ctx)
+ if err != nil {
+ return jmap.ZeroResult[SRES](), NoNextToken, err
+ } else {
+ singleAccountId := ""
+ // TODO what about requests with zero accountIds, can these even happen at all?
+ if len(accountIds) == 1 {
+ // optimization: no need to combine the results of several accounts if the query was
+ // performed with a single accountId
+ singleAccountId = accountIds[0]
+ } else {
+ // the request includes multiple accounts, but that doesn't mean that we have results from
+ // all accounts: let's first calculate the number of results we have for each account
+ totalByAccount := structs.MapValues(result.Payload, func(a SRES) int { return len(a.GetResults()) })
+ // and let's now pick out the accounts that do have results
+ accountsWithResults := structs.FilterKeys(totalByAccount, func(_ string, total int) bool { return total > 0 })
+ if len(accountsWithResults) == 1 {
+ singleAccountId = accountsWithResults[0]
+ }
+ // TODO what if we don't have any results at all? (accountsWithResults == 0)
+ }
+ if singleAccountId != "" {
+ r, err := jmap.RefineResultPayload(result, func(a map[string]SRES) (SRES, bool, error) {
+ r, ok := a[accountIds[0]]
+ return r, ok, nil
+ })
+ return r, NoNextToken, err
+ } else {
+ // more than one account with results
+ return flattenMultipleAccounts(accountIds, qps, limit, result, sorter, searchResultCtor)
+ }
+ }
+ }
+ }
+}
+
+func flattenMultipleAccounts[SRES jmap.SearchResults[T], T jmap.Idable](
+ accountIds []string,
+ qps QueryParamsSupplier,
+ limit *uint,
+ result jmap.Result[map[string]SRES],
+ sorter func(a, b T) int,
+ searchResultCtor func(canCalculateChanges jmap.ChangeCalculation, position *uint, limit *uint, total *uint, results []T) SRES,
+) (jmap.Result[SRES], NextToken, error) {
+ // 1. we need to flatten all the results, and then sort them in memory
+ all := []T{}
+ // 2. we need to sort them in memory in the same way as the filter
+ slices.SortFunc(all, sorter)
+
+ if limit != nil && *limit > 0 && len(all) > int(*limit) {
+ // 3. we need to cut if off to only keep *limit amount of elements
+ shrunk := make([]T, *limit)
+ copy(shrunk, all)
+ all = shrunk
+
+ cc := true
+ total := uint(0)
+ lastByAccountId := map[string]string{}
+ for accountId, searchResult := range result.Payload {
+ if !searchResult.GetCanCalculateChanges() {
+ cc = false
+ }
+ to := searchResult.GetTotal()
+ if to != nil {
+ total += *to
+ }
+ for _, item := range searchResult.GetResults() {
+ lastByAccountId[accountId] = item.GetId()
+ }
+ all = append(all, searchResult.GetResults()...)
+ }
+
+ // 4. we need to build the NextToken by taking the ID of the last item
+ // we kept after shrinking, but separately for each accountId
+ n := map[string]jmap.QueryParams{}
+ for accountId, lastId := range lastByAccountId {
+ n[accountId] = jmap.QueryParams{Anchor: lastId, AnchorOffset: ptr(1)}
+ }
+ if err := fillMissingAccounts(qps, "", accountIds, n); err != nil {
+ return jmap.ZeroResult[SRES](), NoNextToken, err
+ }
+ if t, err := nextSingle(n); err != nil {
+ return jmap.ZeroResult[SRES](), NoNextToken, err
+ } else {
+ if r, err := jmap.RefineResultPayload(result, func(a map[string]SRES) (SRES, bool, error) {
+ return searchResultCtor(jmap.ChangeCalculation(cc), nil, limit, &total, all), true, nil
+ }); err != nil {
+ return jmap.ZeroResult[SRES](), NoNextToken, err
+ } else {
+ return r, t, nil
+ }
+ }
+ } else {
+ cc := true
+ total := uint(0)
+ for _, searchResult := range result.Payload {
+ if !searchResult.GetCanCalculateChanges() {
+ cc = false
+ }
+ to := searchResult.GetTotal()
+ if to != nil {
+ total += *to
+ }
+ }
+
+ // no need to compute a NextToken since there was no limit,
+ // which means that this Result contains all the elements,
+ // and thus there is no "next" to query for
+ if r, err := jmap.RefineResultPayload(result, func(a map[string]SRES) (SRES, bool, error) {
+ return searchResultCtor(jmap.ChangeCalculation(cc), nil, limit, &total, all), true, nil
+ }); err != nil {
+ return jmap.ZeroResult[SRES](), NoNextToken, err
+ } else {
+ return r, NoNextToken, nil
+ }
+ }
+}
diff --git a/services/groupware/pkg/groupware/pet_export_test.go b/services/groupware/pkg/groupware/pet_export_test.go
new file mode 100644
index 0000000000..afb9fe03fe
--- /dev/null
+++ b/services/groupware/pkg/groupware/pet_export_test.go
@@ -0,0 +1,100 @@
+package groupware
+
+import (
+ "github.com/opencloud-eu/opencloud/pkg/jmap"
+)
+
+var petObjectType = jmap.ObjectType{Name: jmap.ObjectTypeName("Pet"), Namespaces: []jmap.JmapNamespace{}}
+
+type Pet struct {
+ id string
+ name string
+}
+
+func (m Pet) GetId() string { return m.id }
+func (m Pet) GetObjectType() jmap.ObjectType { return petObjectType }
+
+var _ jmap.Idable = &Pet{}
+
+type PetGetResponse struct {
+ AccountId string
+ State jmap.State
+ List []Pet
+}
+
+var _ jmap.GetResponse[Pet] = &PetGetResponse{}
+
+func (r PetGetResponse) GetMarker() Pet { return Pet{} }
+func (r PetGetResponse) GetState() jmap.State { return r.State }
+func (r PetGetResponse) GetNotFound() []string { return []string{} }
+func (r PetGetResponse) GetList() []Pet { return r.List }
+
+type PetSearchResults jmap.SearchResultsTemplate[Pet]
+
+var _ jmap.SearchResults[Pet] = &PetSearchResults{}
+
+func (r *PetSearchResults) GetResults() []Pet { return r.Results }
+func (r *PetSearchResults) GetCanCalculateChanges() jmap.ChangeCalculation {
+ return r.CanCalculateChanges
+}
+func (r *PetSearchResults) GetPosition() *uint { return r.Position }
+func (r *PetSearchResults) GetLimit() *uint { return r.Limit }
+func (r *PetSearchResults) GetTotal() *uint { return r.Total }
+func (r *PetSearchResults) RemoveResults() { r.Results = nil }
+func (r *PetSearchResults) SetLimit(limit *uint) { r.Limit = limit }
+func (r *PetSearchResults) SetPosition(position *uint) { r.Position = position }
+
+type PetComparator struct {
+ Property string
+ IsAscending bool
+}
+
+var _ jmap.Comparator[Pet] = &PetComparator{}
+
+func (c PetComparator) GetMarker() Pet { return Pet{} }
+
+type PetFilterElement interface {
+ _isAPetFilterElement() // marker method
+ IsNotEmpty() bool
+ jmap.FilterElement[Pet]
+}
+
+type PetFilterCondition struct {
+ id string
+}
+
+var _ PetFilterElement = &PetFilterCondition{}
+
+var _ jmap.FilterElement[Pet] = &PetFilterCondition{}
+
+func (f PetFilterCondition) GetMarker() Pet { return Pet{} }
+
+func (f PetFilterCondition) _isAPetFilterElement() { //NOSONAR
+ // marker interface method, does not need to do anything
+}
+
+func (f PetFilterCondition) IsNotEmpty() bool { //NOSONAR
+ if f.id != "" {
+ return true
+ }
+ return false
+}
+
+type PetFilterOperator struct {
+ Operator jmap.FilterOperatorTerm
+ Conditions []PetFilterElement
+}
+
+var _ PetFilterElement = &PetFilterOperator{}
+
+var _ jmap.FilterElement[Pet] = &PetFilterOperator{}
+
+func (f PetFilterOperator) GetMarker() Pet { return Pet{} }
+
+func (o PetFilterOperator) _isAPetFilterElement() { //NOSONAR
+ // marker interface method, does not need to do anything
+}
+
+func (o PetFilterOperator) IsNotEmpty() bool {
+ return len(o.Conditions) > 0
+}
diff --git a/services/groupware/pkg/groupware/request.go b/services/groupware/pkg/groupware/request.go
index 06008abc4b..3881c12c75 100644
--- a/services/groupware/pkg/groupware/request.go
+++ b/services/groupware/pkg/groupware/request.go
@@ -223,7 +223,11 @@ func (r *Request) parameterError(param string, detail string) *Error {
withSource(&ErrorSource{Parameter: param}))
}
-func (r *Request) parameterErrorResponse(accountIds []string, param string, detail string) Response {
+func (r *Request) parameterErrorResponse(accountId string, param string, detail string) Response {
+ return r.error(accountId, r.parameterError(param, detail))
+}
+
+func (r *Request) parameterErrorResponseN(accountIds []string, param string, detail string) Response {
return r.errorN(accountIds, r.parameterError(param, detail))
}
@@ -239,7 +243,7 @@ func (r *Request) unsupportedQueryParams(accountIds []string, allowed supportedQ
q := r.r.URL.Query()
for n := range q {
if _, ok := allowed[n]; !ok {
- return true, r.parameterErrorResponse(accountIds, n, "Unsupported query parameter")
+ return true, r.parameterErrorResponseN(accountIds, n, "Unsupported query parameter")
}
}
return false, Response{}
diff --git a/services/groupware/pkg/groupware/response.go b/services/groupware/pkg/groupware/response.go
index 22a7451896..196c2d19c3 100644
--- a/services/groupware/pkg/groupware/response.go
+++ b/services/groupware/pkg/groupware/response.go
@@ -35,6 +35,7 @@ type Response struct {
accountIds []string
sessionState jmap.SessionState
contentLanguage jmap.Language
+ next NextToken
}
func errorResponse(accountIds []string, err *Error, sessionState jmap.SessionState, contentLanguage jmap.Language) Response {
@@ -45,6 +46,7 @@ func errorResponse(accountIds []string, err *Error, sessionState jmap.SessionSta
etag: "",
sessionState: sessionState,
contentLanguage: contentLanguage,
+ next: NoNextToken,
}
}
@@ -56,6 +58,7 @@ func response(accountIds []string, body any, sessionState jmap.SessionState, con
etag: jmap.State(sessionState),
sessionState: sessionState,
contentLanguage: contentLanguage,
+ next: NoNextToken,
}
}
@@ -72,6 +75,7 @@ func etaggedResponse(accountIds []string, body any, sessionState jmap.SessionSta
objectType: objectType,
sessionState: sessionState,
contentLanguage: contentLanguage,
+ next: NoNextToken,
}
}
@@ -83,6 +87,23 @@ func (r *Request) respondN(accountIds []string, body any, objectType ResponseObj
return etaggedResponse(accountIds, body, result.GetSessionState(), objectType, result.GetState(), result.GetLanguage())
}
+func etaggedNextResponse(accountIds []string, body any, sessionState jmap.SessionState, objectType ResponseObjectType, etag jmap.State, contentLanguage jmap.Language, next NextToken) Response {
+ return Response{
+ accountIds: accountIds,
+ body: body,
+ err: nil,
+ etag: etag,
+ objectType: objectType,
+ sessionState: sessionState,
+ contentLanguage: contentLanguage,
+ next: next,
+ }
+}
+
+func (r *Request) respondNext(accountId string, body any, objectType ResponseObjectType, result jmap.ResultMetadata, next NextToken) Response {
+ return etaggedNextResponse(single(accountId), body, result.GetSessionState(), objectType, result.GetState(), result.GetLanguage(), next)
+}
+
/*
func etagOnlyResponse(body any, etag jmap.State, objectType ResponseObjectType, contentLanguage jmap.Language) Response {
return Response{
@@ -104,6 +125,7 @@ func noContentResponse(accountIds []string, sessionState jmap.SessionState) Resp
err: nil,
etag: jmap.State(sessionState),
sessionState: sessionState,
+ next: NoNextToken,
}
}
@@ -124,6 +146,7 @@ func noContentResponseWithEtag(accountIds []string, sessionState jmap.SessionSta
etag: etag,
objectType: objectType,
sessionState: sessionState,
+ next: NoNextToken,
}
}
@@ -164,6 +187,7 @@ func notFoundResponse(accountIds []string, sessionState jmap.SessionState, objec
objectType: objectType,
etag: etag,
sessionState: sessionState,
+ next: NoNextToken,
}
}
@@ -181,6 +205,7 @@ func etaggedNotFoundResponse(accountIds []string, sessionState jmap.SessionState
objectType: objectType,
sessionState: sessionState,
contentLanguage: contentLanguage,
+ next: NoNextToken,
}
}
@@ -196,6 +221,7 @@ func notImplementedResponse(accountIds []string, sessionState jmap.SessionState,
err: nil,
objectType: objectType,
sessionState: sessionState,
+ next: NoNextToken,
}
}
diff --git a/services/groupware/pkg/groupware/route.go b/services/groupware/pkg/groupware/route.go
index 57f5fe9322..a873c801ca 100644
--- a/services/groupware/pkg/groupware/route.go
+++ b/services/groupware/pkg/groupware/route.go
@@ -71,6 +71,7 @@ const (
QueryParamEmailSubmissions = "submissions"
QueryParamId = "id"
QueryParamCalculateTotal = "total"
+ QueryParamNext = "next"
HeaderParamSince = "if-none-match"
)
diff --git a/services/groupware/pkg/groupware/suppliers.go b/services/groupware/pkg/groupware/suppliers.go
new file mode 100644
index 0000000000..91b706a865
--- /dev/null
+++ b/services/groupware/pkg/groupware/suppliers.go
@@ -0,0 +1,425 @@
+package groupware
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "slices"
+ "strings"
+
+ "github.com/opencloud-eu/opencloud/pkg/jmap"
+ "github.com/opencloud-eu/opencloud/pkg/structs"
+)
+
+type Supplier[T jmap.Foo] interface {
+ GetId() string
+ IsMine(id string) bool
+}
+
+type ListSupplier[T jmap.Foo, G jmap.GetResponse[T]] interface {
+ GetAll(accountId string, ids []string, ctx jmap.Context) (jmap.Result[G], error)
+ Supplier[T]
+}
+
+type QuerySupplier[T jmap.Foo, R jmap.SearchResults[T], F jmap.FilterElement[T], C jmap.Comparator[T]] interface {
+ Query(accountIds []string, qps QueryParamsSupplier, limit *uint, filter F, sortBy []C, calculateTotal bool, ctx jmap.Context) (jmap.Result[map[string]R], error)
+ Supplier[T]
+}
+
+// queryFunc func(req Request, accountIds []string, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, ctx jmap.Context) (jmap.Result[SEARCHRESULTS], NextToken, error),
+func curryQueryFunc[SRES jmap.SearchResults[T], T jmap.Foo, FILTER any, COMP any](
+ f func(accountIds []string, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, calculateTotal bool, ctx jmap.Context) (jmap.Result[SRES], NextToken, error),
+) func(req Request, accountIds []string, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, ctx jmap.Context) (jmap.Result[SRES], NextToken, error) {
+ return func(_ Request, accountIds []string, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, ctx jmap.Context) (jmap.Result[SRES], NextToken, error) { //NOSONAR
+ result, next, err := f(accountIds, qps, limit, filter, sortBy, true, ctx)
+ if err != nil {
+ return jmap.ZeroResult[SRES](), next, err
+ } else {
+ return result, next, err
+ }
+ }
+}
+
+func agg[T jmap.Idable, R jmap.GetResponse[T]](accountId string, supplierIds []string, responses []*R, //NOSONAR
+ ctor func(accountId string, state jmap.State, notFound []string, list []T) R) (R, error) {
+ if len(responses) < 1 {
+ var zero R
+ return zero, fmt.Errorf("requires at least one response")
+ }
+ lists := structs.Concat(structs.Map(responses, func(e *R) []T {
+ if e != nil {
+ return (*e).GetList()
+ } else {
+ var zero []T
+ return zero
+ }
+ })...)
+ states, err := structs.MeshMap(supplierIds, responses, func(id string, e *R) (string, jmap.State, bool) {
+ if e != nil {
+ state := (*e).GetState()
+ if state != jmap.EmptyState {
+ return id, state, true
+ } else {
+ return id, jmap.EmptyState, false
+ }
+ } else {
+ return "", jmap.EmptyState, false
+ }
+ })
+ if err != nil {
+ var zero R
+ return zero, err
+ }
+ state, err := combineState(states)
+ if err != nil {
+ var zero R
+ return zero, err
+ }
+ notFounds := structs.Concat(structs.Map(responses, func(e *R) []string {
+ if e != nil {
+ return (*e).GetNotFound()
+ } else {
+ return []string{}
+ }
+ })...)
+ return ctor(accountId, state, notFounds, lists), nil
+}
+
+func slist[T jmap.Idable, G jmap.GetResponse[T], S ListSupplier[T, G]](suppliers []S, accountId string, ids []string, ctx jmap.Context, //NOSONAR
+ ctor func(accountId string, state jmap.State, notFound []string, list []T) G) (jmap.Result[G], error) {
+ switch len(suppliers) {
+ case 0:
+ return jmap.ZeroResult[G](), nil
+ case 1:
+ return suppliers[0].GetAll(accountId, ids, ctx)
+ default:
+ results := make([]*jmap.Result[G], len(suppliers))
+ supplierIds := make([]string, len(suppliers))
+ for i, supplier := range suppliers {
+ supplierIds[i] = supplier.GetId()
+ localIds := []string{}
+ if len(ids) > 0 {
+ localIds = structs.Filter(ids, func(id string) bool { return supplier.IsMine(id) })
+ if len(localIds) == 0 {
+ results[i] = nil
+ continue
+ }
+ }
+ // we are not injecting id prefixes here for all the objects, as each supplier is responsible for doing that if necessary
+ if result, err := supplier.GetAll(accountId, localIds, ctx); err != nil {
+ return result, err
+ } else {
+ results[i] = &result
+ }
+ }
+
+ return jmap.RefineResultSlice(results, func(payloads []*G, sessionStates []*jmap.SessionState, states []*jmap.State, langs []*jmap.Language) (G, jmap.SessionState, jmap.State, jmap.Language, error) {
+ resp, err := agg(accountId, supplierIds, payloads, ctor)
+ if err != nil {
+ return resp, jmap.EmptySessionState, jmap.EmptyState, jmap.NoLanguage, err
+ }
+ m, err := structs.MeshMap(supplierIds, sessionStates, func(id string, state *jmap.SessionState) (string, jmap.SessionState, bool) {
+ if state != nil && *state != jmap.EmptySessionState {
+ return id, *state, true
+ } else {
+ return id, jmap.EmptySessionState, false
+ }
+ })
+ if err != nil {
+ return resp, jmap.EmptySessionState, jmap.EmptyState, jmap.NoLanguage, err
+ }
+ sessionState, err := combineState(m)
+ if err != nil {
+ return resp, jmap.EmptySessionState, jmap.EmptyState, jmap.NoLanguage, err
+ }
+ lang := jmap.NoLanguage
+ if f, ok := structs.First(langs, func(l *jmap.Language) bool { return l != nil && *l != jmap.NoLanguage }); ok {
+ lang = *f
+ }
+ return resp, sessionState, resp.GetState(), lang, nil
+ })
+ }
+}
+
+func fillMissingAccounts(qps QueryParamsSupplier, supplierId string, accountIds []string, n map[string]jmap.QueryParams) error {
+ for _, accountId := range accountIds {
+ if _, ok := n[accountId]; !ok {
+ // no result item was kept for this accountId
+ // => the next page must be either the same as what was requested for this one
+ // or, if not specified, it should be the first page, which is assumed when there
+ // is no entry in the "next" map for that accountId, so no need to store anything
+ // in the "next" map in that case
+ if qp, ok, err := qps.ForSupplier(supplierId, accountId); err != nil {
+ // unlikely to happen here, since that should already have been requested
+ // by the query function, but let's handle it gracefully here as well
+ return err
+ } else if ok {
+ // just use the same query parameters for that accountId the next time
+ n[accountId] = qp
+ } else {
+ // we didn't find any query parameters for this supplier and accountId,
+ // which means that the first page is requested, and thus we don't need
+ // to store anything in the "next" map either
+ }
+ }
+ }
+ return nil
+}
+
+func squery[T jmap.Idable, R jmap.SearchResults[T], S QuerySupplier[T, R, F, C], F jmap.FilterElement[T], C jmap.Comparator[T]]( //NOSONAR
+ suppliers []S, accountIds []string, qps QueryParamsSupplier, limit *uint, filter F, sortBy []C,
+ calculateTotal bool, ctx jmap.Context,
+ sorter func(T, T) int,
+ searchResultCtor func(canCalculateChanges jmap.ChangeCalculation, position *uint, limit *uint, total *uint, results []T) R) (
+ jmap.Result[R], NextToken, error,
+) {
+ s := len(suppliers)
+ if s == 0 {
+ return jmap.ZeroResult[R](), NoNextToken, nil
+ }
+
+ switch s {
+ case 1:
+ supplier := suppliers[0]
+ if result, err := supplier.Query(accountIds, qps, limit, filter, sortBy, calculateTotal, ctx); err != nil {
+ return jmap.ZeroResult[R](), NoNextToken, err
+ } else if len(accountIds) == 0 {
+ return jmap.ZeroResult[R](), NoNextToken, nil
+ } else if len(accountIds) == 1 {
+ accountId := accountIds[0]
+ // use anchor and anchorOffset and limit:
+ // the anchor for the next query is the ID of the last element in the results for this query
+ // using an anchor offset of +1
+ n := map[string]jmap.QueryParams{}
+ for accountId, payload := range result.Payload {
+ items := payload.GetResults()
+ last := items[len(items)-1]
+ n[accountId] = jmap.QueryParams{Anchor: last.GetId(), AnchorOffset: ptr(1)}
+ }
+ if nextToken, err := nextSingle(n); err != nil {
+ return jmap.ZeroResult[R](), NoNextToken, err
+ } else {
+ single, err := jmap.RefineResultPayload(result, func(m map[string]R) (R, bool, error) {
+ if r, ok := m[accountId]; ok {
+ return r, true, nil
+ } else {
+ return r, false, nil
+ }
+ })
+ return single, nextToken, err
+ }
+ } else {
+ // multiple accountIds => we need to merge/flatten the results, and we must use anchor and offset for the next page
+ payloads := []T{}
+ originals := map[string][]T{}
+ cc := true
+ total := uint(0)
+ for accountId, searchResult := range result.Payload {
+ if !searchResult.GetCanCalculateChanges() {
+ cc = false
+ }
+ to := searchResult.GetTotal()
+ if to != nil {
+ total += *to
+ }
+ originals[accountId] = searchResult.GetResults()
+ payloads = append(payloads, searchResult.GetResults()...)
+ }
+
+ // we have to sort everything in memory in order to be able to cut off if the limit is exceeded,
+ // but only if necessary
+ var r R
+ {
+ l := len(payloads)
+ if limit != nil && l > int(*limit) {
+ // the amount of items in payload should not exceed the requested limit
+ // but in order to determine which items to keep and which to discard, we need to sort
+ // them in memory first, to have a stable order from which to cut off and drop the
+ // intermingled search results that are beyond the limit
+ // e.g. if, with a limit of 10, supplier A gives us 10 results and supplier B gives us 5,
+ // we end up with 15 elements of which we may only return the first 10, but in order to
+ // reliably and repeatedly determine which those first 10 are, we need to sort them in
+ // memory first
+ slices.SortFunc(payloads, sorter)
+ var shrunk []T
+ shrunk = make([]T, int(*limit))
+ copy(shrunk, payloads)
+ payloads = shrunk
+ }
+ r = searchResultCtor(jmap.ChangeCalculation(cc), nil, limit, &total, payloads) // TODO can we determine the position here, instead of nil?
+ }
+
+ lastIdByAccountId := map[string]string{}
+ // not amazing, but since the accountId information is not attached to every single
+ // search result element (e.g. a ContactCard), we have to iterate over the original results that we
+ // have by accountId to find them again, in order to determine the "last ID"
+ // for each accountId, since we will have to persist that into the "next page"
+ // token in order to perform the query for each supplier and accountId
+ for _, item := range payloads {
+ for accountId, searchResult := range result.Payload {
+ if slices.IndexFunc(searchResult.GetResults(), func(t T) bool { return t.GetId() == item.GetId() }) >= 0 {
+ lastIdByAccountId[accountId] = item.GetId()
+ }
+ }
+ }
+
+ n := map[string]jmap.QueryParams{}
+ for accountId, lastId := range lastIdByAccountId {
+ // the anchor for the next query is the ID of the last element in the results for this query
+ // using an anchor offset of +1
+ n[accountId] = jmap.QueryParams{Anchor: lastId, AnchorOffset: ptr(1)}
+ }
+ if err := fillMissingAccounts(qps, supplier.GetId(), accountIds, n); err != nil {
+ return jmap.ZeroResult[R](), NoNextToken, err
+ }
+
+ nextBySupplier := map[string]map[string]jmap.QueryParams{supplier.GetId(): n}
+ if nextToken, err := nextMulti(nextBySupplier); err != nil {
+ return jmap.ZeroResult[R](), NoNextToken, err
+ } else {
+ return jmap.Result[R]{
+ Payload: r,
+ SessionState: result.GetSessionState(),
+ State: result.GetState(),
+ Language: result.GetLanguage(),
+ }, nextToken, nil
+ }
+ }
+ default:
+ payloads := []T{}
+ originals := map[string]map[string][]T{}
+ cc := true
+ total := uint(0)
+ sessionState := jmap.EmptySessionState
+ states := map[string]jmap.State{}
+ lang := jmap.NoLanguage
+ for _, supplier := range suppliers {
+ // we are not injecting id prefixes here for all the objects, as each supplier is responsible for doing that if necessary
+ if result, err := supplier.Query(accountIds, qps, limit, filter, sortBy, calculateTotal, ctx); err != nil {
+ return jmap.ZeroResult[R](), NoNextToken, err
+ } else {
+ if result.GetSessionState() != jmap.EmptySessionState {
+ sessionState = result.GetSessionState()
+ }
+ states[supplier.GetId()] = result.GetState()
+ if result.GetLanguage() != jmap.NoLanguage {
+ lang = result.GetLanguage()
+ }
+ // iterate over results by accountId and flatten everything into the 'payloads' array
+ o := map[string][]T{}
+ for accountId, searchResult := range result.Payload {
+ o[accountId] = searchResult.GetResults()
+ if !searchResult.GetCanCalculateChanges() {
+ cc = false
+ }
+ to := searchResult.GetTotal()
+ if to != nil {
+ total += *to
+ }
+ payloads = append(payloads, searchResult.GetResults()...)
+ }
+ originals[supplier.GetId()] = o
+ }
+ }
+
+ // we have to sort everything in memory in order to be able to cut off if the limit is exceeded,
+ // but only if necessary
+ var r R
+ {
+ l := len(payloads)
+ slices.SortFunc(payloads, sorter)
+ if limit != nil && l > int(*limit) {
+ // the amount of items in payload should not exceed the requested limit
+ // but in order to determine which items to keep and which to discard, we need to sort
+ // them in memory first, to have a stable order from which to cut off and drop the
+ // intermingled search results that are beyond the limit
+ // e.g. if, with a limit of 10, supplier A gives us 10 results and supplier B gives us 5,
+ // we end up with 15 elements of which we may only return the first 10, but in order to
+ // reliably and repeatedly determine which those first 10 are, we need to sort them in
+ // memory first
+ var shrunk []T
+ shrunk = make([]T, int(*limit))
+ copy(shrunk, payloads)
+ payloads = shrunk
+ }
+ r = searchResultCtor(jmap.ChangeCalculation(cc), nil, limit, &total, payloads) // TODO cen we provide the position here instead of nil?
+ }
+
+ lastIdBySupplierByAccountId := map[string]map[string]string{}
+ // not amazing, but since the accountId and supplier information is not attached to every single
+ // search result element (e.g. a ContactCard), we have to iterate over the original results that we
+ // kept by supplier and by accountId to find them again, in order to determine the "last ID"
+ // for each supplier and for each accountId, since we will have to persist that into the "next page"
+ // token in order to perform the query for each supplier and accountId
+ for _, item := range payloads {
+ for supplierId, o := range originals {
+ for accountId, items := range o {
+ if slices.IndexFunc(items, func(t T) bool { return t.GetId() == item.GetId() }) >= 0 {
+ if _, ok := lastIdBySupplierByAccountId[supplierId]; !ok {
+ lastIdBySupplierByAccountId[supplierId] = map[string]string{}
+ }
+ lastIdBySupplierByAccountId[supplierId][accountId] = item.GetId()
+ }
+ }
+ }
+ }
+
+ nextBySupplier := map[string]map[string]jmap.QueryParams{}
+ for supplierId, m := range lastIdBySupplierByAccountId {
+ n := map[string]jmap.QueryParams{}
+ for accountId, lastId := range m {
+ // the anchor for the next query is the ID of the last element in the results for this query
+ // using an anchor offset of +1
+ n[accountId] = jmap.QueryParams{Anchor: lastId, AnchorOffset: ptr(1)}
+ }
+ if err := fillMissingAccounts(qps, supplierId, accountIds, n); err != nil {
+ return jmap.ZeroResult[R](), NoNextToken, err
+ }
+ nextBySupplier[supplierId] = n
+ }
+
+ if nextToken, err := nextMulti(nextBySupplier); err != nil {
+ return jmap.ZeroResult[R](), NoNextToken, err
+ } else {
+ if state, err := combineState(states); err != nil {
+ return jmap.ZeroResult[R](), NoNextToken, err
+ } else {
+ return jmap.Result[R]{
+ Payload: r,
+ SessionState: sessionState,
+ State: state,
+ Language: lang,
+ }, nextToken, nil
+ }
+ }
+ }
+}
+
+const combinedStateEncodingPrefix = "="
+
+func combineState[S jmap.State | jmap.SessionState](m map[string]S) (S, error) {
+ if b, err := json.Marshal(m); err != nil {
+ return "", err
+ } else {
+ s := combinedStateEncodingPrefix + base64.RawURLEncoding.EncodeToString(b)
+ return S(s), nil
+ }
+}
+
+func splitState[S jmap.State | jmap.SessionState](state S) (map[string]S, error) {
+ s := string(state)
+ if strings.HasPrefix(s, combinedStateEncodingPrefix) {
+ if b, err := base64.RawURLEncoding.DecodeString(s[len(combinedStateEncodingPrefix):]); err != nil {
+ return nil, err
+ } else {
+ m := map[string]S{}
+ if err := json.Unmarshal(b, &m); err != nil {
+ return nil, err
+ } else {
+ return m, nil
+ }
+ }
+ } else {
+ return map[string]S{"jmap": state}, nil
+ }
+}
diff --git a/services/groupware/pkg/groupware/suppliers_test.go b/services/groupware/pkg/groupware/suppliers_test.go
new file mode 100644
index 0000000000..5d2fa43ce5
--- /dev/null
+++ b/services/groupware/pkg/groupware/suppliers_test.go
@@ -0,0 +1,223 @@
+package groupware
+
+import (
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/opencloud-eu/opencloud/pkg/jmap"
+ "github.com/stretchr/testify/require"
+)
+
+type PetSupplier struct {
+ id string
+ petsByAccountId map[string][]Pet
+}
+
+// var _ ListSupplier[Pet, PetGetResponse] = &PetSupplier{}
+var _ QuerySupplier[Pet, *PetSearchResults, PetFilterElement, PetComparator] = &PetSupplier{}
+
+func (s *PetSupplier) GetId() string {
+ return s.id
+}
+func (s *PetSupplier) IsMine(id string) bool {
+ return strings.HasPrefix(id, s.id+":")
+}
+func (s *PetSupplier) Query(accountIds []string, qps QueryParamsSupplier, limit *uint, filter PetFilterElement, sortBy []PetComparator, calculateTotal bool, ctx jmap.Context) (jmap.Result[map[string]*PetSearchResults], error) {
+ return inmemquery(s.id, s.petsByAccountId, accountIds, qps, limit, calculateTotal,
+ func(results []Pet, canCalculateChanges jmap.ChangeCalculation, position *uint, limit *uint, total *uint) *PetSearchResults {
+ return &PetSearchResults{Results: results, CanCalculateChanges: canCalculateChanges, Position: position, Limit: limit, Total: total}
+ },
+ )
+}
+
+func inmemquery[T jmap.Idable, R jmap.SearchResults[T]](
+ supplierId string,
+ store map[string][]T,
+ accountIds []string,
+ qps QueryParamsSupplier, limit *uint,
+ calculateTotal bool,
+ searchResultCtor func(results []T, canCalculateChanges jmap.ChangeCalculation, position *uint, limit *uint, total *uint) R,
+) (jmap.Result[map[string]R], error) {
+ payload := make(map[string]R, len(accountIds))
+ for _, accountId := range accountIds {
+ qp := jmap.NullQueryParams
+ if q, ok, err := qps.ForSupplier(supplierId, accountId); err != nil {
+ return jmap.ZeroResult[map[string]R](), err
+ } else if ok {
+ qp = q
+ }
+
+ items := store[accountId]
+ if items == nil {
+ items = []T{}
+ }
+ total := len(items)
+ all := []T{}
+ p := uint(qp.Position)
+ if qp.Position < total {
+ all = items[qp.Position:]
+ }
+ if qp.Anchor != "" {
+ a := slices.IndexFunc(all, func(e T) bool { return e.GetId() == qp.Anchor })
+ if a >= 0 {
+ if qp.AnchorOffset != nil {
+ a += int(*qp.AnchorOffset)
+ } else {
+ a += 1
+ }
+ p += uint(a)
+ if a < len(all) {
+ all = all[a:]
+ } else {
+ all = []T{}
+ }
+ } else {
+ all = []T{}
+ }
+ }
+ if limit != nil {
+ if len(all) > int(*limit) {
+ all = all[:int(*limit)]
+ }
+ }
+
+ var t *uint = nil
+ if calculateTotal {
+ ut := uint(total)
+ t = &ut
+ }
+ res := searchResultCtor(all, false, &p, limit, t)
+
+ payload[accountId] = res
+ }
+ return jmap.Result[map[string]R]{
+ Payload: payload,
+ SessionState: jmap.EmptySessionState,
+ State: jmap.EmptyState,
+ Language: jmap.NoLanguage,
+ }, nil
+}
+
+func pets(
+ suppliers []QuerySupplier[Pet, *PetSearchResults, PetFilterElement, PetComparator],
+ accountIds []string, qps QueryParamsSupplier, limit *uint,
+ filter PetFilterElement, sortBy []PetComparator,
+ calculateTotal bool,
+ ctx jmap.Context) (jmap.Result[*PetSearchResults], NextToken, error) {
+ return squery(suppliers, accountIds, qps, limit, filter, sortBy, calculateTotal, ctx,
+ func(a, b Pet) int { return strings.Compare(a.name, b.name) },
+ func(canCalculateChanges jmap.ChangeCalculation, position, limit, total *uint, results []Pet) *PetSearchResults {
+ return &PetSearchResults{
+ Results: results,
+ CanCalculateChanges: canCalculateChanges,
+ Position: position,
+ Limit: limit,
+ Total: total,
+ }
+ },
+ )
+}
+
+func TestSquery(t *testing.T) {
+ require := require.New(t)
+
+ suppliers := []QuerySupplier[Pet, *PetSearchResults, PetFilterElement, PetComparator]{
+ &PetSupplier{
+ id: "X",
+ petsByAccountId: map[string][]Pet{
+ "a": {
+ {id: "X:1", name: "ace"},
+ {id: "X:2", name: "bella"},
+ },
+ "b": {
+ {id: "X:3", name: "mitzi"},
+ },
+ },
+ },
+ &PetSupplier{
+ id: "Y",
+ petsByAccountId: map[string][]Pet{
+ "a": {
+ {id: "Y:1", name: "cupcake"},
+ {id: "Y:2", name: "elvis"},
+ },
+ "c": {
+ {id: "Y:3", name: "fluffy"},
+ {id: "Y:4", name: "rambo"},
+ },
+ },
+ },
+ }
+ f := func(accountIds []string, position int, anchor string, anchorOffset *int, limit *uint) (jmap.Result[*PetSearchResults], NextToken, error) {
+ return pets(suppliers, accountIds,
+ StaticQueryParamsSupplier{qp: jmap.QueryParams{Position: position, Anchor: anchor, AnchorOffset: anchorOffset}}, limit,
+ PetFilterCondition{}, []PetComparator{{Property: "id", IsAscending: true}},
+ true, jmap.Context{},
+ )
+ }
+ n := func(accountIds []string, nextToken NextToken, limit *uint) (jmap.Result[*PetSearchResults], NextToken, error) {
+ if qps, err := unnext(nextToken); err != nil {
+ return jmap.ZeroResult[*PetSearchResults](), NoNextToken, err
+ } else {
+ return pets(suppliers, accountIds, qps, limit, PetFilterCondition{}, []PetComparator{{Property: "id", IsAscending: true}}, true, jmap.Context{})
+ }
+ }
+
+ {
+ res, n, err := f([]string{"a", "b", "c"}, 0, "", nil, nil)
+ require.NoError(err)
+ require.Len(res.Payload.Results, 7)
+ require.Equal(uint(len(res.Payload.Results)), *res.Payload.Total)
+ {
+ require.Equal("ace", res.Payload.Results[0].name)
+ require.Equal("bella", res.Payload.Results[1].name)
+ require.Equal("cupcake", res.Payload.Results[2].name)
+ require.Equal("elvis", res.Payload.Results[3].name)
+ require.Equal("fluffy", res.Payload.Results[4].name)
+ require.Equal("mitzi", res.Payload.Results[5].name)
+ require.Equal("rambo", res.Payload.Results[6].name)
+ }
+ require.Equal(jmap.IncapableOfChangeCalculation, res.Payload.CanCalculateChanges)
+ require.Nil(res.Payload.Limit)
+ require.Equal(uint(0), *res.Payload.Position)
+ {
+ m, err := unnext(n)
+ require.NoError(err)
+ require.NotEmpty(m)
+ }
+ }
+ var nextToken NextToken
+ {
+ res, n, err := f([]string{"a", "b", "c"}, 0, "", nil, uintPtr(4))
+ nextToken = n
+ require.NoError(err)
+ require.Len(res.Payload.Results, 4)
+ require.Equal(uint(7), *res.Payload.Total)
+ {
+ require.Equal("ace", res.Payload.Results[0].name)
+ require.Equal("bella", res.Payload.Results[1].name)
+ require.Equal("cupcake", res.Payload.Results[2].name)
+ require.Equal("elvis", res.Payload.Results[3].name)
+ }
+ require.Equal(jmap.IncapableOfChangeCalculation, res.Payload.CanCalculateChanges)
+ require.Equal(uint(4), *res.Payload.Limit)
+ require.Equal(uint(0), *res.Payload.Position)
+ {
+ m, err := unnext(n)
+ require.NoError(err)
+ require.NotEmpty(m)
+ }
+ }
+ {
+ res, _, err := n([]string{"a", "b", "c"}, nextToken, uintPtr(4))
+ require.NoError(err)
+ require.Equal(uint(7), *res.Payload.Total)
+ require.Len(res.Payload.Results, 3)
+ {
+ require.Equal("fluffy", res.Payload.Results[4].name)
+ require.Equal("mitzi", res.Payload.Results[5].name)
+ require.Equal("rambo", res.Payload.Results[6].name)
+ }
+ }
+}
diff --git a/services/groupware/pkg/groupware/templates.go b/services/groupware/pkg/groupware/templates.go
index 6fd308ca60..7cbeddd9f7 100644
--- a/services/groupware/pkg/groupware/templates.go
+++ b/services/groupware/pkg/groupware/templates.go
@@ -10,12 +10,12 @@ import (
// Create a new {{.Name}} using the JSON payload in the body of the `{{.Verb}}` operation.
// @api:response 200:T returns the {{.Name}} that was just created
// @api:body CHANGE the {{.Name}} to create
-func create[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]](
+func create[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
bodyFunc func(r Request, accountId string, body *CHANGE, ctx jmap.Context) (bool, Response),
- createFunc func(accountId string, change CHANGE, ctx jmap.Context) (jmap.Result[*T], jmap.Error),
+ createFunc func(accountId string, change CHANGE, ctx jmap.Context) (jmap.Result[*T], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -43,11 +43,20 @@ func create[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]](
}
}
- result, jerr := createFunc(accountId, create, ctx)
- if jerr != nil {
- return req.jmapError(accountId, jerr, result)
+ if result, err := createFunc(accountId, create, ctx); err != nil {
+ switch e := err.(type) {
+ case jmap.Error:
+ return req.jmapError(accountId, e, result)
+ case GroupwareError:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, e))
+ default:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
+ }
+ } else {
+ return req.respond(accountId, result.Payload, o.responseType, result)
}
- return req.respond(accountId, result.Payload, o.responseType, result)
})
}
@@ -57,7 +66,7 @@ func getall[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.G
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
- getFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[RESP], jmap.Error),
+ getFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[RESP], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -72,15 +81,27 @@ func getall[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.G
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
- result, jerr := getFunc(accountId, []string{}, ctx)
- if jerr != nil {
- return req.jmapError(accountId, jerr, result)
+ if result, err := getFunc(accountId, []string{}, ctx); err != nil {
+ switch e := err.(type) {
+ case jmap.Error:
+ return req.jmapError(accountId, e, result)
+ case GroupwareError:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, e))
+ default:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
+ }
+ } else {
+ return req.respond(accountId, result.Payload, o.responseType, result)
}
- return req.respond(accountId, result.Payload, o.responseType, result)
})
}
-var paginationQueryParams = toSupportedQueryParams(QueryParamPosition, QueryParamAnchor, QueryParamAnchorOffset, QueryParamLimit)
+// var paginationQueryParams = toSupportedQueryParams(QueryParamPosition, QueryParamAnchor, QueryParamAnchorOffset, QueryParamLimit)
+var firstQueryParams = toSupportedQueryParams(QueryParamLimit)
+var nextQueryParams = toSupportedQueryParams(QueryParamNext, QueryParamLimit)
+var noQueryParams = toSupportedQueryParams()
// 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 +112,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, anchor string, anchorOffset *int, limit *uint, ctx jmap.Context) (jmap.Result[SEARCHRESULTS], jmap.Error),
+ queryFunc func(req Request, accountIds []string, qps QueryParamsSupplier, limit *uint, filter FILTER, sortBy []COMP, ctx jmap.Context) (jmap.Result[SEARCHRESULTS], NextToken, error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -100,31 +121,6 @@ func getallpaged[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], FILTER
}
l := req.logger.With().Str(accountId, log.SafeString(accountId))
- position, ok, err := req.parseIntParam(QueryParamPosition, 0)
- if err != nil {
- return req.error(accountId, err)
- }
- if ok {
- 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))
@@ -137,6 +133,51 @@ func getallpaged[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], FILTER
}
}
+ var qps QueryParamsSupplier
+ var supportedQueryParams = noQueryParams
+ {
+ if s, ok := req.getStringParam(QueryParamNext, ""); ok {
+ supportedQueryParams = nextQueryParams
+ if n, err := unnext(NextToken(s)); err != nil {
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(err.Error()))) // TODO replace ErrorGeneric
+ } else {
+ qps = n
+ }
+ } else {
+ supportedQueryParams = firstQueryParams
+ qps = FirstQueryParamsSupplier{}
+
+ /*
+ position, ok, err := req.parseIntParam(QueryParamPosition, 0)
+ if err != nil {
+ return req.error(accountId, err)
+ }
+ if ok {
+ 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
+ }
+ }
+ */
+
+ }
+ }
+
containerId := ""
if withContainerId && o.containerUriParamName != "" {
var err *Error
@@ -147,33 +188,28 @@ func getallpaged[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], FILTER
l = l.Str(o.containerUriParamName, log.SafeString(containerId))
}
- if notok, resp := req.unsupportedQueryParams(single(accountId), paginationQueryParams); notok {
+ if notok, resp := req.unsupportedQueryParams(single(accountId), supportedQueryParams); notok {
return resp
}
filter := filterFunc(containerId)
- jmaplimit := limit
- if limit != nil && *limit == 0 {
- jmaplimit = UintPtrOne
- }
-
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
- result, jerr := queryFunc(req, accountId, filter, sortBy, position, anchor, anchorOffset, jmaplimit, ctx)
- if jerr != nil {
- return req.jmapError(accountId, jerr, result)
+ if result, nextNextToken, err := queryFunc(req, single(accountId), qps, limit, filter, sortBy, ctx); err != nil {
+ switch e := err.(type) {
+ case jmap.Error:
+ return req.jmapError(accountId, e, result)
+ case GroupwareError:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, e))
+ default:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
+ }
+ } else {
+ return req.respondNext(accountId, result.Payload, o.responseType, result, nextNextToken)
}
-
- if limit != nil && *limit == 0 {
- result.Payload.RemoveResults()
- result.Payload.SetLimit(UintPtrZero)
- }
- if anchor != "" && result.Payload.GetPosition() != nil && *result.Payload.GetPosition() == 0 {
- result.Payload.SetPosition(nil)
- }
-
- return req.respond(accountId, result.Payload, o.responseType, result)
})
}
@@ -183,8 +219,8 @@ func query[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], SEARCHRESULT
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
- defaultLimit uint,
- queryFunc func(req Request, accountId string, containerId string, position int, anchor string, anchorOffset *int, limit *uint, ctx jmap.Context) (jmap.Result[SEARCHRESULTS], *Error),
+ defaultLimit *uint,
+ queryFunc func(req Request, accountId string, containerId string, qp jmap.QueryParams, limit *uint, ctx jmap.Context) (jmap.Result[SEARCHRESULTS], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -203,78 +239,134 @@ func query[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], SEARCHRESULT
l = l.Str(o.containerUriParamName, log.SafeString(containerId))
}
- position, ok, err := req.parseIntParam(QueryParamPosition, 0)
- if err != nil {
- return req.error(accountId, err)
- }
- if ok {
- 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)
+ v, ok, err := req.parseUIntParam(QueryParamLimit, 0)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Uint(QueryParamLimit, v)
limit = &v
- } else if defaultLimit > 0 {
- limit = &defaultLimit
+ } else {
+ limit = defaultLimit
}
}
+ var qp jmap.QueryParams
+ var supportedQueryParams = noQueryParams
+ {
+ if s, ok := req.getStringParam(QueryParamNext, ""); ok {
+ supportedQueryParams = nextQueryParams
+ if n, err := unnext(NextToken(s)); err != nil {
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(err.Error()))) // TODO replace ErrorGeneric
+ } else {
+ if q, ok, err := n.ForAccountId(accountId); err != nil {
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(err.Error()))) // TODO replace ErrorGeneric
+ } else if ok {
+ qp = q
+ } else {
+ qp = jmap.NullQueryParams
+ }
+ }
+ } else {
+ supportedQueryParams = firstQueryParams
+ qp = jmap.NullQueryParams
+ }
+ }
+
+ /*
+ position, ok, err := req.parseIntParam(QueryParamPosition, 0)
+ if err != nil {
+ return req.error(accountId, err)
+ }
+ if ok {
+ 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)
+ if err != nil {
+ return req.error(accountId, err)
+ }
+ if ok {
+ l = l.Uint(QueryParamLimit, v)
+ limit = &v
+ } else if defaultLimit > 0 {
+ limit = &defaultLimit
+ }
+ }
+ */
+
+ if notok, resp := req.unsupportedQueryParams(single(accountId), supportedQueryParams); notok {
+ return resp
+ }
+
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
- jmaplimit := limit
- if limit != nil && *limit == 0 {
- jmaplimit = UintPtrOne
- }
+ /*
+ jmaplimit := limit
+ if limit != nil && *limit == 0 {
+ jmaplimit = UintPtrOne
+ }
+ */
- result, err := queryFunc(req, accountId, containerId, position, anchor, anchorOffset, jmaplimit, ctx)
- if err != nil {
- return req.error(accountId, err)
- }
+ if result, err := queryFunc(req, accountId, containerId, qp, limit, ctx); err != nil {
+ switch e := err.(type) {
+ case jmap.Error:
+ return req.jmapError(accountId, e, result)
+ case GroupwareError:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, e))
+ default:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
+ }
+ } else {
+ /*
+ if limit != nil && *limit == 0 {
+ result.Payload.RemoveResults()
+ result.Payload.SetLimit(UintPtrZero)
+ }
+ if anchor != "" && result.Payload.GetPosition() != nil && *result.Payload.GetPosition() == 0 {
+ result.Payload.SetPosition(nil)
+ }
+ */
- if limit != nil && *limit == 0 {
- result.Payload.RemoveResults()
- result.Payload.SetLimit(UintPtrZero)
+ return req.respond(accountId, result.Payload, o.responseType, result)
}
- if anchor != "" && result.Payload.GetPosition() != nil && *result.Payload.GetPosition() == 0 {
- result.Payload.SetPosition(nil)
- }
-
- return req.respond(accountId, result.Payload, o.responseType, result)
})
}
// Retrieve a specific {{.Name}} referenced by its unique identifier as specified in the path parameter `{{.UriParamName}}` in the path `{{.Path}}`
// @api:response 200:T returns the {{.Name}} that matches the requested identifier, if it exists
// @api:response 404 when there is no {{.Name}} for the requested identifier
-func get[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.GetResponse[T]](
+func get[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.GetResponse[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
- getFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[RESP], jmap.Error),
+ getFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[RESP], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -298,20 +390,28 @@ func get[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.GetR
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
- result, jerr := getFunc(accountId, ids, ctx)
- if jerr != nil {
- return req.jmapError(accountId, jerr, result)
- }
-
- n := len(result.Payload.GetList())
- switch n {
- case 0:
- return req.notFound(accountId, ContactResponseObjectType, result)
- case 1:
- return req.respond(accountId, result.Payload.GetList()[0], ContactResponseObjectType, result)
- default:
- logger.Error().Msgf("found %d %s matching '%s' instead of 1", n, o.responseType, ids)
- return req.errorS(accountId, req.apiError(&ErrorMultipleIdMatches), result)
+ if result, err := getFunc(accountId, ids, ctx); err != nil {
+ switch e := err.(type) {
+ case jmap.Error:
+ return req.jmapError(accountId, e, result)
+ case GroupwareError:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, e))
+ default:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
+ }
+ } else {
+ n := len(result.Payload.GetList())
+ switch n {
+ case 0:
+ return req.notFound(accountId, ContactResponseObjectType, result)
+ case 1:
+ return req.respond(accountId, result.Payload.GetList()[0], ContactResponseObjectType, result)
+ default:
+ logger.Error().Msgf("found %d %s matching '%s' instead of 1", n, o.responseType, ids)
+ return req.errorS(accountId, req.apiError(&ErrorMultipleIdMatches), result)
+ }
}
})
}
@@ -319,11 +419,11 @@ func get[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.GetR
// Retrieve a specific {{.Name}} referenced by its unique identifier as specified in the path parameter `{{.UriParamName}}` in the path `{{.Path}}`
// @api:response 200:T returns the {{.Name}} that matches the requested identifier, if it exists
// @api:response 404 when there is no {{.Name}} for the requested identifier
-func getFromMap[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.GetResponse[T]](
+func getFromMap[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jmap.GetResponse[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
- getFunc func(accountIds []string, ids []string, ctx jmap.Context) (jmap.Result[map[string]RESP], jmap.Error),
+ getFunc func(accountIds []string, ids []string, ctx jmap.Context) (jmap.Result[map[string]RESP], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -343,24 +443,32 @@ func getFromMap[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T], RESP jm
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
- result, jerr := getFunc(single(accountId), single(id), ctx)
- if jerr != nil {
- return req.jmapError(accountId, jerr, result)
- }
-
- if objs, ok := result.Payload[accountId]; ok {
- n := len(objs.GetList())
- switch n {
- case 0:
- return req.notFound(accountId, ContactResponseObjectType, result)
- case 1:
- return req.respond(accountId, objs.GetList()[0], ContactResponseObjectType, result)
+ if result, err := getFunc(single(accountId), single(id), ctx); err != nil {
+ switch e := err.(type) {
+ case jmap.Error:
+ return req.jmapError(accountId, e, result)
+ case GroupwareError:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, e))
default:
- logger.Error().Msgf("found %d %s matching '%s' instead of 1", n, o.responseType, id)
- return req.errorS(accountId, req.apiError(&ErrorMultipleIdMatches), result)
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
}
} else {
- return req.notFound(accountId, ContactResponseObjectType, result)
+ if objs, ok := result.Payload[accountId]; ok {
+ n := len(objs.GetList())
+ switch n {
+ case 0:
+ return req.notFound(accountId, ContactResponseObjectType, result)
+ case 1:
+ return req.respond(accountId, objs.GetList()[0], ContactResponseObjectType, result)
+ default:
+ logger.Error().Msgf("found %d %s matching '%s' instead of 1", n, o.responseType, id)
+ return req.errorS(accountId, req.apiError(&ErrorMultipleIdMatches), result)
+ }
+ } else {
+ return req.notFound(accountId, ContactResponseObjectType, result)
+ }
}
})
}
@@ -370,11 +478,11 @@ var changesSupportedQueryParams = toSupportedQueryParams(QueryParamMaxChanges)
// Retrieve the changes that occured for {{.Name}}, optionally since an opaque state specified using the header `{{.HeaderParam.HeaderParamSince}}`,
// optionally bounded by the query parameter `{{.QueryParam.QueryParamMaxChanges}}`.
// @api:response 200:CHANGES returns the changes to {{.Names}}: created, updated, and identifiers of destroyed {{.Names}}
-func changes[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]](
+func changes[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
- changesFunc func(accountId string, sinceState jmap.State, maxChanges uint, ctx jmap.Context) (jmap.Result[CHANGES], jmap.Error),
+ changesFunc func(accountId string, sinceState jmap.State, maxChanges uint, ctx jmap.Context) (jmap.Result[CHANGES], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -402,12 +510,20 @@ func changes[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]](
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
- result, jerr := changesFunc(accountId, sinceState, maxChanges, ctx)
- if jerr != nil {
- return req.jmapError(accountId, jerr, result)
+ if result, err := changesFunc(accountId, sinceState, maxChanges, ctx); err != nil {
+ switch e := err.(type) {
+ case jmap.Error:
+ return req.jmapError(accountId, e, result)
+ case GroupwareError:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, e))
+ default:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
+ }
+ } else {
+ return req.respond(accountId, result.Payload, o.responseType, result)
}
-
- return req.respond(accountId, result.Payload, o.responseType, result)
})
}
@@ -419,7 +535,7 @@ func delete[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSONAR
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
- deleteFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[map[string]jmap.SetError], jmap.Error),
+ deleteFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[map[string]jmap.SetError], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -439,27 +555,35 @@ func delete[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSONAR
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
- result, jerr := deleteFunc(accountId, single(id), ctx)
- if jerr != nil {
- return req.jmapError(accountId, jerr, result)
- }
-
- for _, e := range result.Payload {
- desc := e.Description
- if desc != "" {
- return req.error(accountId, apiError(
- req.errorId(),
- o.failedToDeleteError,
- withDetail(e.Description),
- ))
- } else {
- return req.error(accountId, apiError(
- req.errorId(),
- o.failedToDeleteError,
- ))
+ if result, err := deleteFunc(accountId, single(id), ctx); err != nil {
+ switch e := err.(type) {
+ case jmap.Error:
+ return req.jmapError(accountId, e, result)
+ case GroupwareError:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, e))
+ default:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
}
+ } else {
+ for _, e := range result.Payload {
+ desc := e.Description
+ if desc != "" {
+ return req.error(accountId, apiError(
+ req.errorId(),
+ o.failedToDeleteError,
+ withDetail(e.Description),
+ ))
+ } else {
+ return req.error(accountId, apiError(
+ req.errorId(),
+ o.failedToDeleteError,
+ ))
+ }
+ }
+ return req.noContent(accountId, o.responseType, result)
}
- return req.noContent(accountId, o.responseType, result)
})
}
@@ -473,7 +597,7 @@ func deleteMany[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSO
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
- deleteFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[map[string]jmap.SetError], jmap.Error),
+ deleteFunc func(accountId string, ids []string, ctx jmap.Context) (jmap.Result[map[string]jmap.SetError], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -524,27 +648,35 @@ func deleteMany[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]]( //NOSO
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
- result, jerr := deleteFunc(accountId, ids, ctx)
- if jerr != nil {
- return req.jmapError(accountId, jerr, result)
- }
-
- for _, e := range result.Payload {
- desc := e.Description
- if desc != "" {
- return req.error(accountId, apiError(
- req.errorId(),
- o.failedToDeleteError,
- withDetail(e.Description),
- ))
- } else {
- return req.error(accountId, apiError(
- req.errorId(),
- o.failedToDeleteError,
- ))
+ if result, err := deleteFunc(accountId, ids, ctx); err != nil {
+ switch e := err.(type) {
+ case jmap.Error:
+ return req.jmapError(accountId, e, result)
+ case GroupwareError:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, e))
+ default:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
}
+ } else {
+ for _, e := range result.Payload {
+ desc := e.Description
+ if desc != "" {
+ return req.error(accountId, apiError(
+ req.errorId(),
+ o.failedToDeleteError,
+ withDetail(e.Description),
+ ))
+ } else {
+ return req.error(accountId, apiError(
+ req.errorId(),
+ o.failedToDeleteError,
+ ))
+ }
+ }
+ return req.noContent(accountId, o.responseType, result)
}
- return req.noContent(accountId, o.responseType, result)
})
}
@@ -554,7 +686,7 @@ func modify[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]](
o ObjectType[T, CHANGE, CHANGES],
w http.ResponseWriter, r *http.Request,
g *Groupware,
- updateFunc func(accountId string, id string, change CHANGE, ctx jmap.Context) (jmap.Result[T], jmap.Error),
+ updateFunc func(accountId string, id string, change CHANGE, ctx jmap.Context) (jmap.Result[T], error),
) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := o.accountFunc(&req)
@@ -580,10 +712,19 @@ func modify[T jmap.Foo, CHANGE jmap.Change, CHANGES jmap.Changes[T]](
logger := log.From(l)
ctx := req.ctx.WithLogger(logger)
- result, jerr := updateFunc(accountId, id, change, ctx)
- if jerr != nil {
- return req.jmapError(accountId, jerr, result)
+ if result, err := updateFunc(accountId, id, change, ctx); err != nil {
+ switch e := err.(type) {
+ case jmap.Error:
+ return req.jmapError(accountId, e, result)
+ case GroupwareError:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, e))
+ default:
+ errorId := req.errorId()
+ return req.error(accountId, apiError(errorId, ErrorGeneric, withDetail(e.Error())))
+ }
+ } else {
+ return req.respond(accountId, result.Payload, o.responseType, result)
}
- return req.respond(accountId, result.Payload, o.responseType, result)
})
}
diff --git a/services/groupware/pkg/groupware/tools.go b/services/groupware/pkg/groupware/tools.go
index 6daef93520..08416d4a7c 100644
--- a/services/groupware/pkg/groupware/tools.go
+++ b/services/groupware/pkg/groupware/tools.go
@@ -33,3 +33,11 @@ func ptrIf[T any | uint | int | bool](t T, predicate bool) *T {
return nil
}
}
+
+func ptrIfNot[T comparable](t T, notThis T) *T {
+ if t != notThis {
+ return &t
+ } else {
+ return nil
+ }
+}