From 2378eca6d83d021e4f4f04b83eea4d8814bfe404 Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Thu, 26 Mar 2026 16:25:45 +0100
Subject: [PATCH] groupware: framework refactorings + add support for /changes
+ add /objects
---
pkg/jmap/api_blob.go | 8 +-
pkg/jmap/api_calendar.go | 93 ++++-
pkg/jmap/api_changes.go | 144 ++++++++
pkg/jmap/api_contact.go | 172 ++++-----
pkg/jmap/api_email.go | 23 +-
pkg/jmap/api_identity.go | 90 ++---
pkg/jmap/api_mailbox.go | 336 ++++++++----------
pkg/jmap/api_objects.go | 118 ++++++
pkg/jmap/api_quota.go | 1 +
pkg/jmap/api_vacation.go | 2 +-
pkg/jmap/client.go | 6 +-
pkg/jmap/error.go | 38 +-
pkg/jmap/http.go | 60 ++--
pkg/jmap/integration_test.go | 2 +
pkg/jmap/model.go | 284 +++++++++++----
pkg/jmap/model_examples.go | 47 +++
pkg/jmap/session.go | 12 +-
pkg/jmap/templates.go | 160 ++++++++-
pkg/jmap/tools.go | 46 ++-
pkg/jmap/tools_test.go | 11 +
.../groupware/pkg/groupware/api_account.go | 2 +-
.../groupware/pkg/groupware/api_calendars.go | 68 ++++
.../groupware/pkg/groupware/api_changes.go | 78 ++++
.../groupware/pkg/groupware/api_contacts.go | 44 ++-
.../groupware/pkg/groupware/api_mailbox.go | 12 +-
.../groupware/pkg/groupware/api_objects.go | 106 ++++++
services/groupware/pkg/groupware/error.go | 9 +
services/groupware/pkg/groupware/request.go | 44 ++-
.../groupware/pkg/groupware/request_test.go | 36 ++
services/groupware/pkg/groupware/route.go | 16 +-
services/idm/ldif/demousers.ldif.tmpl | 1 +
31 files changed, 1568 insertions(+), 501 deletions(-)
create mode 100644 pkg/jmap/api_changes.go
create mode 100644 pkg/jmap/api_objects.go
create mode 100644 services/groupware/pkg/groupware/api_changes.go
create mode 100644 services/groupware/pkg/groupware/api_objects.go
diff --git a/pkg/jmap/api_blob.go b/pkg/jmap/api_blob.go
index c6ab466cb7..c44a5bf904 100644
--- a/pkg/jmap/api_blob.go
+++ b/pkg/jmap/api_blob.go
@@ -31,7 +31,7 @@ func (j *Client) GetBlobMetadata(accountId string, session *Session, ctx context
if len(response.List) != 1 {
logger.Error().Msgf("%T.List has %v entries instead of 1", response, len(response.List))
- return nil, "", simpleError(err, JmapErrorInvalidJmapResponsePayload)
+ return nil, "", jmapError(err, JmapErrorInvalidJmapResponsePayload)
}
get := response.List[0]
return &get, response.State, nil
@@ -112,17 +112,17 @@ func (j *Client) UploadBlob(accountId string, session *Session, ctx context.Cont
if len(uploadResponse.Created) != 1 {
logger.Error().Msgf("%T.Created has %v entries instead of 1", uploadResponse, len(uploadResponse.Created))
- return UploadedBlobWithHash{}, "", simpleError(err, JmapErrorInvalidJmapResponsePayload)
+ return UploadedBlobWithHash{}, "", jmapError(err, JmapErrorInvalidJmapResponsePayload)
}
upload, ok := uploadResponse.Created["0"]
if !ok {
logger.Error().Msgf("%T.Created has no item '0'", uploadResponse)
- return UploadedBlobWithHash{}, "", simpleError(err, JmapErrorInvalidJmapResponsePayload)
+ return UploadedBlobWithHash{}, "", jmapError(err, JmapErrorInvalidJmapResponsePayload)
}
if len(getResponse.List) != 1 {
logger.Error().Msgf("%T.List has %v entries instead of 1", getResponse, len(getResponse.List))
- return UploadedBlobWithHash{}, "", simpleError(err, JmapErrorInvalidJmapResponsePayload)
+ return UploadedBlobWithHash{}, "", jmapError(err, JmapErrorInvalidJmapResponsePayload)
}
get := getResponse.List[0]
diff --git a/pkg/jmap/api_calendar.go b/pkg/jmap/api_calendar.go
index 083fa6e986..3de6d55925 100644
--- a/pkg/jmap/api_calendar.go
+++ b/pkg/jmap/api_calendar.go
@@ -45,6 +45,52 @@ func (j *Client) GetCalendars(accountId string, session *Session, ctx context.Co
)
}
+type CalendarChanges struct {
+ HasMoreChanges bool `json:"hasMoreChanges"`
+ OldState State `json:"oldState,omitempty"`
+ NewState State `json:"newState"`
+ Created []Calendar `json:"created,omitempty"`
+ Updated []Calendar `json:"updated,omitempty"`
+ Destroyed []string `json:"destroyed,omitempty"`
+}
+
+// Retrieve Calendar changes since a given state.
+// @apidoc calendar,changes
+func (j *Client) GetCalendarChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceState State, maxChanges uint) (CalendarChanges, SessionState, State, Language, Error) {
+ return changesTemplate(j, "GetCalendarChanges",
+ CommandCalendarChanges, CommandCalendarGet,
+ func() CalendarChangesCommand {
+ return CalendarChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: posUIntPtr(maxChanges)}
+ },
+ func(path string, rof string) CalendarGetRefCommand {
+ return CalendarGetRefCommand{
+ AccountId: accountId,
+ IdsRef: &ResultReference{
+ Name: CommandCalendarChanges,
+ Path: path,
+ ResultOf: rof,
+ },
+ }
+ },
+ func(resp CalendarChangesResponse) (State, State, bool, []string) {
+ return resp.OldState, resp.NewState, resp.HasMoreChanges, resp.Destroyed
+ },
+ func(resp CalendarGetResponse) []Calendar { return resp.List },
+ func(oldState, newState State, hasMoreChanges bool, created, updated []Calendar, destroyed []string) CalendarChanges {
+ return CalendarChanges{
+ OldState: oldState,
+ NewState: newState,
+ HasMoreChanges: hasMoreChanges,
+ Created: created,
+ Updated: updated,
+ Destroyed: destroyed,
+ }
+ },
+ func(resp CalendarGetResponse) State { return resp.State },
+ session, ctx, logger, acceptLanguage,
+ )
+}
+
func (j *Client) QueryCalendarEvents(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, //NOSONAR
filter CalendarEventFilterElement, sortBy []CalendarEventComparator,
position uint, limit uint) (map[string][]CalendarEvent, SessionState, State, Language, Error) {
@@ -95,7 +141,7 @@ func (j *Client) QueryCalendarEvents(accountIds []string, session *Session, ctx
return nil, "", err
}
if len(response.NotFound) > 0 {
- // TODO what to do when there are not-found emails here? potentially nothing, they could have been deleted between query and get?
+ // TODO what to do when there are not-found calendarevents here? potentially nothing, they could have been deleted between query and get?
}
resp[accountId] = response.List
stateByAccountId[accountId] = response.State
@@ -104,6 +150,51 @@ func (j *Client) QueryCalendarEvents(accountIds []string, session *Session, ctx
})
}
+type CalendarEventChanges struct {
+ OldState State `json:"oldState,omitempty"`
+ NewState State `json:"newState"`
+ HasMoreChanges bool `json:"hasMoreChanges"`
+ Created []CalendarEvent `json:"created,omitempty"`
+ Updated []CalendarEvent `json:"updated,omitempty"`
+ Destroyed []string `json:"destroyed,omitempty"`
+}
+
+func (j *Client) GetCalendarEventChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger,
+ acceptLanguage string, sinceState State, maxChanges uint) (CalendarEventChanges, SessionState, State, Language, Error) {
+ return changesTemplate(j, "GetCalendarEventChanges",
+ CommandCalendarEventChanges, CommandCalendarEventGet,
+ func() CalendarEventChangesCommand {
+ return CalendarEventChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: posUIntPtr(maxChanges)}
+ },
+ func(path string, rof string) CalendarEventGetRefCommand {
+ return CalendarEventGetRefCommand{
+ AccountId: accountId,
+ IdsRef: &ResultReference{
+ Name: CommandCalendarEventChanges,
+ Path: path,
+ ResultOf: rof,
+ },
+ }
+ },
+ func(resp CalendarEventChangesResponse) (State, State, bool, []string) {
+ return resp.OldState, resp.NewState, resp.HasMoreChanges, resp.Destroyed
+ },
+ func(resp CalendarEventGetResponse) []CalendarEvent { return resp.List },
+ func(oldState, newState State, hasMoreChanges bool, created, updated []CalendarEvent, destroyed []string) CalendarEventChanges {
+ return CalendarEventChanges{
+ OldState: oldState,
+ NewState: newState,
+ HasMoreChanges: hasMoreChanges,
+ Created: created,
+ Updated: updated,
+ Destroyed: destroyed,
+ }
+ },
+ func(resp CalendarEventGetResponse) State { return resp.State },
+ session, ctx, logger, acceptLanguage,
+ )
+}
+
func (j *Client) CreateCalendarEvent(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, create CalendarEvent) (*CalendarEvent, SessionState, State, Language, Error) {
return createTemplate(j, "CreateCalendarEvent", CalendarEventType, CommandCalendarEventSet, CommandCalendarEventGet,
func(accountId string, create map[string]CalendarEvent) CalendarEventSetCommand {
diff --git a/pkg/jmap/api_changes.go b/pkg/jmap/api_changes.go
new file mode 100644
index 0000000000..da319b2ede
--- /dev/null
+++ b/pkg/jmap/api_changes.go
@@ -0,0 +1,144 @@
+package jmap
+
+import (
+ "context"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/rs/zerolog"
+)
+
+type Changes struct {
+ MaxChanges uint `json:"maxchanges,omitzero"`
+ Mailboxes *MailboxChangesResponse `json:"mailboxes,omitempty"`
+ Emails *EmailChangesResponse `json:"emails,omitempty"`
+ Calendars *CalendarChangesResponse `json:"calendars,omitempty"`
+ Events *CalendarEventChangesResponse `json:"events,omitempty"`
+ Addressbooks *AddressBookChangesResponse `json:"addressbooks,omitempty"`
+ Contacts *ContactCardChangesResponse `json:"contacts,omitempty"`
+}
+
+type StateMap struct {
+ Mailboxes *State `json:"mailboxes,omitempty"`
+ Emails *State `json:"emails,omitempty"`
+ Calendars *State `json:"calendars,omitempty"`
+ Events *State `json:"events,omitempty"`
+ Addressbooks *State `json:"addressbooks,omitempty"`
+ Contacts *State `json:"contacts,omitempty"`
+}
+
+var _ zerolog.LogObjectMarshaler = StateMap{}
+
+func (s StateMap) IsZero() bool {
+ return s.Mailboxes == nil && s.Emails == nil && s.Calendars == nil &&
+ s.Events == nil && s.Addressbooks == nil && s.Contacts == nil
+}
+
+func (s StateMap) MarshalZerologObject(e *zerolog.Event) {
+ if s.Mailboxes != nil {
+ e.Str("mailboxes", string(*s.Mailboxes))
+ }
+ if s.Emails != nil {
+ e.Str("emails", string(*s.Emails))
+ }
+ if s.Calendars != nil {
+ e.Str("calendars", string(*s.Calendars))
+ }
+ if s.Events != nil {
+ e.Str("events", string(*s.Events))
+ }
+ if s.Addressbooks != nil {
+ e.Str("addressbooks", string(*s.Addressbooks))
+ }
+ if s.Contacts != nil {
+ e.Str("contacts", string(*s.Contacts))
+ }
+}
+
+func (j *Client) GetChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, stateMap StateMap, maxChanges uint) (Changes, SessionState, State, Language, Error) { //NOSONAR
+ logger = log.From(j.logger("GetChanges", session, logger).With().Object("state", stateMap).Uint("maxChanges", maxChanges))
+
+ methodCalls := []Invocation{}
+ if stateMap.Mailboxes != nil {
+ methodCalls = append(methodCalls, invocation(CommandMailboxChanges, MailboxChangesCommand{AccountId: accountId, SinceState: *stateMap.Mailboxes, MaxChanges: posUIntPtr(maxChanges)}, "mailboxes"))
+ }
+ if stateMap.Emails != nil {
+ methodCalls = append(methodCalls, invocation(CommandEmailChanges, EmailChangesCommand{AccountId: accountId, SinceState: *stateMap.Emails, MaxChanges: posUIntPtr(maxChanges)}, "emails"))
+ }
+ if stateMap.Calendars != nil {
+ methodCalls = append(methodCalls, invocation(CommandCalendarChanges, CalendarChangesCommand{AccountId: accountId, SinceState: *stateMap.Calendars, MaxChanges: posUIntPtr(maxChanges)}, "calendars"))
+ }
+ if stateMap.Events != nil {
+ methodCalls = append(methodCalls, invocation(CommandCalendarEventChanges, CalendarEventChangesCommand{AccountId: accountId, SinceState: *stateMap.Events, MaxChanges: posUIntPtr(maxChanges)}, "events"))
+ }
+ if stateMap.Addressbooks != nil {
+ methodCalls = append(methodCalls, invocation(CommandAddressBookChanges, AddressBookChangesCommand{AccountId: accountId, SinceState: *stateMap.Addressbooks, MaxChanges: posUIntPtr(maxChanges)}, "addressbooks"))
+ }
+ if stateMap.Addressbooks != nil {
+ methodCalls = append(methodCalls, invocation(CommandAddressBookChanges, AddressBookChangesCommand{AccountId: accountId, SinceState: *stateMap.Addressbooks, MaxChanges: posUIntPtr(maxChanges)}, "addressbooks"))
+ }
+ if stateMap.Contacts != nil {
+ methodCalls = append(methodCalls, invocation(CommandContactCardChanges, ContactCardChangesCommand{AccountId: accountId, SinceState: *stateMap.Contacts, MaxChanges: posUIntPtr(maxChanges)}, "contacts"))
+ }
+
+ cmd, err := j.request(session, logger, methodCalls...)
+ if err != nil {
+ return Changes{}, "", "", "", err
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (Changes, State, Error) {
+ changes := Changes{
+ MaxChanges: maxChanges,
+ }
+ states := map[string]State{}
+
+ var mailboxes MailboxChangesResponse
+ if ok, err := tryRetrieveResponseMatchParameters(logger, body, CommandMailboxChanges, "mailboxes", &mailboxes); err != nil {
+ return Changes{}, "", err
+ } else if ok {
+ changes.Mailboxes = &mailboxes
+ states["mailbox"] = mailboxes.NewState
+ }
+
+ var emails EmailChangesResponse
+ if ok, err := tryRetrieveResponseMatchParameters(logger, body, CommandEmailChanges, "emails", &emails); err != nil {
+ return Changes{}, "", err
+ } else if ok {
+ changes.Emails = &emails
+ states["emails"] = emails.NewState
+ }
+
+ var calendars CalendarChangesResponse
+ if ok, err := tryRetrieveResponseMatchParameters(logger, body, CommandCalendarChanges, "calendars", &calendars); err != nil {
+ return Changes{}, "", err
+ } else if ok {
+ changes.Calendars = &calendars
+ states["calendars"] = calendars.NewState
+ }
+
+ var events CalendarEventChangesResponse
+ if ok, err := tryRetrieveResponseMatchParameters(logger, body, CommandCalendarEventChanges, "events", &events); err != nil {
+ return Changes{}, "", err
+ } else if ok {
+ changes.Events = &events
+ states["events"] = events.NewState
+ }
+
+ var addressbooks AddressBookChangesResponse
+ if ok, err := tryRetrieveResponseMatchParameters(logger, body, CommandAddressBookChanges, "addressbooks", &addressbooks); err != nil {
+ return Changes{}, "", err
+ } else if ok {
+ changes.Addressbooks = &addressbooks
+ states["addressbooks"] = addressbooks.NewState
+ }
+
+ var contacts ContactCardChangesResponse
+ if ok, err := tryRetrieveResponseMatchParameters(logger, body, CommandContactCardChanges, "contacts", &contacts); err != nil {
+ return Changes{}, "", err
+ } else if ok {
+ changes.Contacts = &contacts
+ states["contacts"] = contacts.NewState
+ }
+
+ return changes, squashKeyedStates(states), nil
+ })
+}
diff --git a/pkg/jmap/api_contact.go b/pkg/jmap/api_contact.go
index 7bdbd9a58a..92ddd47fa1 100644
--- a/pkg/jmap/api_contact.go
+++ b/pkg/jmap/api_contact.go
@@ -37,6 +37,52 @@ func (j *Client) GetAddressbooks(accountId string, session *Session, ctx context
})
}
+type AddressBookChanges struct {
+ HasMoreChanges bool `json:"hasMoreChanges"`
+ OldState State `json:"oldState,omitempty"`
+ NewState State `json:"newState"`
+ Created []AddressBook `json:"created,omitempty"`
+ Updated []AddressBook `json:"updated,omitempty"`
+ Destroyed []string `json:"destroyed,omitempty"`
+}
+
+// Retrieve Address Book changes since a given state.
+// @apidoc addressbook,changes
+func (j *Client) GetAddressbookChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceState State, maxChanges uint) (AddressBookChanges, SessionState, State, Language, Error) {
+ return changesTemplate(j, "GetAddressbookChanges",
+ CommandAddressBookChanges, CommandAddressBookGet,
+ func() AddressBookChangesCommand {
+ return AddressBookChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: posUIntPtr(maxChanges)}
+ },
+ func(path string, rof string) AddressBookGetRefCommand {
+ return AddressBookGetRefCommand{
+ AccountId: accountId,
+ IdsRef: &ResultReference{
+ Name: CommandAddressBookChanges,
+ Path: path,
+ ResultOf: rof,
+ },
+ }
+ },
+ func(resp AddressBookChangesResponse) (State, State, bool, []string) {
+ return resp.OldState, resp.NewState, resp.HasMoreChanges, resp.Destroyed
+ },
+ func(resp AddressBookGetResponse) []AddressBook { return resp.List },
+ func(oldState, newState State, hasMoreChanges bool, created, updated []AddressBook, destroyed []string) AddressBookChanges {
+ return AddressBookChanges{
+ OldState: oldState,
+ NewState: newState,
+ HasMoreChanges: hasMoreChanges,
+ Created: created,
+ Updated: updated,
+ Destroyed: destroyed,
+ }
+ },
+ func(resp AddressBookGetResponse) State { return resp.State },
+ session, ctx, logger, acceptLanguage,
+ )
+}
+
func (j *Client) GetContactCardsById(accountId string, session *Session, ctx context.Context, logger *log.Logger,
acceptLanguage string, contactIds []string) (map[string]jscontact.ContactCard, SessionState, State, Language, Error) {
logger = j.logger("GetContactCardsById", session, logger)
@@ -65,24 +111,14 @@ func (j *Client) GetContactCardsById(accountId string, session *Session, ctx con
func (j *Client) GetContactCards(accountId string, session *Session, ctx context.Context, logger *log.Logger,
acceptLanguage string, contactIds []string) ([]jscontact.ContactCard, SessionState, State, Language, Error) {
- logger = j.logger("GetContactCards", session, logger)
-
- cmd, err := j.request(session, logger, invocation(CommandContactCardGet, ContactCardGetCommand{
- Ids: contactIds,
- AccountId: accountId,
- }, "0"))
- if err != nil {
- return nil, "", "", "", err
- }
-
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) ([]jscontact.ContactCard, State, Error) {
- var response ContactCardGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandContactCardGet, "0", &response)
- if err != nil {
- return nil, "", err
- }
- return response.List, response.State, nil
- })
+ return getTemplate(j, "GetContactCards", CommandContactCardGet,
+ func(accountId string, ids []string) ContactCardGetCommand {
+ return ContactCardGetCommand{AccountId: accountId, Ids: contactIds}
+ },
+ func(resp ContactCardGetResponse) []jscontact.ContactCard { return resp.List },
+ func(resp ContactCardGetResponse) State { return resp.State },
+ accountId, session, ctx, logger, acceptLanguage, contactIds,
+ )
}
type ContactCardChanges struct {
@@ -94,70 +130,40 @@ type ContactCardChanges struct {
Destroyed []string `json:"destroyed,omitempty"`
}
-func (j *Client) GetContactCardsSince(accountId string, session *Session, ctx context.Context, logger *log.Logger,
- acceptLanguage string, sinceState string, maxChanges uint) (ContactCardChanges, SessionState, State, Language, Error) {
- logger = j.logger("GetContactCards", session, logger)
-
- maxChangesPtr := &maxChanges
- if maxChanges < 1 {
- maxChangesPtr = nil
- }
-
- cmd, err := j.request(session, logger,
- invocation(CommandContactCardChanges, ContactCardChangesCommand{
- AccountId: accountId,
- SinceState: sinceState,
- MaxChanges: maxChangesPtr,
- }, "0"),
- invocation(CommandContactCardGet, ContactCardGetRefCommand{
- AccountId: accountId,
- IdsRef: &ResultReference{
- ResultOf: "0",
- Name: CommandContactCardChanges,
- Path: "/created",
- },
- }, "1"),
- invocation(CommandContactCardGet, ContactCardGetRefCommand{
- AccountId: accountId,
- IdsRef: &ResultReference{
- ResultOf: "0",
- Name: CommandContactCardChanges,
- Path: "/updated",
- },
- }, "2"),
+func (j *Client) GetContactCardChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger,
+ acceptLanguage string, sinceState State, maxChanges uint) (ContactCardChanges, SessionState, State, Language, Error) {
+ return changesTemplate(j, "GetContactCardChanges",
+ CommandContactCardChanges, CommandContactCardGet,
+ func() ContactCardChangesCommand {
+ return ContactCardChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: posUIntPtr(maxChanges)}
+ },
+ func(path string, rof string) ContactCardGetRefCommand {
+ return ContactCardGetRefCommand{
+ AccountId: accountId,
+ IdsRef: &ResultReference{
+ Name: CommandContactCardChanges,
+ Path: path,
+ ResultOf: rof,
+ },
+ }
+ },
+ func(resp ContactCardChangesResponse) (State, State, bool, []string) {
+ return resp.OldState, resp.NewState, resp.HasMoreChanges, resp.Destroyed
+ },
+ func(resp ContactCardGetResponse) []jscontact.ContactCard { return resp.List },
+ func(oldState, newState State, hasMoreChanges bool, created, updated []jscontact.ContactCard, destroyed []string) ContactCardChanges {
+ return ContactCardChanges{
+ OldState: oldState,
+ NewState: newState,
+ HasMoreChanges: hasMoreChanges,
+ Created: created,
+ Updated: updated,
+ Destroyed: destroyed,
+ }
+ },
+ func(resp ContactCardGetResponse) State { return resp.State },
+ session, ctx, logger, acceptLanguage,
)
- if err != nil {
- return ContactCardChanges{}, "", "", "", err
- }
-
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (ContactCardChanges, State, Error) {
- result := ContactCardChanges{}
- var changes ContactCardChangesResponse
- err = retrieveResponseMatchParameters(logger, body, CommandContactCardChanges, "0", &changes)
- if err != nil {
- return ContactCardChanges{}, "", err
- }
- result.NewState = changes.NewState
- result.OldState = changes.OldState
- result.HasMoreChanges = changes.HasMoreChanges
- result.Destroyed = changes.Destroyed
-
- var created ContactCardGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandContactCardGet, "1", &created)
- if err != nil {
- return ContactCardChanges{}, "", err
- }
- result.Created = created.List
-
- var updated ContactCardGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandContactCardGet, "2", &updated)
- if err != nil {
- return ContactCardChanges{}, "", err
- }
- result.Updated = updated.List
-
- return result, changes.NewState, nil
- })
}
func (j *Client) QueryContactCards(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, //NOSONAR
@@ -253,7 +259,7 @@ func (j *Client) CreateContactCard(accountId string, session *Session, ctx conte
if created, ok := setResponse.Created["c"]; !ok || created == nil {
berr := fmt.Errorf("failed to find %s in %s response", string(ContactCardType), string(CommandContactCardSet))
logger.Error().Err(berr)
- return nil, "", simpleError(berr, JmapErrorInvalidJmapResponsePayload)
+ return nil, "", jmapError(berr, JmapErrorInvalidJmapResponsePayload)
}
var getResponse ContactCardGetResponse
@@ -265,7 +271,7 @@ func (j *Client) CreateContactCard(accountId string, session *Session, ctx conte
if len(getResponse.List) < 1 {
berr := fmt.Errorf("failed to find %s in %s response", string(ContactCardType), string(CommandContactCardSet))
logger.Error().Err(berr)
- return nil, "", simpleError(berr, JmapErrorInvalidJmapResponsePayload)
+ return nil, "", jmapError(berr, JmapErrorInvalidJmapResponsePayload)
}
return &getResponse.List[0], setResponse.NewState, nil
diff --git a/pkg/jmap/api_email.go b/pkg/jmap/api_email.go
index 558977f4a9..d421fd77bf 100644
--- a/pkg/jmap/api_email.go
+++ b/pkg/jmap/api_email.go
@@ -56,8 +56,7 @@ func (j *Client) GetEmails(accountId string, session *Session, ctx context.Conte
cmd, err := j.request(session, logger, methodCalls...)
if err != nil {
- logger.Error().Err(err).Send()
- return nil, nil, "", "", "", simpleError(err, JmapErrorInvalidJmapRequestPayload)
+ return nil, nil, "", "", "", err
}
result, sessionState, state, language, gwerr := command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (getEmailsResult, State, Error) {
if markAsSeen {
@@ -95,8 +94,7 @@ func (j *Client) GetEmailBlobId(accountId string, session *Session, ctx context.
get := EmailGetCommand{AccountId: accountId, Ids: []string{id}, FetchAllBodyValues: false, Properties: []string{"blobId"}}
cmd, err := j.request(session, logger, invocation(CommandEmailGet, get, "0"))
if err != nil {
- logger.Error().Err(err).Send()
- return "", "", "", "", simpleError(err, JmapErrorInvalidJmapRequestPayload)
+ return "", "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (string, State, Error) {
var response EmailGetResponse
@@ -240,7 +238,7 @@ func (j *Client) GetEmailChanges(accountId string, session *Session, ctx context
invocation(CommandEmailGet, getUpdated, "2"),
)
if err != nil {
- return EmailChanges{}, "", "", "", simpleError(err, JmapErrorInvalidJmapRequestPayload)
+ return EmailChanges{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (EmailChanges, State, Error) {
@@ -536,8 +534,7 @@ func (j *Client) QueryEmailsWithSnippets(accountIds []string, filter EmailFilter
cmd, err := j.request(session, logger, invocations...)
if err != nil {
- logger.Error().Err(err).Send()
- return nil, "", "", "", simpleError(err, JmapErrorInvalidJmapRequestPayload)
+ return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]EmailQueryWithSnippetsResult, State, Error) {
@@ -631,7 +628,7 @@ func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Con
invocation(CommandBlobGet, getHash, "1"),
)
if err != nil {
- return UploadedEmail{}, "", "", "", simpleError(err, JmapErrorInvalidJmapRequestPayload)
+ return UploadedEmail{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (UploadedEmail, State, Error) {
@@ -650,17 +647,17 @@ func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Con
if len(uploadResponse.Created) != 1 {
logger.Error().Msgf("%T.Created has %v elements instead of 1", uploadResponse, len(uploadResponse.Created))
- return UploadedEmail{}, "", simpleError(err, JmapErrorInvalidJmapResponsePayload)
+ return UploadedEmail{}, "", jmapError(err, JmapErrorInvalidJmapResponsePayload)
}
upload, ok := uploadResponse.Created["0"]
if !ok {
logger.Error().Msgf("%T.Created has no element '0'", uploadResponse)
- return UploadedEmail{}, "", simpleError(err, JmapErrorInvalidJmapResponsePayload)
+ return UploadedEmail{}, "", jmapError(err, JmapErrorInvalidJmapResponsePayload)
}
if len(getResponse.List) != 1 {
logger.Error().Msgf("%T.List has %v elements instead of 1", getResponse, len(getResponse.List))
- return UploadedEmail{}, "", simpleError(err, JmapErrorInvalidJmapResponsePayload)
+ return UploadedEmail{}, "", jmapError(err, JmapErrorInvalidJmapResponsePayload)
}
get := getResponse.List[0]
@@ -714,7 +711,7 @@ func (j *Client) CreateEmail(accountId string, email EmailCreate, replaceId stri
if !ok {
berr := fmt.Errorf("failed to find %s in %s response", string(EmailType), string(CommandEmailSet))
logger.Error().Err(berr)
- return nil, "", simpleError(berr, JmapErrorInvalidJmapResponsePayload)
+ return nil, "", jmapError(berr, JmapErrorInvalidJmapResponsePayload)
}
return created, setResponse.NewState, nil
@@ -886,7 +883,7 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string
return submission, setResponse.NewState, nil
} else {
- err = simpleError(fmt.Errorf("failed to submit email: updated is empty"), 0) // TODO proper error handling
+ err = jmapError(fmt.Errorf("failed to submit email: updated is empty"), 0) // TODO proper error handling
return EmailSubmission{}, "", err
}
})
diff --git a/pkg/jmap/api_identity.go b/pkg/jmap/api_identity.go
index 86dbbcc696..65cf3ef8f1 100644
--- a/pkg/jmap/api_identity.go
+++ b/pkg/jmap/api_identity.go
@@ -9,75 +9,37 @@ import (
)
func (j *Client) GetAllIdentities(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) ([]Identity, SessionState, State, Language, Error) {
- logger = j.logger("GetAllIdentities", session, logger)
- cmd, err := j.request(session, logger, invocation(CommandIdentityGet, IdentityGetCommand{AccountId: accountId}, "0"))
- if err != nil {
- return nil, "", "", "", err
- }
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) ([]Identity, State, Error) {
- var response IdentityGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandIdentityGet, "0", &response)
- if err != nil {
- return nil, "", err
- }
- return response.List, response.State, nil
- })
+ return getTemplate(j, "GetAllIdentities", CommandIdentityGet,
+ func(accountId string, ids []string) IdentityGetCommand {
+ return IdentityGetCommand{AccountId: accountId}
+ },
+ func(resp IdentityGetResponse) []Identity { return resp.List },
+ func(resp IdentityGetResponse) State { return resp.State },
+ accountId, session, ctx, logger, acceptLanguage, []string{},
+ )
}
func (j *Client) GetIdentities(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, identityIds []string) ([]Identity, SessionState, State, Language, Error) {
- logger = j.logger("GetIdentities", session, logger)
- cmd, err := j.request(session, logger, invocation(CommandIdentityGet, IdentityGetCommand{AccountId: accountId, Ids: identityIds}, "0"))
- if err != nil {
- return nil, "", "", "", err
- }
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) ([]Identity, State, Error) {
- var response IdentityGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandIdentityGet, "0", &response)
- if err != nil {
- return nil, "", err
- }
- return response.List, response.State, nil
- })
+ return getTemplate(j, "GetIdentities", CommandIdentityGet,
+ func(accountId string, ids []string) IdentityGetCommand {
+ return IdentityGetCommand{AccountId: accountId, Ids: ids}
+ },
+ func(resp IdentityGetResponse) []Identity { return resp.List },
+ func(resp IdentityGetResponse) State { return resp.State },
+ accountId, session, ctx, logger, acceptLanguage, identityIds,
+ )
}
-type IdentitiesGetResponse struct {
- Identities map[string][]Identity `json:"identities,omitempty"`
- NotFound []string `json:"notFound,omitempty"`
-}
-
-func (j *Client) GetIdentitiesForAllAccounts(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (IdentitiesGetResponse, SessionState, State, Language, Error) {
- logger = j.logger("GetIdentitiesForAllAccounts", session, logger)
- uniqueAccountIds := structs.Uniq(accountIds)
- calls := make([]Invocation, len(uniqueAccountIds))
- for i, accountId := range uniqueAccountIds {
- calls[i] = invocation(CommandIdentityGet, IdentityGetCommand{AccountId: accountId}, strconv.Itoa(i))
- }
-
- cmd, err := j.request(session, logger, calls...)
- if err != nil {
- return IdentitiesGetResponse{}, "", "", "", err
- }
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (IdentitiesGetResponse, State, Error) {
- identities := make(map[string][]Identity, len(uniqueAccountIds))
- stateByAccountId := make(map[string]State, len(uniqueAccountIds))
- notFound := []string{}
- for i, accountId := range uniqueAccountIds {
- var response IdentityGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandIdentityGet, strconv.Itoa(i), &response)
- if err != nil {
- return IdentitiesGetResponse{}, "", err
- } else {
- identities[accountId] = response.List
- }
- stateByAccountId[accountId] = response.State
- notFound = append(notFound, response.NotFound...)
- }
-
- return IdentitiesGetResponse{
- Identities: identities,
- NotFound: structs.Uniq(notFound),
- }, squashState(stateByAccountId), nil
- })
+func (j *Client) GetIdentitiesForAllAccounts(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string][]Identity, SessionState, State, Language, Error) {
+ return getTemplateN(j, "GetIdentitiesForAllAccounts", CommandIdentityGet,
+ func(accountId string, ids []string) IdentityGetCommand {
+ return IdentityGetCommand{AccountId: accountId}
+ },
+ func(resp IdentityGetResponse) []Identity { return resp.List },
+ identity1,
+ func(resp IdentityGetResponse) State { return resp.State },
+ accountIds, session, ctx, logger, acceptLanguage, []string{},
+ )
}
type IdentitiesAndMailboxesGetResponse struct {
diff --git a/pkg/jmap/api_mailbox.go b/pkg/jmap/api_mailbox.go
index efe2d481d5..867bdd2521 100644
--- a/pkg/jmap/api_mailbox.go
+++ b/pkg/jmap/api_mailbox.go
@@ -7,7 +7,6 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
- "github.com/rs/zerolog"
)
type MailboxesResponse struct {
@@ -15,64 +14,32 @@ type MailboxesResponse struct {
NotFound []any `json:"notFound"`
}
-// https://jmap.io/spec-mail.html#mailboxget
func (j *Client) GetMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (MailboxesResponse, SessionState, State, Language, Error) {
- logger = j.logger("GetMailbox", session, logger)
-
- cmd, err := j.request(session, logger,
- invocation(CommandMailboxGet, MailboxGetCommand{AccountId: accountId, Ids: ids}, "0"),
+ return getTemplate(j, "GetMailbox", CommandCalendarGet,
+ func(accountId string, ids []string) MailboxGetCommand {
+ return MailboxGetCommand{AccountId: accountId, Ids: ids}
+ },
+ func(resp MailboxGetResponse) MailboxesResponse {
+ return MailboxesResponse{
+ Mailboxes: resp.List,
+ NotFound: resp.NotFound,
+ }
+ },
+ func(resp MailboxGetResponse) State { return resp.State },
+ accountId, session, ctx, logger, acceptLanguage, ids,
)
- if err != nil {
- return MailboxesResponse{}, "", "", "", err
- }
-
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (MailboxesResponse, State, Error) {
- var response MailboxGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, "0", &response)
- if err != nil {
- return MailboxesResponse{}, "", err
- }
- return MailboxesResponse{
- Mailboxes: response.List,
- NotFound: response.NotFound,
- }, response.State, nil
- })
}
func (j *Client) GetAllMailboxes(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string][]Mailbox, SessionState, State, Language, Error) {
- logger = j.logger("GetAllMailboxes", session, logger)
-
- uniqueAccountIds := structs.Uniq(accountIds)
- n := len(uniqueAccountIds)
- if n < 1 {
- return nil, "", "", "", nil
- }
-
- invocations := make([]Invocation, n)
- for i, accountId := range uniqueAccountIds {
- invocations[i] = invocation(CommandMailboxGet, MailboxGetCommand{AccountId: accountId}, mcid(accountId, "0"))
- }
-
- cmd, err := j.request(session, logger, invocations...)
- if err != nil {
- return nil, "", "", "", err
- }
-
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string][]Mailbox, State, Error) {
- resp := map[string][]Mailbox{}
- stateByAccountid := map[string]State{}
- for _, accountId := range uniqueAccountIds {
- var response MailboxGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, mcid(accountId, "0"), &response)
- if err != nil {
- return nil, "", err
- }
-
- resp[accountId] = response.List
- stateByAccountid[accountId] = response.State
- }
- return resp, squashState(stateByAccountid), nil
- })
+ return getTemplateN(j, "GetAllMailboxes", CommandCalendarGet,
+ func(accountId string, ids []string) MailboxGetCommand {
+ return MailboxGetCommand{AccountId: accountId}
+ },
+ func(resp MailboxGetResponse) []Mailbox { return resp.List },
+ identity1,
+ func(resp MailboxGetResponse) State { return resp.State },
+ accountIds, session, ctx, logger, acceptLanguage, []string{},
+ )
}
func (j *Client) SearchMailboxes(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, filter MailboxFilterElement) (map[string][]Mailbox, SessionState, State, Language, Error) {
@@ -163,154 +130,149 @@ type MailboxChanges struct {
Destroyed []string `json:"destroyed,omitempty"`
}
-// Retrieve Mailbox changes since a given state.
-// @apidoc mailboxes,changes
-func (j *Client) GetMailboxChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceState string, maxChanges uint) (MailboxChanges, SessionState, State, Language, Error) {
- logger = j.logger("GetMailboxChanges", session, logger)
-
- changes := MailboxChangesCommand{
- AccountId: accountId,
- SinceState: sinceState,
- MaxChanges: nil,
+func newMailboxChanges(oldState, newState State, hasMoreChanges bool, created, updated []Mailbox, destroyed []string) MailboxChanges {
+ return MailboxChanges{
+ OldState: oldState,
+ NewState: newState,
+ HasMoreChanges: hasMoreChanges,
+ Created: created,
+ Updated: updated,
+ Destroyed: destroyed,
}
- if maxChanges > 0 {
- changes.MaxChanges = &maxChanges
- }
-
- getCreated := MailboxGetRefCommand{
- AccountId: accountId,
- IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: "/created", ResultOf: "0"},
- }
- getUpdated := MailboxGetRefCommand{
- AccountId: accountId,
- IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: "/updated", ResultOf: "0"},
- }
-
- cmd, err := j.request(session, logger,
- invocation(CommandMailboxChanges, changes, "0"),
- invocation(CommandMailboxGet, getCreated, "1"),
- invocation(CommandMailboxGet, getUpdated, "2"),
- )
- if err != nil {
- return MailboxChanges{}, "", "", "", err
- }
-
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (MailboxChanges, State, Error) {
- var mailboxResponse MailboxChangesResponse
- err = retrieveResponseMatchParameters(logger, body, CommandMailboxChanges, "0", &mailboxResponse)
- if err != nil {
- return MailboxChanges{}, "", err
- }
-
- var createdResponse MailboxGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, "1", &createdResponse)
- if err != nil {
- logger.Error().Err(err).Send()
- return MailboxChanges{}, "", err
- }
-
- var updatedResponse MailboxGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, "2", &updatedResponse)
- if err != nil {
- logger.Error().Err(err).Send()
- return MailboxChanges{}, "", err
- }
-
- return MailboxChanges{
- Destroyed: mailboxResponse.Destroyed,
- HasMoreChanges: mailboxResponse.HasMoreChanges,
- OldState: mailboxResponse.OldState,
- NewState: mailboxResponse.NewState,
- Created: createdResponse.List,
- Updated: createdResponse.List,
- }, createdResponse.State, nil
- })
}
-// Retrieve Email changes in Mailboxes of multiple Accounts.
-func (j *Client) GetMailboxChangesForMultipleAccounts(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceStateMap map[string]string, maxChanges uint) (map[string]MailboxChanges, SessionState, State, Language, Error) { //NOSONAR
- logger = j.loggerParams("GetMailboxChangesForMultipleAccounts", session, logger, func(z zerolog.Context) zerolog.Context {
- sinceStateLogDict := zerolog.Dict()
- for k, v := range sinceStateMap {
- sinceStateLogDict.Str(log.SafeString(k), log.SafeString(v))
- }
- return z.Dict(logSinceState, sinceStateLogDict)
- })
+// Retrieve Mailbox changes since a given state.
+// @apidoc mailboxes,changes
+func (j *Client) GetMailboxChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceState State, maxChanges uint) (MailboxChanges, SessionState, State, Language, Error) {
+ return changesTemplate(j, "GetMailboxChanges",
+ CommandMailboxChanges, CommandMailboxGet,
+ func() MailboxChangesCommand {
+ return MailboxChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: posUIntPtr(maxChanges)}
+ },
+ func(path string, rof string) MailboxGetRefCommand {
+ return MailboxGetRefCommand{
+ AccountId: accountId,
+ IdsRef: &ResultReference{
+ Name: CommandMailboxChanges,
+ Path: path,
+ ResultOf: rof,
+ },
+ }
+ },
+ func(resp MailboxChangesResponse) (State, State, bool, []string) {
+ return resp.OldState, resp.NewState, resp.HasMoreChanges, resp.Destroyed
+ },
+ func(resp MailboxGetResponse) []Mailbox { return resp.List },
+ newMailboxChanges,
+ func(resp MailboxGetResponse) State { return resp.State },
+ session, ctx, logger, acceptLanguage,
+ )
+}
- uniqueAccountIds := structs.Uniq(accountIds)
- n := len(uniqueAccountIds)
- if n < 1 {
- return map[string]MailboxChanges{}, "", "", "", nil
- }
+// Retrieve Mailbox changes of multiple Accounts.
+func (j *Client) GetMailboxChangesForMultipleAccounts(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceStateMap map[string]State, maxChanges uint) (map[string]MailboxChanges, SessionState, State, Language, Error) { //NOSONAR
+ return changesTemplateN(j, "GetMailboxChangesForMultipleAccounts",
+ accountIds, sinceStateMap, CommandMailboxChanges, CommandMailboxGet,
+ func(accountId string, state State) MailboxChangesCommand {
+ return MailboxChangesCommand{AccountId: accountId, SinceState: state, MaxChanges: posUIntPtr(maxChanges)}
+ },
+ func(accountId string, path string, ref string) MailboxGetRefCommand {
+ return MailboxGetRefCommand{AccountId: accountId, IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: path, ResultOf: ref}}
+ },
+ func(resp MailboxChangesResponse) (State, State, bool, []string) {
+ return resp.OldState, resp.NewState, resp.HasMoreChanges, resp.Destroyed
+ },
+ func(resp MailboxGetResponse) []Mailbox { return resp.List },
+ newMailboxChanges,
+ identity1,
+ func(resp MailboxGetResponse) State { return resp.State },
+ session, ctx, logger, acceptLanguage,
+ )
- invocations := make([]Invocation, n*3)
- for i, accountId := range uniqueAccountIds {
- changes := MailboxChangesCommand{
- AccountId: accountId,
+ /*
+ logger = j.loggerParams("GetMailboxChangesForMultipleAccounts", session, logger, func(z zerolog.Context) zerolog.Context {
+ sinceStateLogDict := zerolog.Dict()
+ for k, v := range sinceStateMap {
+ sinceStateLogDict.Str(log.SafeString(k), log.SafeString(v))
+ }
+ return z.Dict(logSinceState, sinceStateLogDict)
+ })
+
+ uniqueAccountIds := structs.Uniq(accountIds)
+ n := len(uniqueAccountIds)
+ if n < 1 {
+ return map[string]MailboxChanges{}, "", "", "", nil
}
- sinceState, ok := sinceStateMap[accountId]
- if ok {
- changes.SinceState = sinceState
- }
-
- if maxChanges > 0 {
- changes.MaxChanges = &maxChanges
- }
-
- getCreated := MailboxGetRefCommand{
- AccountId: accountId,
- IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: "/created", ResultOf: mcid(accountId, "0")},
- }
- getUpdated := MailboxGetRefCommand{
- AccountId: accountId,
- IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: "/updated", ResultOf: mcid(accountId, "0")},
- }
-
- invocations[i*3+0] = invocation(CommandMailboxChanges, changes, mcid(accountId, "0"))
- invocations[i*3+1] = invocation(CommandMailboxGet, getCreated, mcid(accountId, "1"))
- invocations[i*3+2] = invocation(CommandMailboxGet, getUpdated, mcid(accountId, "2"))
- }
-
- cmd, err := j.request(session, logger, invocations...)
- if err != nil {
- return nil, "", "", "", err
- }
-
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]MailboxChanges, State, Error) {
- resp := make(map[string]MailboxChanges, n)
- stateByAccountId := make(map[string]State, n)
- for _, accountId := range uniqueAccountIds {
- var mailboxResponse MailboxChangesResponse
- err = retrieveResponseMatchParameters(logger, body, CommandMailboxChanges, mcid(accountId, "0"), &mailboxResponse)
- if err != nil {
- return nil, "", err
+ invocations := make([]Invocation, n*3)
+ for i, accountId := range uniqueAccountIds {
+ changes := MailboxChangesCommand{
+ AccountId: accountId,
}
- var createdResponse MailboxGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, mcid(accountId, "1"), &createdResponse)
- if err != nil {
- return nil, "", err
+ sinceState, ok := sinceStateMap[accountId]
+ if ok {
+ changes.SinceState = sinceState
}
- var updatedResponse MailboxGetResponse
- err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, mcid(accountId, "2"), &updatedResponse)
- if err != nil {
- return nil, "", err
+ if maxChanges > 0 {
+ changes.MaxChanges = &maxChanges
}
- resp[accountId] = MailboxChanges{
- Destroyed: mailboxResponse.Destroyed,
- HasMoreChanges: mailboxResponse.HasMoreChanges,
- NewState: mailboxResponse.NewState,
- Created: createdResponse.List,
- Updated: createdResponse.List,
+ getCreated := MailboxGetRefCommand{
+ AccountId: accountId,
+ IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: "/created", ResultOf: mcid(accountId, "0")},
}
- stateByAccountId[accountId] = createdResponse.State
+ getUpdated := MailboxGetRefCommand{
+ AccountId: accountId,
+ IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: "/updated", ResultOf: mcid(accountId, "0")},
+ }
+
+ invocations[i*3+0] = invocation(CommandMailboxChanges, changes, mcid(accountId, "0"))
+ invocations[i*3+1] = invocation(CommandMailboxGet, getCreated, mcid(accountId, "1"))
+ invocations[i*3+2] = invocation(CommandMailboxGet, getUpdated, mcid(accountId, "2"))
}
- return resp, squashState(stateByAccountId), nil
- })
+ cmd, err := j.request(session, logger, invocations...)
+ if err != nil {
+ return nil, "", "", "", err
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]MailboxChanges, State, Error) {
+ resp := make(map[string]MailboxChanges, n)
+ stateByAccountId := make(map[string]State, n)
+ for _, accountId := range uniqueAccountIds {
+ var mailboxResponse MailboxChangesResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandMailboxChanges, mcid(accountId, "0"), &mailboxResponse)
+ if err != nil {
+ return nil, "", err
+ }
+
+ var createdResponse MailboxGetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, mcid(accountId, "1"), &createdResponse)
+ if err != nil {
+ return nil, "", err
+ }
+
+ var updatedResponse MailboxGetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, mcid(accountId, "2"), &updatedResponse)
+ if err != nil {
+ return nil, "", err
+ }
+
+ resp[accountId] = MailboxChanges{
+ Destroyed: mailboxResponse.Destroyed,
+ HasMoreChanges: mailboxResponse.HasMoreChanges,
+ NewState: mailboxResponse.NewState,
+ Created: createdResponse.List,
+ Updated: updatedResponse.List,
+ }
+ stateByAccountId[accountId] = createdResponse.State
+ }
+
+ return resp, squashState(stateByAccountId), nil
+ })
+ */
}
func (j *Client) GetMailboxRolesForMultipleAccounts(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string][]string, SessionState, State, Language, Error) {
@@ -472,7 +434,7 @@ func (j *Client) CreateMailbox(accountId string, session *Session, ctx context.C
if mailbox, ok := setResp.Created["c"]; ok {
return mailbox, setResp.NewState, nil
} else {
- return Mailbox{}, "", simpleError(fmt.Errorf("failed to find created %T in response", Mailbox{}), JmapErrorMissingCreatedObject)
+ return Mailbox{}, "", jmapError(fmt.Errorf("failed to find created %T in response", Mailbox{}), JmapErrorMissingCreatedObject)
}
})
}
diff --git a/pkg/jmap/api_objects.go b/pkg/jmap/api_objects.go
new file mode 100644
index 0000000000..6ab76a0cec
--- /dev/null
+++ b/pkg/jmap/api_objects.go
@@ -0,0 +1,118 @@
+package jmap
+
+import (
+ "context"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+)
+
+type Objects struct {
+ Mailboxes *MailboxGetResponse `json:"mailboxes,omitempty"`
+ Emails *EmailGetResponse `json:"emails,omitempty"`
+ Calendars *CalendarGetResponse `json:"calendars,omitempty"`
+ Events *CalendarEventGetResponse `json:"events,omitempty"`
+ Addressbooks *AddressBookGetResponse `json:"addressbooks,omitempty"`
+ Contacts *ContactCardGetResponse `json:"contacts,omitempty"`
+}
+
+func (j *Client) GetObjects(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, //NOSONAR
+ mailboxIds []string, emailIds []string,
+ addressbookIds []string, contactIds []string,
+ calendarIds []string, eventIds []string,
+) (Objects, SessionState, State, Language, Error) {
+ l := j.logger("GetObjects", session, logger).With()
+ if len(mailboxIds) > 0 {
+ l = l.Array("mailboxIds", log.SafeStringArray(mailboxIds))
+ }
+ if len(emailIds) > 0 {
+ l = l.Array("emailIds", log.SafeStringArray(emailIds))
+ }
+ if len(addressbookIds) > 0 {
+ l = l.Array("addressbookIds", log.SafeStringArray(addressbookIds))
+ }
+ if len(contactIds) > 0 {
+ l = l.Array("contactIds", log.SafeStringArray(contactIds))
+ }
+ if len(calendarIds) > 0 {
+ l = l.Array("calendarIds", log.SafeStringArray(calendarIds))
+ }
+ if len(eventIds) > 0 {
+ l = l.Array("eventIds", log.SafeStringArray(eventIds))
+ }
+ logger = log.From(l)
+
+ methodCalls := []Invocation{}
+ if len(mailboxIds) > 0 {
+ methodCalls = append(methodCalls, invocation(CommandMailboxGet, MailboxGetCommand{AccountId: accountId, Ids: mailboxIds}, "mailboxes"))
+ }
+ if len(emailIds) > 0 {
+ methodCalls = append(methodCalls, invocation(CommandEmailGet, EmailGetCommand{AccountId: accountId, Ids: emailIds}, "emails"))
+ }
+ if len(addressbookIds) > 0 {
+ methodCalls = append(methodCalls, invocation(CommandAddressBookGet, AddressBookGetCommand{AccountId: accountId, Ids: addressbookIds}, "addressbooks"))
+ }
+ if len(contactIds) > 0 {
+ methodCalls = append(methodCalls, invocation(CommandContactCardGet, ContactCardGetCommand{AccountId: accountId, Ids: contactIds}, "contacts"))
+ }
+ if len(calendarIds) > 0 {
+ methodCalls = append(methodCalls, invocation(CommandCalendarGet, CalendarGetCommand{AccountId: accountId, Ids: calendarIds}, "calendars"))
+ }
+ if len(eventIds) > 0 {
+ methodCalls = append(methodCalls, invocation(CommandCalendarEventGet, CalendarEventGetCommand{AccountId: accountId, Ids: eventIds}, "events"))
+ }
+
+ cmd, err := j.request(session, logger, methodCalls...)
+ if err != nil {
+ return Objects{}, "", "", "", err
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (Objects, State, Error) {
+ objs := Objects{}
+
+ var mailboxes MailboxGetResponse
+ if ok, err := tryRetrieveResponseMatchParameters(logger, body, CommandMailboxGet, "mailboxes", &mailboxes); err != nil {
+ return Objects{}, "", err
+ } else if ok {
+ objs.Mailboxes = &mailboxes
+ }
+
+ var emails EmailGetResponse
+ if ok, err := tryRetrieveResponseMatchParameters(logger, body, CommandEmailGet, "emails", &emails); err != nil {
+ return Objects{}, "", err
+ } else if ok {
+ objs.Emails = &emails
+ }
+
+ var calendars CalendarGetResponse
+ if ok, err := tryRetrieveResponseMatchParameters(logger, body, CommandCalendarGet, "calendars", &calendars); err != nil {
+ return Objects{}, "", err
+ } else if ok {
+ objs.Calendars = &calendars
+ }
+
+ var events CalendarEventGetResponse
+ if ok, err := tryRetrieveResponseMatchParameters(logger, body, CommandCalendarEventGet, "events", &events); err != nil {
+ return Objects{}, "", err
+ } else if ok {
+ objs.Events = &events
+ }
+
+ var addressbooks AddressBookGetResponse
+ if ok, err := tryRetrieveResponseMatchParameters(logger, body, CommandAddressBookGet, "addressbooks", &addressbooks); err != nil {
+ return Objects{}, "", err
+ } else if ok {
+ objs.Addressbooks = &addressbooks
+ }
+
+ var contacts ContactCardGetResponse
+ if ok, err := tryRetrieveResponseMatchParameters(logger, body, CommandContactCardGet, "contacts", &contacts); err != nil {
+ return Objects{}, "", err
+ } else if ok {
+ objs.Contacts = &contacts
+ }
+
+ state := squashStates(mailboxes.State, emails.State, calendars.State, events.State, addressbooks.State, contacts.State)
+
+ return objs, state, nil
+ })
+}
diff --git a/pkg/jmap/api_quota.go b/pkg/jmap/api_quota.go
index d67fa79bfb..0f137650ef 100644
--- a/pkg/jmap/api_quota.go
+++ b/pkg/jmap/api_quota.go
@@ -12,6 +12,7 @@ func (j *Client) GetQuotas(accountIds []string, session *Session, ctx context.Co
return QuotaGetCommand{AccountId: accountId}
},
identity1,
+ identity1,
func(resp QuotaGetResponse) State { return resp.State },
accountIds, session, ctx, logger, acceptLanguage, []string{},
)
diff --git a/pkg/jmap/api_vacation.go b/pkg/jmap/api_vacation.go
index f2a4f9d9b1..8b0073c373 100644
--- a/pkg/jmap/api_vacation.go
+++ b/pkg/jmap/api_vacation.go
@@ -94,7 +94,7 @@ func (j *Client) SetVacationResponse(accountId string, vacation VacationResponse
if len(getResponse.List) != 1 {
berr := fmt.Errorf("failed to find %s in %s response", string(VacationResponseType), string(CommandVacationResponseGet))
logger.Error().Msg(berr.Error())
- return VacationResponse{}, "", simpleError(berr, JmapErrorInvalidJmapResponsePayload)
+ return VacationResponse{}, "", jmapError(berr, JmapErrorInvalidJmapResponsePayload)
}
return getResponse.List[0], setResponse.NewState, nil
diff --git a/pkg/jmap/client.go b/pkg/jmap/client.go
index 86feb3ecf7..d3814c0077 100644
--- a/pkg/jmap/client.go
+++ b/pkg/jmap/client.go
@@ -79,11 +79,11 @@ func (j *Client) loggerParams(operation string, _ *Session, logger *log.Logger,
func (j *Client) maxCallsCheck(calls int, session *Session, logger *log.Logger) Error {
if calls > session.Capabilities.Core.MaxCallsInRequest {
- logger.Warn().
+ logger.Error().
Int("max-calls-in-request", session.Capabilities.Core.MaxCallsInRequest).
Int("calls-in-request", calls).
- Msgf("number of calls in request payload (%d) would exceed the allowed maximum (%d)", session.Capabilities.Core.MaxCallsInRequest, calls)
- return simpleError(errTooManyMethodCalls, JmapErrorTooManyMethodCalls)
+ Msgf("number of calls in request payload (%d) exceeds the allowed maximum (%d)", session.Capabilities.Core.MaxCallsInRequest, calls)
+ return jmapError(errTooManyMethodCalls, JmapErrorTooManyMethodCalls)
}
return nil
}
diff --git a/pkg/jmap/error.go b/pkg/jmap/error.go
index d5d9b5c6d1..63b7c9e0dc 100644
--- a/pkg/jmap/error.go
+++ b/pkg/jmap/error.go
@@ -38,6 +38,7 @@ const (
JmapErrorWssFailedToRetrieveSession
JmapErrorSocketPushUnsupported
JmapErrorMissingCreatedObject
+ JmapInvalidObjectState
)
var (
@@ -49,35 +50,52 @@ type Error interface {
error
}
-type SimpleError struct {
- code int
- err error
+type JmapError struct {
+ code int
+ err error
+ typ string
+ description string
}
-var _ Error = &SimpleError{}
+var _ Error = &JmapError{}
-func (e SimpleError) Code() int {
+func (e JmapError) Code() int {
return e.code
}
-func (e SimpleError) Unwrap() error {
+func (e JmapError) Unwrap() error {
return e.err
}
-func (e SimpleError) Error() string {
+func (e JmapError) Error() string {
if e.err != nil {
return e.err.Error()
} else {
return ""
}
}
+func (e JmapError) Type() string {
+ return e.typ
+}
+func (e JmapError) Description() string {
+ return e.description
+}
-func simpleError(err error, code int) Error {
+func jmapError(err error, code int) Error {
if err != nil {
- return SimpleError{code: code, err: err}
+ return JmapError{code: code, err: err}
} else {
return nil
}
}
+func jmapResponseError(code int, err error, typ string, description string) JmapError {
+ return JmapError{
+ code: code,
+ err: err,
+ typ: typ,
+ description: description,
+ }
+}
+
func setErrorError(err SetError, objectType ObjectType) Error {
var e error
if len(err.Properties) > 0 {
@@ -85,5 +103,5 @@ func setErrorError(err SetError, objectType ObjectType) Error {
} else {
e = fmt.Errorf("failed to modify %s due to %s error: %s", objectType, err.Type, err.Description)
}
- return SimpleError{code: JmapErrorSetError, err: e}
+ return JmapError{code: JmapErrorSetError, err: e}
}
diff --git a/pkg/jmap/http.go b/pkg/jmap/http.go
index bd6eda438c..89b3a8086b 100644
--- a/pkg/jmap/http.go
+++ b/pkg/jmap/http.go
@@ -155,7 +155,7 @@ var (
func (h *HttpJmapClient) GetSession(ctx context.Context, sessionUrl *url.URL, username string, logger *log.Logger) (SessionResponse, Error) {
if sessionUrl == nil {
logger.Error().Msg("sessionUrl is nil")
- return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: errNilBaseUrl}
+ return SessionResponse{}, jmapError(errNilBaseUrl, JmapErrorInvalidHttpRequest)
}
// See the JMAP specification on Service Autodiscovery: https://jmap.io/spec-core.html#service-autodiscovery
// There are two standardised autodiscovery methods in use for Internet protocols:
@@ -170,7 +170,7 @@ func (h *HttpJmapClient) GetSession(ctx context.Context, sessionUrl *url.URL, us
req, err := http.NewRequest(http.MethodGet, sessionUrlStr, nil)
if err != nil {
logger.Error().Err(err).Msgf("failed to create GET request for %v", sessionUrl)
- return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: err}
+ return SessionResponse{}, jmapError(err, JmapErrorInvalidHttpRequest)
}
if err := h.auth(ctx, username, logger, req); err != nil {
return SessionResponse{}, err
@@ -181,12 +181,12 @@ func (h *HttpJmapClient) GetSession(ctx context.Context, sessionUrl *url.URL, us
if err != nil {
h.listener.OnFailedRequest(endpoint, err)
logger.Error().Err(err).Msgf("failed to perform GET %v", sessionUrl)
- return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: err}
+ return SessionResponse{}, jmapError(err, JmapErrorInvalidHttpRequest)
}
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 200")
- return SessionResponse{}, SimpleError{code: JmapErrorServerResponse, err: fmt.Errorf("JMAP API response status is %v", res.Status)}
+ return SessionResponse{}, jmapError(fmt.Errorf("JMAP API response status is %v", res.Status), JmapErrorServerResponse)
}
h.listener.OnSuccessfulRequest(endpoint, res.StatusCode)
@@ -203,7 +203,7 @@ func (h *HttpJmapClient) GetSession(ctx context.Context, sessionUrl *url.URL, us
if err != nil {
logger.Error().Err(err).Msg("failed to read response body") //NOSONAR
h.listener.OnResponseBodyReadingError(endpoint, err)
- return SessionResponse{}, SimpleError{code: JmapErrorReadingResponseBody, err: err}
+ return SessionResponse{}, jmapError(err, JmapErrorReadingResponseBody)
}
var data SessionResponse
@@ -211,7 +211,7 @@ func (h *HttpJmapClient) GetSession(ctx context.Context, sessionUrl *url.URL, us
if err != nil {
logger.Error().Str(logHttpUrl, log.SafeString(sessionUrlStr)).Err(err).Msg("failed to decode JSON payload from .well-known/jmap response")
h.listener.OnResponseBodyUnmarshallingError(endpoint, err)
- return SessionResponse{}, SimpleError{code: JmapErrorDecodingResponseBody, err: err}
+ return SessionResponse{}, jmapError(err, JmapErrorDecodingResponseBody)
}
return data, nil
@@ -225,13 +225,13 @@ func (h *HttpJmapClient) Command(ctx context.Context, logger *log.Logger, sessio
bodyBytes, err := json.Marshal(request)
if err != nil {
logger.Error().Err(err).Msg("failed to marshall JSON payload")
- return nil, "", SimpleError{code: JmapErrorEncodingRequestBody, err: err}
+ return nil, "", jmapError(err, JmapErrorEncodingRequestBody)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, jmapUrl, bytes.NewBuffer(bodyBytes))
if err != nil {
logger.Error().Err(err).Msgf("failed to create POST request for %v", jmapUrl)
- return nil, "", SimpleError{code: JmapErrorCreatingRequest, err: err}
+ return nil, "", jmapError(err, JmapErrorCreatingRequest)
}
// Some JMAP APIs use the Accept-Language header to determine which language to use to translate
@@ -257,7 +257,7 @@ func (h *HttpJmapClient) Command(ctx context.Context, logger *log.Logger, sessio
if err != nil {
h.listener.OnFailedRequest(endpoint, err)
logger.Error().Err(err).Msgf("failed to perform POST %v", jmapUrl)
- return nil, "", SimpleError{code: JmapErrorSendingRequest, err: err}
+ return nil, "", jmapError(err, JmapErrorSendingRequest)
}
if logger.Trace().Enabled() {
@@ -273,7 +273,7 @@ func (h *HttpJmapClient) Command(ctx context.Context, logger *log.Logger, sessio
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
logger.Error().Str(logEndpoint, endpoint).Str(logHttpStatus, log.SafeString(res.Status)).Msg("HTTP response status code is not 2xx") //NOSONAR
- return nil, language, SimpleError{code: JmapErrorServerResponse, err: err}
+ return nil, language, jmapError(err, JmapErrorServerResponse)
}
if res.Body != nil {
defer func(Body io.ReadCloser) {
@@ -289,7 +289,7 @@ func (h *HttpJmapClient) Command(ctx context.Context, logger *log.Logger, sessio
if err != nil {
logger.Error().Err(err).Msg("failed to read response body")
h.listener.OnResponseBodyReadingError(endpoint, err)
- return nil, language, SimpleError{code: JmapErrorServerResponse, err: err}
+ return nil, language, jmapError(err, JmapErrorServerResponse)
}
return body, language, nil
@@ -301,7 +301,7 @@ func (h *HttpJmapClient) UploadBinary(ctx context.Context, logger *log.Logger, s
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadUrl, body)
if err != nil {
logger.Error().Err(err).Msgf("failed to create POST request for %v", uploadUrl)
- return UploadedBlob{}, "", SimpleError{code: JmapErrorCreatingRequest, err: err}
+ return UploadedBlob{}, "", jmapError(err, JmapErrorCreatingRequest)
}
req.Header.Add("Content-Type", contentType)
req.Header.Add("User-Agent", h.userAgent)
@@ -323,7 +323,7 @@ func (h *HttpJmapClient) UploadBinary(ctx context.Context, logger *log.Logger, s
if err != nil {
h.listener.OnFailedRequest(endpoint, err)
logger.Error().Err(err).Msgf("failed to perform POST %v", uploadUrl)
- return UploadedBlob{}, "", SimpleError{code: JmapErrorSendingRequest, err: err}
+ return UploadedBlob{}, "", jmapError(err, JmapErrorSendingRequest)
}
if logger.Trace().Enabled() {
responseBytes, err := httputil.DumpResponse(res, true)
@@ -338,7 +338,7 @@ func (h *HttpJmapClient) UploadBinary(ctx context.Context, logger *log.Logger, s
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx")
- return UploadedBlob{}, language, SimpleError{code: JmapErrorServerResponse, err: err}
+ return UploadedBlob{}, language, jmapError(err, JmapErrorServerResponse)
}
if res.Body != nil {
defer func(Body io.ReadCloser) {
@@ -354,7 +354,7 @@ func (h *HttpJmapClient) UploadBinary(ctx context.Context, logger *log.Logger, s
if err != nil {
logger.Error().Err(err).Msg("failed to read response body")
h.listener.OnResponseBodyReadingError(endpoint, err)
- return UploadedBlob{}, language, SimpleError{code: JmapErrorServerResponse, err: err}
+ return UploadedBlob{}, language, jmapError(err, JmapErrorServerResponse)
}
logger.Trace()
@@ -364,7 +364,7 @@ func (h *HttpJmapClient) UploadBinary(ctx context.Context, logger *log.Logger, s
if err != nil {
logger.Error().Str(logHttpUrl, log.SafeString(uploadUrl)).Err(err).Msg("failed to decode JSON payload from the upload response")
h.listener.OnResponseBodyUnmarshallingError(endpoint, err)
- return UploadedBlob{}, language, SimpleError{code: JmapErrorDecodingResponseBody, err: err}
+ return UploadedBlob{}, language, jmapError(err, JmapErrorDecodingResponseBody)
}
return result, language, nil
@@ -376,7 +376,7 @@ func (h *HttpJmapClient) DownloadBinary(ctx context.Context, logger *log.Logger,
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadUrl, nil)
if err != nil {
logger.Error().Err(err).Msgf("failed to create GET request for %v", downloadUrl)
- return nil, "", SimpleError{code: JmapErrorCreatingRequest, err: err}
+ return nil, "", jmapError(err, JmapErrorCreatingRequest)
}
req.Header.Add("User-Agent", h.userAgent)
if acceptLanguage != "" {
@@ -397,7 +397,7 @@ func (h *HttpJmapClient) DownloadBinary(ctx context.Context, logger *log.Logger,
if err != nil {
h.listener.OnFailedRequest(endpoint, err)
logger.Error().Err(err).Msgf("failed to perform GET %v", downloadUrl)
- return nil, "", SimpleError{code: JmapErrorSendingRequest, err: err}
+ return nil, "", jmapError(err, JmapErrorSendingRequest)
}
if logger.Trace().Enabled() {
responseBytes, err := httputil.DumpResponse(res, false)
@@ -414,7 +414,7 @@ func (h *HttpJmapClient) DownloadBinary(ctx context.Context, logger *log.Logger,
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx")
- return nil, language, SimpleError{code: JmapErrorServerResponse, err: err}
+ return nil, language, jmapError(err, JmapErrorServerResponse)
}
h.listener.OnSuccessfulRequest(endpoint, res.StatusCode)
@@ -502,14 +502,14 @@ func (w *HttpWsClientFactory) connect(ctx context.Context, sessionProvider func(
session, err := sessionProvider()
if err != nil {
- return nil, "", "", SimpleError{code: JmapErrorWssFailedToRetrieveSession, err: err}
+ return nil, "", "", jmapError(err, JmapErrorWssFailedToRetrieveSession)
}
if session == nil {
- return nil, "", "", SimpleError{code: JmapErrorWssFailedToRetrieveSession, err: nil}
+ return nil, "", "", jmapError(fmt.Errorf("WSS connection failed to retrieve JMAP session"), JmapErrorWssFailedToRetrieveSession)
}
if !session.SupportsWebsocketPush {
- return nil, "", "", SimpleError{code: JmapErrorSocketPushUnsupported, err: nil}
+ return nil, "", "", jmapError(fmt.Errorf("WSS connection returned a session that does not support websocket push"), JmapErrorSocketPushUnsupported)
}
username := session.Username
@@ -520,7 +520,7 @@ func (w *HttpWsClientFactory) connect(ctx context.Context, sessionProvider func(
w.auth(ctx, username, logger, h)
c, res, err := w.dialer.DialContext(ctx, u.String(), h)
if err != nil {
- return nil, "", endpoint, SimpleError{code: JmapErrorFailedToEstablishWssConnection, err: err}
+ return nil, "", endpoint, jmapError(err, JmapErrorFailedToEstablishWssConnection)
}
if w.logger.Trace().Enabled() {
@@ -535,7 +535,7 @@ func (w *HttpWsClientFactory) connect(ctx context.Context, sessionProvider func(
if res.StatusCode != 101 {
w.eventListener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 101")
- return nil, "", endpoint, SimpleError{code: JmapErrorServerResponse, err: fmt.Errorf("JMAP WS API response status is %v", res.Status)}
+ return nil, "", endpoint, jmapError(fmt.Errorf("JMAP WS API response status is %v", res.Status), JmapErrorServerResponse)
} else {
w.eventListener.OnSuccessfulWsRequest(endpoint, res.StatusCode)
}
@@ -544,7 +544,7 @@ func (w *HttpWsClientFactory) connect(ctx context.Context, sessionProvider func(
// The reply from the server MUST also contain a corresponding "Sec-WebSocket-Protocol" header
// field with a value of "jmap" in order for a JMAP subprotocol connection to be established.
if !slices.Contains(res.Header.Values("Sec-WebSocket-Protocol"), "jmap") {
- return nil, "", endpoint, SimpleError{code: JmapErrorWssConnectionResponseMissingJmapSubprotocol}
+ return nil, "", endpoint, jmapError(fmt.Errorf("WSS connection header does not contain Sec-WebSocket-Protocol:jmap"), JmapErrorWssConnectionResponseMissingJmapSubprotocol)
}
return c, username, endpoint, nil
@@ -625,14 +625,14 @@ func (w *HttpWsClientFactory) EnableNotifications(ctx context.Context, pushState
data, err := json.Marshal(msg)
if err != nil {
- return nil, SimpleError{code: JmapErrorWssFailedToSendWebSocketPushEnable, err: err}
+ return nil, jmapError(err, JmapErrorWssFailedToSendWebSocketPushEnable)
}
if w.logger.Trace().Enabled() {
w.logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmapWs).Str(logType, logTypeRequest).Msg(string(data))
}
if err := c.WriteMessage(websocket.TextMessage, data); err != nil {
- return nil, SimpleError{code: JmapErrorWssFailedToSendWebSocketPushEnable, err: err}
+ return nil, jmapError(err, JmapErrorWssFailedToSendWebSocketPushEnable)
}
wsc := &HttpWsClient{
@@ -664,13 +664,13 @@ func (c *HttpWsClient) DisableNotifications() Error {
cerr := c.c.Close()
if werr != nil {
- return SimpleError{code: JmapErrorWssFailedToClose, err: werr}
+ return jmapError(werr, JmapErrorWssFailedToClose)
}
if merr != nil {
- return SimpleError{code: JmapErrorWssFailedToClose, err: merr}
+ return jmapError(merr, JmapErrorWssFailedToClose)
}
if cerr != nil {
- return SimpleError{code: JmapErrorWssFailedToClose, err: cerr}
+ return jmapError(cerr, JmapErrorWssFailedToClose)
}
return nil
}
diff --git a/pkg/jmap/integration_test.go b/pkg/jmap/integration_test.go
index c4ccbe899b..ae720325ed 100644
--- a/pkg/jmap/integration_test.go
+++ b/pkg/jmap/integration_test.go
@@ -912,6 +912,7 @@ var basicColors = []string{
"aqua",
}
+/*
// https://www.w3.org/TR/SVG11/types.html#ColorKeywords
var extendedColors = []string{
"aliceblue",
@@ -1062,6 +1063,7 @@ var extendedColors = []string{
"yellow",
"yellowgreen",
}
+*/
func propmap[T any](enabled bool, min int, max int, container map[string]any, name string, cardProperty *map[string]T, generator func(int, string) (map[string]any, T, error)) error {
if !enabled {
diff --git a/pkg/jmap/model.go b/pkg/jmap/model.go
index 1a08029229..a85bda3378 100644
--- a/pkg/jmap/model.go
+++ b/pkg/jmap/model.go
@@ -1343,7 +1343,7 @@ type MailboxChangesCommand struct {
// This is the string that was returned as the state argument in the Mailbox/get response.
//
// The server will return the changes that have occurred since this state.
- SinceState string `json:"sinceState,omitempty"`
+ SinceState State `json:"sinceState,omitempty"`
// The maximum number of ids to return in the response.
//
@@ -4960,6 +4960,11 @@ type AddressBookGetCommand struct {
Ids []string `json:"ids,omitempty"`
}
+type AddressBookGetRefCommand struct {
+ AccountId string `json:"accountId"`
+ IdsRef *ResultReference `json:"#ids,omitempty"`
+}
+
type AddressBookGetResponse struct {
AccountId string `json:"accountId"`
State State `json:"state,omitempty"`
@@ -4967,6 +4972,54 @@ type AddressBookGetResponse struct {
NotFound []string `json:"notFound,omitempty"`
}
+type AddressBookChangesCommand struct {
+ // The id of the account to use.
+ AccountId string `json:"accountId"`
+
+ // The current state of the client.
+ //
+ // This is the string that was returned as the state argument in the AddressBook/get response.
+ //
+ // The server will return the changes that have occurred since this state.
+ SinceState State `json:"sinceState,omitempty"`
+
+ // The maximum number of ids to return in the response.
+ //
+ // The server MAY choose to return fewer than this value but MUST NOT return more.
+ //
+ // If not given by the client, the server may choose how many to return.
+ //
+ // If supplied by the client, the value MUST be a positive integer greater than 0.
+ //
+ // If a value outside of this range is given, the server MUST reject the call with an invalidArguments error.
+ MaxChanges *uint `json:"maxChanges,omitzero"`
+}
+
+type AddressBookChangesResponse struct {
+ // The id of the account used for the call.
+ AccountId string `json:"accountId"`
+
+ // This is the sinceState argument echoed back; it’s the state from which the server is returning changes.
+ OldState State `json:"oldState"`
+
+ // This is the state the client will be in after applying the set of changes to the old state.
+ NewState State `json:"newState"`
+
+ // If true, the client may call Mailbox/changes again with the newState returned to get further updates.
+ //
+ // If false, newState is the current server state.
+ HasMoreChanges bool `json:"hasMoreChanges"`
+
+ // An array of ids for records that have been created since the old state.
+ Created []string `json:"created,omitempty"`
+
+ // An array of ids for records that have been updated since the old state.
+ Updated []string `json:"updated,omitempty"`
+
+ // An array of ids for records that have been destroyed since the old state.
+ Destroyed []string `json:"destroyed,omitempty"`
+}
+
type ContactCardComparator struct {
// The name of the property on the objects to compare.
Property string `json:"property,omitempty"`
@@ -5326,7 +5379,7 @@ type ContactCardChangesCommand struct {
// The current state of the client.
// This is the string that was returned as the "state" argument in the "ContactCard/get" response.
// The server will return the changes that have occurred since this state.
- SinceState string `json:"sinceState,omitempty"`
+ SinceState State `json:"sinceState,omitempty"`
// The maximum number of ids to return in the response.
// The server MAY choose to return fewer than this value but MUST NOT return more.
@@ -5496,6 +5549,11 @@ type CalendarGetCommand struct {
Ids []string `json:"ids,omitempty"`
}
+type CalendarGetRefCommand struct {
+ AccountId string `json:"accountId"`
+ IdsRef *ResultReference `json:"#ids,omitempty"`
+}
+
type CalendarGetResponse struct {
AccountId string `json:"accountId"`
State State `json:"state,omitempty"`
@@ -5503,6 +5561,53 @@ type CalendarGetResponse struct {
NotFound []string `json:"notFound,omitempty"`
}
+type CalendarChangesCommand struct {
+ // The id of the account to use.
+ AccountId string `json:"accountId"`
+
+ // The current state of the client.
+ //
+ // This is the string that was returned as the state argument in the Calendar/get response.
+ //
+ // The server will return the changes that have occurred since this state.
+ SinceState State `json:"sinceState,omitempty"`
+
+ // The maximum number of ids to return in the response.
+ //
+ // The server MAY choose to return fewer than this value but MUST NOT return more.
+ //
+ // If not given by the client, the server may choose how many to return.
+ //
+ // If supplied by the client, the value MUST be a positive integer greater than 0.
+ //
+ // If a value outside of this range is given, the server MUST reject the call with an invalidArguments error.
+ MaxChanges *uint `json:"maxChanges,omitzero"`
+}
+
+type CalendarChangesResponse struct {
+ // The id of the account used for the call.
+ AccountId string `json:"accountId"`
+
+ // This is the "sinceState" argument echoed back; it's the state from which the server is returning changes.
+ OldState State `json:"oldState"`
+
+ // This is the state the client will be in after applying the set of changes to the old state.
+ NewState State `json:"newState"`
+
+ // If true, the client may call "Calendar/changes" again with the "newState" returned to get further updates.
+ // If false, "newState" is the current server state.
+ HasMoreChanges bool `json:"hasMoreChanges"`
+
+ // An array of ids for records that have been created since the old state.
+ Created []string `json:"created,omitempty"`
+
+ // An array of ids for records that have been updated since the old state.
+ Updated []string `json:"updated,omitempty"`
+
+ // An array of ids for records that have been destroyed since the old state.
+ Destroyed []string `json:"destroyed,omitempty"`
+}
+
type CalendarEventComparator struct {
// The name of the property on the objects to compare.
Property string `json:"property,omitempty"`
@@ -5807,6 +5912,53 @@ type CalendarEventGetResponse struct {
NotFound []any `json:"notFound"`
}
+type CalendarEventChangesCommand struct {
+ // The id of the account to use.
+ AccountId string `json:"accountId"`
+
+ // The current state of the client.
+ //
+ // This is the string that was returned as the state argument in the CalendarEvent/get response.
+ //
+ // The server will return the changes that have occurred since this state.
+ SinceState State `json:"sinceState,omitempty"`
+
+ // The maximum number of ids to return in the response.
+ //
+ // The server MAY choose to return fewer than this value but MUST NOT return more.
+ //
+ // If not given by the client, the server may choose how many to return.
+ //
+ // If supplied by the client, the value MUST be a positive integer greater than 0.
+ //
+ // If a value outside of this range is given, the server MUST reject the call with an invalidArguments error.
+ MaxChanges *uint `json:"maxChanges,omitzero"`
+}
+
+type CalendarEventChangesResponse struct {
+ // The id of the account used for the call.
+ AccountId string `json:"accountId"`
+
+ // This is the "sinceState" argument echoed back; it's the state from which the server is returning changes.
+ OldState State `json:"oldState"`
+
+ // This is the state the client will be in after applying the set of changes to the old state.
+ NewState State `json:"newState"`
+
+ // If true, the client may call "CalendarEvent/changes" again with the "newState" returned to get further updates.
+ // If false, "newState" is the current server state.
+ HasMoreChanges bool `json:"hasMoreChanges"`
+
+ // An array of ids for records that have been created since the old state.
+ Created []string `json:"created,omitempty"`
+
+ // An array of ids for records that have been updated since the old state.
+ Updated []string `json:"updated,omitempty"`
+
+ // An array of ids for records that have been destroyed since the old state.
+ Destroyed []string `json:"destroyed,omitempty"`
+}
+
type CalendarEventUpdate map[string]any
type CalendarEventSetCommand struct {
@@ -5915,68 +6067,74 @@ type ErrorResponse struct {
}
const (
- ErrorCommand Command = "error" // only occurs in responses
- CommandBlobGet Command = "Blob/get"
- CommandBlobUpload Command = "Blob/upload"
- CommandEmailGet Command = "Email/get"
- CommandEmailQuery Command = "Email/query"
- CommandEmailChanges Command = "Email/changes"
- CommandEmailSet Command = "Email/set"
- CommandEmailImport Command = "Email/import"
- CommandEmailSubmissionGet Command = "EmailSubmission/get"
- CommandEmailSubmissionSet Command = "EmailSubmission/set"
- CommandThreadGet Command = "Thread/get"
- CommandMailboxGet Command = "Mailbox/get"
- CommandMailboxSet Command = "Mailbox/set"
- CommandMailboxQuery Command = "Mailbox/query"
- CommandMailboxChanges Command = "Mailbox/changes"
- CommandIdentityGet Command = "Identity/get"
- CommandIdentitySet Command = "Identity/set"
- CommandVacationResponseGet Command = "VacationResponse/get"
- CommandVacationResponseSet Command = "VacationResponse/set"
- CommandSearchSnippetGet Command = "SearchSnippet/get"
- CommandQuotaGet Command = "Quota/get"
- CommandAddressBookGet Command = "AddressBook/get"
- CommandContactCardQuery Command = "ContactCard/query"
- CommandContactCardGet Command = "ContactCard/get"
- CommandContactCardChanges Command = "ContactCard/changes"
- CommandContactCardSet Command = "ContactCard/set"
- CommandCalendarEventParse Command = "CalendarEvent/parse"
- CommandCalendarGet Command = "Calendar/get"
- CommandCalendarEventQuery Command = "CalendarEvent/query"
- CommandCalendarEventGet Command = "CalendarEvent/get"
- CommandCalendarEventSet Command = "CalendarEvent/set"
+ ErrorCommand Command = "error" // only occurs in responses
+ CommandBlobGet Command = "Blob/get"
+ CommandBlobUpload Command = "Blob/upload"
+ CommandEmailGet Command = "Email/get"
+ CommandEmailQuery Command = "Email/query"
+ CommandEmailChanges Command = "Email/changes"
+ CommandEmailSet Command = "Email/set"
+ CommandEmailImport Command = "Email/import"
+ CommandEmailSubmissionGet Command = "EmailSubmission/get"
+ CommandEmailSubmissionSet Command = "EmailSubmission/set"
+ CommandThreadGet Command = "Thread/get"
+ CommandMailboxGet Command = "Mailbox/get"
+ CommandMailboxSet Command = "Mailbox/set"
+ CommandMailboxQuery Command = "Mailbox/query"
+ CommandMailboxChanges Command = "Mailbox/changes"
+ CommandIdentityGet Command = "Identity/get"
+ CommandIdentitySet Command = "Identity/set"
+ CommandVacationResponseGet Command = "VacationResponse/get"
+ CommandVacationResponseSet Command = "VacationResponse/set"
+ CommandSearchSnippetGet Command = "SearchSnippet/get"
+ CommandQuotaGet Command = "Quota/get"
+ CommandAddressBookGet Command = "AddressBook/get"
+ CommandAddressBookChanges Command = "AddressBook/changes"
+ CommandContactCardQuery Command = "ContactCard/query"
+ CommandContactCardGet Command = "ContactCard/get"
+ CommandContactCardChanges Command = "ContactCard/changes"
+ CommandContactCardSet Command = "ContactCard/set"
+ CommandCalendarEventParse Command = "CalendarEvent/parse"
+ CommandCalendarGet Command = "Calendar/get"
+ CommandCalendarChanges Command = "Calendar/changes"
+ CommandCalendarEventQuery Command = "CalendarEvent/query"
+ CommandCalendarEventGet Command = "CalendarEvent/get"
+ CommandCalendarEventSet Command = "CalendarEvent/set"
+ CommandCalendarEventChanges Command = "CalendarEvent/changes"
)
var CommandResponseTypeMap = map[Command]func() any{
- ErrorCommand: func() any { return ErrorResponse{} },
- CommandBlobGet: func() any { return BlobGetResponse{} },
- CommandBlobUpload: func() any { return BlobUploadResponse{} },
- CommandMailboxQuery: func() any { return MailboxQueryResponse{} },
- CommandMailboxGet: func() any { return MailboxGetResponse{} },
- CommandMailboxSet: func() any { return MailboxSetResponse{} },
- CommandMailboxChanges: func() any { return MailboxChangesResponse{} },
- CommandEmailQuery: func() any { return EmailQueryResponse{} },
- CommandEmailChanges: func() any { return EmailChangesResponse{} },
- CommandEmailGet: func() any { return EmailGetResponse{} },
- CommandEmailSet: func() any { return EmailSetResponse{} },
- CommandEmailSubmissionGet: func() any { return EmailSubmissionGetResponse{} },
- CommandEmailSubmissionSet: func() any { return EmailSubmissionSetResponse{} },
- CommandThreadGet: func() any { return ThreadGetResponse{} },
- CommandIdentityGet: func() any { return IdentityGetResponse{} },
- CommandIdentitySet: func() any { return IdentitySetResponse{} },
- CommandVacationResponseGet: func() any { return VacationResponseGetResponse{} },
- CommandVacationResponseSet: func() any { return VacationResponseSetResponse{} },
- CommandSearchSnippetGet: func() any { return SearchSnippetGetResponse{} },
- CommandQuotaGet: func() any { return QuotaGetResponse{} },
- CommandAddressBookGet: func() any { return AddressBookGetResponse{} },
- CommandContactCardQuery: func() any { return ContactCardQueryResponse{} },
- CommandContactCardGet: func() any { return ContactCardGetResponse{} },
- CommandContactCardChanges: func() any { return ContactCardChangesResponse{} },
- CommandContactCardSet: func() any { return ContactCardSetResponse{} },
- CommandCalendarEventParse: func() any { return CalendarEventParseResponse{} },
- CommandCalendarGet: func() any { return CalendarGetResponse{} },
- CommandCalendarEventQuery: func() any { return CalendarEventQueryResponse{} },
- CommandCalendarEventGet: func() any { return CalendarEventGetResponse{} },
- CommandCalendarEventSet: func() any { return CalendarEventSetResponse{} },
+ ErrorCommand: func() any { return ErrorResponse{} },
+ CommandBlobGet: func() any { return BlobGetResponse{} },
+ CommandBlobUpload: func() any { return BlobUploadResponse{} },
+ CommandMailboxQuery: func() any { return MailboxQueryResponse{} },
+ CommandMailboxGet: func() any { return MailboxGetResponse{} },
+ CommandMailboxSet: func() any { return MailboxSetResponse{} },
+ CommandMailboxChanges: func() any { return MailboxChangesResponse{} },
+ CommandEmailQuery: func() any { return EmailQueryResponse{} },
+ CommandEmailChanges: func() any { return EmailChangesResponse{} },
+ CommandEmailGet: func() any { return EmailGetResponse{} },
+ CommandEmailSet: func() any { return EmailSetResponse{} },
+ CommandEmailSubmissionGet: func() any { return EmailSubmissionGetResponse{} },
+ CommandEmailSubmissionSet: func() any { return EmailSubmissionSetResponse{} },
+ CommandThreadGet: func() any { return ThreadGetResponse{} },
+ CommandIdentityGet: func() any { return IdentityGetResponse{} },
+ CommandIdentitySet: func() any { return IdentitySetResponse{} },
+ CommandVacationResponseGet: func() any { return VacationResponseGetResponse{} },
+ CommandVacationResponseSet: func() any { return VacationResponseSetResponse{} },
+ CommandSearchSnippetGet: func() any { return SearchSnippetGetResponse{} },
+ CommandQuotaGet: func() any { return QuotaGetResponse{} },
+ CommandAddressBookGet: func() any { return AddressBookGetResponse{} },
+ CommandAddressBookChanges: func() any { return AddressBookChangesResponse{} },
+ CommandContactCardQuery: func() any { return ContactCardQueryResponse{} },
+ CommandContactCardGet: func() any { return ContactCardGetResponse{} },
+ CommandContactCardChanges: func() any { return ContactCardChangesResponse{} },
+ CommandContactCardSet: func() any { return ContactCardSetResponse{} },
+ CommandCalendarEventParse: func() any { return CalendarEventParseResponse{} },
+ CommandCalendarGet: func() any { return CalendarGetResponse{} },
+ CommandCalendarChanges: func() any { return CalendarChangesResponse{} },
+ CommandCalendarEventQuery: func() any { return CalendarEventQueryResponse{} },
+ CommandCalendarEventGet: func() any { return CalendarEventGetResponse{} },
+ CommandCalendarEventSet: func() any { return CalendarEventSetResponse{} },
+ CommandCalendarEventChanges: func() any { return CalendarEventChangesResponse{} },
}
diff --git a/pkg/jmap/model_examples.go b/pkg/jmap/model_examples.go
index c1982c30a6..bcb3e274c1 100644
--- a/pkg/jmap/model_examples.go
+++ b/pkg/jmap/model_examples.go
@@ -1817,3 +1817,50 @@ func (e Exemplar) EmailChanges() EmailChanges {
Destroyed: []string{"mmnan", "moxzz"},
}
}
+
+func (e Exemplar) Changes() Changes {
+ return Changes{
+ MaxChanges: 3,
+ Mailboxes: &MailboxChangesResponse{
+ AccountId: e.AccountId,
+ OldState: "n",
+ NewState: "rafrag",
+ HasMoreChanges: true,
+ Created: []string{"d", "e", "a"},
+ },
+ Emails: &EmailChangesResponse{
+ AccountId: e.AccountId,
+ OldState: "n",
+ NewState: "rafrag",
+ HasMoreChanges: true,
+ Created: []string{"bmaaaaal", "hqaaaab2", "hqaaaab0"},
+ },
+ Calendars: &CalendarChangesResponse{
+ AccountId: e.AccountId,
+ OldState: "n",
+ NewState: "sci",
+ HasMoreChanges: false,
+ Created: []string{"b"},
+ },
+ Events: &CalendarEventChangesResponse{
+ AccountId: e.AccountId,
+ OldState: "n",
+ NewState: "sci",
+ HasMoreChanges: false,
+ },
+ Addressbooks: &AddressBookChangesResponse{
+ AccountId: e.AccountId,
+ OldState: "n",
+ NewState: "sb2",
+ HasMoreChanges: false,
+ Created: []string{"b", "c"},
+ },
+ Contacts: &ContactCardChangesResponse{
+ AccountId: e.AccountId,
+ OldState: "n",
+ NewState: "rbsxqeay",
+ HasMoreChanges: true,
+ Created: []string{"fq", "fr", "fs"},
+ },
+ }
+}
diff --git a/pkg/jmap/session.go b/pkg/jmap/session.go
index db20758d59..6b2b4487f7 100644
--- a/pkg/jmap/session.go
+++ b/pkg/jmap/session.go
@@ -56,12 +56,12 @@ type Session struct {
}
var (
- invalidSessionResponseErrorMissingUsername = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response does not provide a username")}
- invalidSessionResponseErrorMissingApiUrl = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response does not provide an API URL")}
- invalidSessionResponseErrorInvalidApiUrl = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response provides an invalid API URL")}
- invalidSessionResponseErrorMissingUploadUrl = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response does not provide an upload URL")}
- invalidSessionResponseErrorMissingDownloadUrl = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response does not provide a download URL")}
- invalidSessionResponseErrorInvalidWebsocketUrl = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response provides an invalid Websocket URL")}
+ invalidSessionResponseErrorMissingUsername = jmapError(errors.New("JMAP session response does not provide a username"), JmapErrorInvalidSessionResponse)
+ invalidSessionResponseErrorMissingApiUrl = jmapError(errors.New("JMAP session response does not provide an API URL"), JmapErrorInvalidSessionResponse)
+ invalidSessionResponseErrorInvalidApiUrl = jmapError(errors.New("JMAP session response provides an invalid API URL"), JmapErrorInvalidSessionResponse)
+ invalidSessionResponseErrorMissingUploadUrl = jmapError(errors.New("JMAP session response does not provide an upload URL"), JmapErrorInvalidSessionResponse)
+ invalidSessionResponseErrorMissingDownloadUrl = jmapError(errors.New("JMAP session response does not provide a download URL"), JmapErrorInvalidSessionResponse)
+ invalidSessionResponseErrorInvalidWebsocketUrl = jmapError(errors.New("JMAP session response provides an invalid Websocket URL"), JmapErrorInvalidSessionResponse)
)
// Create a new Session from a SessionResponse.
diff --git a/pkg/jmap/templates.go b/pkg/jmap/templates.go
index f327ca8efe..802f54ffd5 100644
--- a/pkg/jmap/templates.go
+++ b/pkg/jmap/templates.go
@@ -6,6 +6,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
+ "github.com/rs/zerolog"
)
func getTemplate[GETREQ any, GETRESP any, RESP any]( //NOSONAR
@@ -36,10 +37,11 @@ func getTemplate[GETREQ any, GETRESP any, RESP any]( //NOSONAR
})
}
-func getTemplateN[GETREQ any, GETRESP any, RESP any]( //NOSONAR
+func getTemplateN[GETREQ any, GETRESP any, ITEM any, RESP any]( //NOSONAR
client *Client, name string, getCommand Command,
getCommandFactory func(string, []string) GETREQ,
- mapper func(map[string]GETRESP) RESP,
+ itemMapper func(GETRESP) ITEM,
+ respMapper func(map[string]ITEM) RESP,
stateMapper func(GETRESP) State,
accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (RESP, SessionState, State, Language, Error) {
logger = client.logger(name, session, logger)
@@ -59,16 +61,18 @@ func getTemplateN[GETREQ any, GETRESP any, RESP any]( //NOSONAR
}
return command(client.api, logger, ctx, session, client.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (RESP, State, Error) {
- result := map[string]GETRESP{}
+ result := map[string]ITEM{}
+ responses := map[string]GETRESP{}
for _, accountId := range uniqueAccountIds {
- var response GETRESP
- err = retrieveResponseMatchParameters(logger, body, getCommand, mcid(accountId, "0"), &response)
+ var resp GETRESP
+ err = retrieveResponseMatchParameters(logger, body, getCommand, mcid(accountId, "0"), &resp)
if err != nil {
return zero, "", err
}
- result[accountId] = response
+ responses[accountId] = resp
+ result[accountId] = itemMapper(resp)
}
- return mapper(result), squashStateFunc(result, stateMapper), nil
+ return respMapper(result), squashStateFunc(responses, stateMapper), nil
})
}
@@ -110,7 +114,7 @@ func createTemplate[T any, SETREQ any, GETREQ any, SETRESP any, GETRESP any]( //
if created, ok := createdMap["c"]; !ok || created == nil {
berr := fmt.Errorf("failed to find %s in %s response", string(t), string(setCommand))
logger.Error().Err(berr)
- return nil, "", simpleError(berr, JmapErrorInvalidJmapResponsePayload)
+ return nil, "", jmapError(berr, JmapErrorInvalidJmapResponsePayload)
}
var getResponse GETRESP
@@ -124,7 +128,7 @@ func createTemplate[T any, SETREQ any, GETREQ any, SETRESP any, GETRESP any]( //
if len(list) < 1 {
berr := fmt.Errorf("failed to find %s in %s response", string(t), string(getCommand))
logger.Error().Err(berr)
- return nil, "", simpleError(berr, JmapErrorInvalidJmapResponsePayload)
+ return nil, "", jmapError(berr, JmapErrorInvalidJmapResponsePayload)
}
return &list[0], stateMapper(setResponse), nil
@@ -154,3 +158,141 @@ func deleteTemplate[REQ any, RESP any](client *Client, name string, c Command, /
return notDestroyedMapper(setResponse), stateMapper(setResponse), nil
})
}
+
+func changesTemplate[CHANGESREQ any, GETREQ any, CHANGESRESP any, GETRESP any, ITEM any, RESP any]( //NOSONAR
+ client *Client, name string,
+ changesCommand Command, getCommand Command,
+ changesCommandFactory func() CHANGESREQ,
+ getCommandFactory func(string, string) GETREQ,
+ changesMapper func(CHANGESRESP) (State, State, bool, []string),
+ getMapper func(GETRESP) []ITEM,
+ respMapper func(State, State, bool, []ITEM, []ITEM, []string) RESP,
+ stateMapper func(GETRESP) State,
+ session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (RESP, SessionState, State, Language, Error) {
+ logger = client.logger(name, session, logger)
+ var zero RESP
+
+ changes := changesCommandFactory()
+ getCreated := getCommandFactory("/created", "0") //NOSONAR
+ getUpdated := getCommandFactory("/updated", "0") //NOSONAR
+ cmd, err := client.request(session, logger,
+ invocation(changesCommand, changes, "0"),
+ invocation(getCommand, getCreated, "1"),
+ invocation(getCommand, getUpdated, "2"),
+ )
+ if err != nil {
+ return zero, "", "", "", err
+ }
+
+ return command(client.api, logger, ctx, session, client.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (RESP, State, Error) {
+ var changesResponse CHANGESRESP
+ err = retrieveResponseMatchParameters(logger, body, changesCommand, "0", &changesResponse)
+ if err != nil {
+ return zero, "", err
+ }
+
+ var createdResponse GETRESP
+ err = retrieveResponseMatchParameters(logger, body, getCommand, "1", &createdResponse)
+ if err != nil {
+ logger.Error().Err(err).Send()
+ return zero, "", err
+ }
+
+ var updatedResponse GETRESP
+ err = retrieveResponseMatchParameters(logger, body, getCommand, "2", &updatedResponse)
+ if err != nil {
+ logger.Error().Err(err).Send()
+ return zero, "", err
+ }
+
+ oldState, newState, hasMoreChanges, destroyed := changesMapper(changesResponse)
+ created := getMapper(createdResponse)
+ updated := getMapper(updatedResponse)
+
+ result := respMapper(oldState, newState, hasMoreChanges, created, updated, destroyed)
+
+ return result, stateMapper(createdResponse), nil
+ })
+}
+
+func changesTemplateN[CHANGESREQ any, GETREQ any, CHANGESRESP any, GETRESP any, ITEM any, CHANGESITEM any, RESP any]( //NOSONAR
+ client *Client, name string,
+ accountIds []string, sinceStateMap map[string]State,
+ changesCommand Command, getCommand Command,
+ changesCommandFactory func(string, State) CHANGESREQ,
+ getCommandFactory func(string, string, string) GETREQ,
+ changesMapper func(CHANGESRESP) (State, State, bool, []string),
+ getMapper func(GETRESP) []ITEM,
+ changesItemMapper func(State, State, bool, []ITEM, []ITEM, []string) CHANGESITEM,
+ respMapper func(map[string]CHANGESITEM) RESP,
+ stateMapper func(GETRESP) State,
+ session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (RESP, SessionState, State, Language, Error) {
+ logger = client.loggerParams(name, session, logger, func(z zerolog.Context) zerolog.Context {
+ sinceStateLogDict := zerolog.Dict()
+ for k, v := range sinceStateMap {
+ sinceStateLogDict.Str(log.SafeString(k), log.SafeString(string(v)))
+ }
+ return z.Dict(logSinceState, sinceStateLogDict)
+ })
+
+ var zero RESP
+
+ uniqueAccountIds := structs.Uniq(accountIds)
+ n := len(uniqueAccountIds)
+ if n < 1 {
+ return zero, "", "", "", nil
+ }
+
+ invocations := make([]Invocation, n*3)
+ for i, accountId := range uniqueAccountIds {
+ sinceState, ok := sinceStateMap[accountId]
+ if !ok {
+ sinceState = ""
+ }
+ changes := changesCommandFactory(accountId, sinceState)
+ ref := mcid(accountId, "0")
+
+ getCreated := getCommandFactory(accountId, "/created", ref)
+ getUpdated := getCommandFactory(accountId, "/updated", ref)
+
+ invocations[i*3+0] = invocation(changesCommand, changes, ref)
+ invocations[i*3+1] = invocation(getCommand, getCreated, mcid(accountId, "1"))
+ invocations[i*3+2] = invocation(getCommand, getUpdated, mcid(accountId, "2"))
+ }
+
+ cmd, err := client.request(session, logger, invocations...)
+ if err != nil {
+ return zero, "", "", "", err
+ }
+
+ return command(client.api, logger, ctx, session, client.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (RESP, State, Error) {
+ changesItemByAccount := make(map[string]CHANGESITEM, n)
+ stateByAccountId := make(map[string]State, n)
+ for _, accountId := range uniqueAccountIds {
+ var changesResponse CHANGESRESP
+ err = retrieveResponseMatchParameters(logger, body, changesCommand, mcid(accountId, "0"), &changesResponse)
+ if err != nil {
+ return zero, "", err
+ }
+
+ var createdResponse GETRESP
+ err = retrieveResponseMatchParameters(logger, body, getCommand, mcid(accountId, "1"), &createdResponse)
+ if err != nil {
+ return zero, "", err
+ }
+
+ var updatedResponse GETRESP
+ err = retrieveResponseMatchParameters(logger, body, getCommand, mcid(accountId, "2"), &updatedResponse)
+ if err != nil {
+ return zero, "", err
+ }
+
+ oldState, newState, hasMoreChanges, destroyed := changesMapper(changesResponse)
+ created := getMapper(createdResponse)
+ updated := getMapper(updatedResponse)
+ changesItemByAccount[accountId] = changesItemMapper(oldState, newState, hasMoreChanges, created, updated, destroyed)
+ stateByAccountId[accountId] = stateMapper(createdResponse)
+ }
+ return respMapper(changesItemByAccount), squashState(stateByAccountId), nil
+ })
+}
diff --git a/pkg/jmap/tools.go b/pkg/jmap/tools.go
index bbba0c7ce0..7f527412ee 100644
--- a/pkg/jmap/tools.go
+++ b/pkg/jmap/tools.go
@@ -14,6 +14,7 @@ import (
"github.com/mitchellh/mapstructure"
"github.com/opencloud-eu/opencloud/pkg/jscalendar"
"github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/opencloud-eu/opencloud/pkg/structs"
)
type eventListeners[T any] struct {
@@ -72,7 +73,7 @@ func command[T any](api ApiClient, //NOSONAR
if err != nil {
logger.Error().Err(err).Msgf("failed to deserialize body JSON payload into a %T", response)
var zero T
- return zero, "", "", language, SimpleError{code: JmapErrorDecodingResponseBody, err: err}
+ return zero, "", "", language, jmapError(err, JmapErrorDecodingResponseBody)
}
if response.SessionState != session.State {
@@ -96,6 +97,9 @@ func command[T any](api ApiClient, //NOSONAR
code = JmapErrorUnknownMethod
case MethodLevelErrorInvalidArguments:
code = JmapErrorInvalidArguments
+ if strings.HasPrefix(errorParameters.Description, "invalid JMAP State") {
+ code = JmapInvalidObjectState
+ }
case MethodLevelErrorInvalidResultReference:
code = JmapErrorInvalidResultReference
case MethodLevelErrorForbidden:
@@ -119,14 +123,14 @@ func command[T any](api ApiClient, //NOSONAR
err = errors.New(msg)
logger.Warn().Int("code", code).Str("type", errorParameters.Type).Msg(msg)
var zero T
- return zero, response.SessionState, "", language, SimpleError{code: code, err: err}
+ return zero, response.SessionState, "", language, jmapResponseError(code, err, errorParameters.Type, errorParameters.Description)
} else {
code := JmapErrorUnspecifiedType
msg := fmt.Sprintf("found method level error in response '%v'", mr.Tag)
err := errors.New(msg)
logger.Warn().Int("code", code).Msg(msg)
var zero T
- return zero, response.SessionState, "", language, SimpleError{code: code, err: err}
+ return zero, response.SessionState, "", language, jmapResponseError(code, err, errorParameters.Type, errorParameters.Description)
}
}
}
@@ -200,19 +204,35 @@ func retrieveResponseMatchParameters[T any](logger *log.Logger, data *Response,
if !ok {
err := fmt.Errorf("failed to find JMAP response invocation match for command '%v' and tag '%v'", command, tag)
logger.Error().Msg(err.Error())
- return simpleError(err, JmapErrorInvalidJmapResponsePayload)
+ return jmapError(err, JmapErrorInvalidJmapResponsePayload)
}
params := match.Parameters
typedParams, ok := params.(T)
if !ok {
err := fmt.Errorf("JMAP response invocation matches command '%v' and tag '%v' but the type %T does not match the expected %T", command, tag, params, *target)
logger.Error().Msg(err.Error())
- return simpleError(err, JmapErrorInvalidJmapResponsePayload)
+ return jmapError(err, JmapErrorInvalidJmapResponsePayload)
}
*target = typedParams
return nil
}
+func tryRetrieveResponseMatchParameters[T any](logger *log.Logger, data *Response, command Command, tag string, target *T) (bool, Error) {
+ match, ok := retrieveResponseMatch(data, command, tag)
+ if !ok {
+ return false, nil
+ }
+ params := match.Parameters
+ typedParams, ok := params.(T)
+ if !ok {
+ err := fmt.Errorf("JMAP response invocation matches command '%v' and tag '%v' but the type %T does not match the expected %T", command, tag, params, *target)
+ logger.Error().Msg(err.Error())
+ return true, jmapError(err, JmapErrorInvalidJmapResponsePayload)
+ }
+ *target = typedParams
+ return true, nil
+}
+
func (i *Invocation) MarshalJSON() ([]byte, error) {
// JMAP requests have a slightly unusual structure since they are not a JSON object
// but, instead, a three-element array composed of
@@ -268,6 +288,14 @@ func squashState(all map[string]State) State {
return squashStateFunc(all, func(s State) State { return s })
}
+func squashStates(states ...State) State {
+ return State(strings.Join(structs.Map(states, func(s State) string { return string(s) }), ","))
+}
+
+func squashKeyedStates(m map[string]State) State {
+ return squashStateFunc(m, identity1)
+}
+
func squashStateFunc[V any](all map[string]V, mapper func(V) State) State {
n := len(all)
if n == 0 {
@@ -346,3 +374,11 @@ func boolPtr(b bool) *bool {
func identity1[T any](t T) T {
return t
}
+
+func posUIntPtr(i uint) *uint {
+ if i > 0 {
+ return &i
+ } else {
+ return nil
+ }
+}
diff --git a/pkg/jmap/tools_test.go b/pkg/jmap/tools_test.go
index dcdbf2ffa4..0ddf3fb1e0 100644
--- a/pkg/jmap/tools_test.go
+++ b/pkg/jmap/tools_test.go
@@ -22,3 +22,14 @@ func TestUnmarshallingError(t *testing.T) {
require.Equal("forbidden", er.Type)
require.Equal("You do not have access to account a", er.Description)
}
+
+func TestSquashKeyedStates(t *testing.T) {
+ require := require.New(t)
+
+ result := squashKeyedStates(map[string]State{
+ "a": "aaa",
+ "b": "bbb",
+ "c": "ccc",
+ })
+ require.Equal("a:aaa,b:bbb,c:ccc", string(result))
+}
diff --git a/services/groupware/pkg/groupware/api_account.go b/services/groupware/pkg/groupware/api_account.go
index 1f24181ff2..96f5665c9c 100644
--- a/services/groupware/pkg/groupware/api_account.go
+++ b/services/groupware/pkg/groupware/api_account.go
@@ -51,7 +51,7 @@ func (g *Groupware) GetAccountsWithTheirIdentities(w http.ResponseWriter, r *htt
list := make([]AccountWithIdAndIdentities, len(req.session.Accounts))
i := 0
for accountId, account := range req.session.Accounts {
- identities, ok := resp.Identities[accountId]
+ identities, ok := resp[accountId]
if !ok {
identities = []jmap.Identity{}
}
diff --git a/services/groupware/pkg/groupware/api_calendars.go b/services/groupware/pkg/groupware/api_calendars.go
index 91d8ca2336..22858776b0 100644
--- a/services/groupware/pkg/groupware/api_calendars.go
+++ b/services/groupware/pkg/groupware/api_calendars.go
@@ -55,6 +55,41 @@ func (g *Groupware) GetCalendarById(w http.ResponseWriter, r *http.Request) {
})
}
+// Get the changes that occured in a given mailbox since a certain state.
+// @api:tags calendars,changes
+func (g *Groupware) GetCalendarChanges(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(req Request) Response {
+ ok, accountId, resp := req.needCalendarWithAccount()
+ if !ok {
+ return resp
+ }
+
+ l := req.logger.With()
+
+ maxChanges, ok, err := req.parseUIntParam(QueryParamMaxChanges, 0)
+ if err != nil {
+ return req.error(accountId, err)
+ }
+ if ok {
+ l = l.Uint(QueryParamMaxChanges, maxChanges)
+ }
+
+ sinceState := jmap.State(req.OptHeaderParamDoc(HeaderParamSince, "Optionally specifies the state identifier from which on to list calendar changes"))
+ if sinceState != "" {
+ l = l.Str(HeaderParamSince, log.SafeString(string(sinceState)))
+ }
+
+ logger := log.From(l)
+
+ changes, sessionState, state, lang, jerr := g.jmap.GetCalendarChanges(accountId, req.session, req.ctx, logger, req.language(), sinceState, maxChanges)
+ if jerr != nil {
+ return req.jmapError(accountId, jerr, sessionState, lang)
+ }
+
+ return req.respond(accountId, changes, sessionState, CalendarResponseObjectType, state)
+ })
+}
+
// Get all the events in a calendar of an account by its identifier.
func (g *Groupware) GetEventsInCalendar(w http.ResponseWriter, r *http.Request) { //NOSONAR
g.respond(w, r, func(req Request) Response {
@@ -106,6 +141,39 @@ func (g *Groupware) GetEventsInCalendar(w http.ResponseWriter, r *http.Request)
})
}
+// Get changes to Contacts since a given State
+// @api:tags event,changes
+func (g *Groupware) GetEventChanges(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(req Request) Response {
+ ok, accountId, resp := req.needCalendarWithAccount()
+ if !ok {
+ return resp
+ }
+
+ l := req.logger.With()
+
+ var maxChanges uint = 0
+ if v, ok, err := req.parseUIntParam(QueryParamMaxChanges, 0); err != nil {
+ return req.error(accountId, err)
+ } else if ok {
+ maxChanges = v
+ l = l.Uint(QueryParamMaxChanges, v)
+ }
+
+ sinceState := jmap.State(req.OptHeaderParamDoc(HeaderParamSince, "Specifies the state identifier from which on to list event changes"))
+ l = l.Str(HeaderParamSince, log.SafeString(string(sinceState)))
+
+ logger := log.From(l)
+ changes, sessionState, state, lang, jerr := g.jmap.GetCalendarEventChanges(accountId, req.session, req.ctx, logger, req.language(), sinceState, maxChanges)
+ if jerr != nil {
+ return req.jmapError(accountId, jerr, sessionState, lang)
+ }
+ var body jmap.CalendarEventChanges = changes
+
+ return req.respond(accountId, body, sessionState, ContactResponseObjectType, state)
+ })
+}
+
func (g *Groupware) CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needCalendarWithAccount()
diff --git a/services/groupware/pkg/groupware/api_changes.go b/services/groupware/pkg/groupware/api_changes.go
new file mode 100644
index 0000000000..986d7dce0b
--- /dev/null
+++ b/services/groupware/pkg/groupware/api_changes.go
@@ -0,0 +1,78 @@
+package groupware
+
+import (
+ "net/http"
+
+ "github.com/opencloud-eu/opencloud/pkg/jmap"
+ "github.com/opencloud-eu/opencloud/pkg/log"
+)
+
+// Retrieve changes for multiple or all Groupware objects, based on their respective state token.
+//
+// Since each object type has its own state token, the request must include the token for each
+// object type separately.
+//
+// This is done through individual query parameter, as follows:
+// `?emails=rafrag&contacts=rbsxqeay&events=n`
+//
+// Additionally, the `maxchanges` query parameter may be used to limit the number of changes
+// to retrieve for each object type -- this is applied to each object type, not overall.
+//
+// If `maxchanges` is not specifed or if `maxchanges` has the value `0`, then there is no limit
+// and all the changes from the specified state to now are included in the result.
+//
+// The response then includes the new state after that maximum number if changes,
+// as well as a `hasMoreChanges` boolean flag which can be used to paginate the retrieval of
+// changes and the objects associated with the identifiers.
+func (g *Groupware) GetChanges(w http.ResponseWriter, r *http.Request) { //NOSONAR
+ g.respond(w, r, func(req Request) Response {
+ l := req.logger.With()
+ accountId, err := req.GetAccountIdForMail()
+ if err != nil {
+ return req.error(accountId, err)
+ }
+ l = l.Str(logAccountId, accountId)
+
+ var maxChanges uint = 0
+ if v, ok, err := req.parseUIntParam(QueryParamMaxChanges, 0); err != nil {
+ return req.error(accountId, err)
+ } else if ok {
+ maxChanges = v
+ l = l.Uint(QueryParamMaxChanges, v)
+ }
+
+ sinceState := jmap.StateMap{}
+ {
+ if state, ok := req.getStringParam(QueryParamMailboxes, ""); ok {
+ sinceState.Mailboxes = ptr(toState(state))
+ }
+ if state, ok := req.getStringParam(QueryParamEmails, ""); ok {
+ sinceState.Emails = ptr(toState(state))
+ }
+ if state, ok := req.getStringParam(QueryParamAddressbooks, ""); ok {
+ sinceState.Addressbooks = ptr(toState(state))
+ }
+ if state, ok := req.getStringParam(QueryParamContacts, ""); ok {
+ sinceState.Contacts = ptr(toState(state))
+ }
+ if state, ok := req.getStringParam(QueryParamCalendars, ""); ok {
+ sinceState.Calendars = ptr(toState(state))
+ }
+ if state, ok := req.getStringParam(QueryParamEvents, ""); ok {
+ sinceState.Events = ptr(toState(state))
+ }
+ if sinceState.IsZero() {
+ return req.noop(accountId)
+ }
+ }
+
+ logger := log.From(l)
+ changes, sessionState, state, lang, jerr := g.jmap.GetChanges(accountId, req.session, req.ctx, logger, req.language(), sinceState, maxChanges)
+ if jerr != nil {
+ return req.jmapError(accountId, jerr, sessionState, lang)
+ }
+ var body jmap.Changes = changes
+
+ return req.respond(accountId, body, sessionState, "", state)
+ })
+}
diff --git a/services/groupware/pkg/groupware/api_contacts.go b/services/groupware/pkg/groupware/api_contacts.go
index 973e3a8de3..d82d530f79 100644
--- a/services/groupware/pkg/groupware/api_contacts.go
+++ b/services/groupware/pkg/groupware/api_contacts.go
@@ -89,6 +89,41 @@ func (g *Groupware) GetAddressbook(w http.ResponseWriter, r *http.Request) {
})
}
+// Get the changes that occured in a given mailbox since a certain state.
+// @api:tags mailbox,changes
+func (g *Groupware) GetAddressBookChanges(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(req Request) Response {
+ ok, accountId, resp := req.needContactWithAccount()
+ if !ok {
+ return resp
+ }
+
+ l := req.logger.With()
+
+ maxChanges, ok, err := req.parseUIntParam(QueryParamMaxChanges, 0)
+ if err != nil {
+ return req.error(accountId, err)
+ }
+ if ok {
+ l = l.Uint(QueryParamMaxChanges, maxChanges)
+ }
+
+ sinceState := jmap.State(req.OptHeaderParamDoc(HeaderParamSince, "Optionally specifies the state identifier from which on to list addressbook changes"))
+ if sinceState != "" {
+ l = l.Str(HeaderParamSince, log.SafeString(string(sinceState)))
+ }
+
+ logger := log.From(l)
+
+ changes, sessionState, state, lang, jerr := g.jmap.GetAddressbookChanges(accountId, req.session, req.ctx, logger, req.language(), sinceState, maxChanges)
+ if jerr != nil {
+ return req.jmapError(accountId, jerr, sessionState, lang)
+ }
+
+ return req.respond(accountId, changes, sessionState, AddressBookResponseObjectType, state)
+ })
+}
+
// Get all the contacts in an addressbook of an account by its identifier.
func (g *Groupware) GetContactsInAddressbook(w http.ResponseWriter, r *http.Request) { //NOSONAR
g.respond(w, r, func(req Request) Response {
@@ -214,14 +249,11 @@ func (g *Groupware) GetContactsChanges(w http.ResponseWriter, r *http.Request) {
l = l.Uint(QueryParamMaxChanges, v)
}
- sinceState, err := req.HeaderParamDoc(HeaderParamSince, "Specifies the state identifier from which on to list mailbox changes")
- if err != nil {
- return req.error(accountId, err)
- }
- l = l.Str(HeaderParamSince, log.SafeString(sinceState))
+ sinceState := jmap.State(req.OptHeaderParamDoc(HeaderParamSince, "Specifies the state identifier from which on to list contact changes"))
+ l = l.Str(HeaderParamSince, log.SafeString(string(sinceState)))
logger := log.From(l)
- changes, sessionState, state, lang, jerr := g.jmap.GetContactCardsSince(accountId, req.session, req.ctx, logger, req.language(), sinceState, maxChanges)
+ changes, sessionState, state, lang, jerr := g.jmap.GetContactCardChanges(accountId, req.session, req.ctx, logger, req.language(), sinceState, maxChanges)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
diff --git a/services/groupware/pkg/groupware/api_mailbox.go b/services/groupware/pkg/groupware/api_mailbox.go
index 8baa9abbcb..0e5d5061fe 100644
--- a/services/groupware/pkg/groupware/api_mailbox.go
+++ b/services/groupware/pkg/groupware/api_mailbox.go
@@ -9,6 +9,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/opencloud-eu/opencloud/pkg/structs"
)
// Get a specific mailbox by its identifier.
@@ -62,7 +63,7 @@ func (g *Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) { //NOS
hasCriteria = true
}
role, ok := req.getStringParam(QueryParamMailboxSearchRole, "") // the mailbox role to filter on
- if role != "" {
+ if ok && role != "" {
filter.Role = role
hasCriteria = true
}
@@ -195,9 +196,9 @@ func (g *Groupware) GetMailboxChanges(w http.ResponseWriter, r *http.Request) {
l = l.Uint(QueryParamMaxChanges, maxChanges)
}
- sinceState := req.OptHeaderParamDoc(HeaderParamSince, "Optionally specifies the state identifier from which on to list mailbox changes")
+ sinceState := jmap.State(req.OptHeaderParamDoc(HeaderParamSince, "Optionally specifies the state identifier from which on to list mailbox changes"))
if sinceState != "" {
- l = l.Str(HeaderParamSince, log.SafeString(sinceState))
+ l = l.Str(HeaderParamSince, log.SafeString(string(sinceState)))
}
logger := log.From(l)
@@ -232,13 +233,13 @@ func (g *Groupware) GetMailboxChangesForAllAccounts(w http.ResponseWriter, r *ht
allAccountIds := req.AllAccountIds()
l.Array(logAccountId, log.SafeStringArray(allAccountIds))
- sinceStateMap, ok, err := req.parseMapParam(QueryParamSince)
+ sinceStateStrMap, ok, err := req.parseMapParam(QueryParamSince)
if err != nil {
return req.errorN(allAccountIds, err)
}
if ok {
dict := zerolog.Dict()
- for k, v := range sinceStateMap {
+ for k, v := range sinceStateStrMap {
dict.Str(log.SafeString(k), log.SafeString(v))
}
l = l.Dict(QueryParamSince, dict)
@@ -254,6 +255,7 @@ func (g *Groupware) GetMailboxChangesForAllAccounts(w http.ResponseWriter, r *ht
logger := log.From(l)
+ sinceStateMap := structs.MapValues(sinceStateStrMap, toState)
changesByAccountId, sessionState, state, lang, jerr := g.jmap.GetMailboxChangesForMultipleAccounts(allAccountIds, req.session, req.ctx, logger, req.language(), sinceStateMap, maxChanges)
if jerr != nil {
return req.jmapErrorN(allAccountIds, jerr, sessionState, lang)
diff --git a/services/groupware/pkg/groupware/api_objects.go b/services/groupware/pkg/groupware/api_objects.go
new file mode 100644
index 0000000000..0c35e95cbb
--- /dev/null
+++ b/services/groupware/pkg/groupware/api_objects.go
@@ -0,0 +1,106 @@
+package groupware
+
+import (
+ "net/http"
+
+ "github.com/opencloud-eu/opencloud/pkg/jmap"
+ "github.com/opencloud-eu/opencloud/pkg/log"
+)
+
+type ObjectsRequest struct {
+ Mailboxes []string `json:"mailboxes,omitempty"`
+ Emails []string `json:"emails,omitempty"`
+ Addressbooks []string `json:"addressbooks,omitempty"`
+ Contacts []string `json:"contacts,omitempty"`
+ Calendars []string `json:"calendars,omitempty"`
+ Events []string `json:"events,omitempty"`
+}
+
+// Retrieve changes for multiple or all Groupware objects, based on their respective state token.
+//
+// Since each object type has its own state token, the request must include the token for each
+// object type separately.
+//
+// This is done through individual query parameter, as follows:
+// `?emails=rafrag&contacts=rbsxqeay&events=n`
+//
+// Additionally, the `maxchanges` query parameter may be used to limit the number of changes
+// to retrieve for each object type -- this is applied to each object type, not overall.
+//
+// If `maxchanges` is not specifed or if `maxchanges` has the value `0`, then there is no limit
+// and all the changes from the specified state to now are included in the result.
+//
+// The response then includes the new state after that maximum number if changes,
+// as well as a `hasMoreChanges` boolean flag which can be used to paginate the retrieval of
+// changes and the objects associated with the identifiers.
+func (g *Groupware) GetObjects(w http.ResponseWriter, r *http.Request) { //NOSONAR
+ g.respond(w, r, func(req Request) Response {
+ l := req.logger.With()
+ accountId, err := req.GetAccountIdForMail()
+ if err != nil {
+ return req.error(accountId, err)
+ }
+ l = l.Str(logAccountId, accountId)
+
+ mailboxIds := []string{}
+ emailIds := []string{}
+ addressbookIds := []string{}
+ contactIds := []string{}
+ calendarIds := []string{}
+ eventIds := []string{}
+ {
+ var objects ObjectsRequest
+ if ok, err := req.optBody(&objects); err != nil {
+ return req.error(accountId, err)
+ } else if ok {
+ mailboxIds = append(mailboxIds, objects.Mailboxes...)
+ emailIds = append(mailboxIds, objects.Emails...)
+ addressbookIds = append(mailboxIds, objects.Addressbooks...)
+ contactIds = append(mailboxIds, objects.Contacts...)
+ calendarIds = append(mailboxIds, objects.Calendars...)
+ eventIds = append(mailboxIds, objects.Events...)
+ }
+ }
+
+ if list, ok, err := req.parseOptStringListParam(QueryParamMailboxes); err != nil {
+ return req.error(accountId, err)
+ } else if ok {
+ mailboxIds = append(mailboxIds, list...)
+ }
+ if list, ok, err := req.parseOptStringListParam(QueryParamEmails); err != nil {
+ return req.error(accountId, err)
+ } else if ok {
+ emailIds = append(emailIds, list...)
+ }
+ if list, ok, err := req.parseOptStringListParam(QueryParamAddressbooks); err != nil {
+ return req.error(accountId, err)
+ } else if ok {
+ addressbookIds = append(addressbookIds, list...)
+ }
+ if list, ok, err := req.parseOptStringListParam(QueryParamContacts); err != nil {
+ return req.error(accountId, err)
+ } else if ok {
+ contactIds = append(contactIds, list...)
+ }
+ if list, ok, err := req.parseOptStringListParam(QueryParamCalendars); err != nil {
+ return req.error(accountId, err)
+ } else if ok {
+ calendarIds = append(calendarIds, list...)
+ }
+ if list, ok, err := req.parseOptStringListParam(QueryParamEvents); err != nil {
+ return req.error(accountId, err)
+ } else if ok {
+ eventIds = append(eventIds, list...)
+ }
+
+ logger := log.From(l)
+ objs, sessionState, state, lang, jerr := g.jmap.GetObjects(accountId, req.session, req.ctx, logger, req.language(),
+ mailboxIds, emailIds, addressbookIds, contactIds, calendarIds, eventIds)
+ if jerr != nil {
+ return req.jmapError(accountId, jerr, sessionState, lang)
+ }
+ var body jmap.Objects = objs
+
+ return req.respond(accountId, body, sessionState, "", state)
+ })
+}
diff --git a/services/groupware/pkg/groupware/error.go b/services/groupware/pkg/groupware/error.go
index 5bb513476a..1f1cbdb19b 100644
--- a/services/groupware/pkg/groupware/error.go
+++ b/services/groupware/pkg/groupware/error.go
@@ -138,6 +138,8 @@ func groupwareErrorFromJmap(j jmap.Error) *GroupwareError {
return &ErrorInvalidRequestPayload
case jmap.JmapErrorInvalidJmapResponsePayload:
return &ErrorInvalidResponsePayload
+ case jmap.JmapInvalidObjectState:
+ return &ErrorInvalidObjectState
case jmap.JmapErrorUnspecifiedType, jmap.JmapErrorUnknownMethod, jmap.JmapErrorInvalidArguments, jmap.JmapErrorInvalidResultReference:
return &ErrorInvalidGroupwareRequest
case jmap.JmapErrorServerUnavailable:
@@ -203,6 +205,7 @@ const (
ErrorCodeNoMailboxWithSentRole = "NMBXSE"
ErrorCodeInvalidSortSpecification = "INVSSP"
ErrorCodeInvalidSortProperty = "INVSPR"
+ ErrorCodeInvalidObjectState = "INVOST"
)
var (
@@ -470,6 +473,12 @@ var (
Title: "Invalid sort property",
Detail: "The sort property in the query parameter does not exist or is not acceptable.",
}
+ ErrorInvalidObjectState = GroupwareError{
+ Status: http.StatusBadRequest,
+ Code: ErrorCodeInvalidObjectState,
+ Title: "Invalid Object State",
+ Detail: "The request included an object state that does not exist.",
+ }
)
type ErrorOpt interface {
diff --git a/services/groupware/pkg/groupware/request.go b/services/groupware/pkg/groupware/request.go
index 385280f2bf..b648fd17c4 100644
--- a/services/groupware/pkg/groupware/request.go
+++ b/services/groupware/pkg/groupware/request.go
@@ -233,7 +233,7 @@ func (r *Request) getStringParam(param string, defaultValue string) (string, boo
}
str := q.Get(param)
if str == "" {
- return defaultValue, false
+ return defaultValue, true
}
return str, true
}
@@ -346,23 +346,23 @@ func (r *Request) parseBoolParam(param string, defaultValue bool) (bool, bool, *
return b, true, nil
}
+// Parses query parameters that have the form ?param.field1=...¶m.field2=... into a map of strings.
+// When multiple values are defined for a field, the last one wins.
func (r *Request) parseMapParam(param string) (map[string]string, bool, *Error) {
q := r.r.URL.Query()
- if !q.Has(param) {
- return map[string]string{}, false, nil
- }
-
result := map[string]string{}
prefix := param + "."
+ found := false
for name, values := range q {
if strings.HasPrefix(name, prefix) {
+ found = true
if len(values) > 0 {
- key := name[len(prefix)+1:]
- result[key] = values[0]
+ key := name[len(prefix):]
+ result[key] = values[len(values)-1]
}
}
}
- return result, true, nil
+ return result, found, nil
}
func (r *Request) parseOptStringListParam(param string) ([]string, bool, *Error) {
@@ -402,6 +402,26 @@ func (r *Request) body(target any) *Error {
return nil
}
+func (r *Request) optBody(target any) (bool, *Error) {
+ body := r.r.Body
+ defer func(b io.ReadCloser) {
+ err := b.Close()
+ if err != nil {
+ r.logger.Error().Err(err).Msg("failed to close request body")
+ }
+ }(body)
+ if body == nil || body == http.NoBody { // not sure whether this is always enough to detect an empty body, we might have to read the whole body into memory first
+ return false, nil
+ }
+
+ err := json.NewDecoder(body).Decode(target)
+ if err != nil {
+ r.logger.Warn().Msgf("failed to deserialize the request body: %s", err.Error())
+ return true, r.observedParameterError(ErrorInvalidRequestBody, withSource(&ErrorSource{Pointer: "/"})) // we don't get any details here
+ }
+ return true, nil
+}
+
func (r *Request) language() string {
return r.r.Header.Get("Accept-Language")
}
@@ -573,3 +593,11 @@ func mapSort[T any](accountIds []string, req *Request, defaultSort []T, props []
return defaultSort, true, Response{}
}
}
+
+func toState(s string) jmap.State {
+ return jmap.State(s)
+}
+
+func ptr[T any](t T) *T {
+ return &t
+}
diff --git a/services/groupware/pkg/groupware/request_test.go b/services/groupware/pkg/groupware/request_test.go
index 975cd03f2f..277e379b3c 100644
--- a/services/groupware/pkg/groupware/request_test.go
+++ b/services/groupware/pkg/groupware/request_test.go
@@ -2,7 +2,9 @@ package groupware
import (
"context"
+ "fmt"
"net/http"
+ "net/url"
"testing"
"github.com/stretchr/testify/require"
@@ -62,3 +64,37 @@ func TestParseSort(t *testing.T) {
require.False(res[1].Ascending)
}
}
+
+func TestParseMap(t *testing.T) {
+ require := require.New(t)
+ for _, tt := range []struct {
+ uri string
+ ok bool
+ out map[string]string
+ }{
+ {"/foo", false, map[string]string{}},
+ {"/foo?name=camina", false, map[string]string{}},
+ {"/foo?map=camina", false, map[string]string{}},
+ {"/foo?map.name=camina", true, map[string]string{"name": "camina"}},
+ {"/foo?map.gn=camina&map.sn=drummer", true, map[string]string{"gn": "camina", "sn": "drummer"}},
+ {"/foo?map.name=camina&map.name=chrissie", true, map[string]string{"name": "chrissie"}},
+ } {
+ t.Run(fmt.Sprintf("uri:%s", tt.uri), func(t *testing.T) {
+ var req Request
+ {
+ u, err := url.Parse(tt.uri)
+ require.NoError(err)
+ req = Request{r: &http.Request{URL: u}, ctx: context.Background()}
+ }
+ res, ok, err := req.parseMapParam("map")
+ require.Nil(err)
+ if tt.ok {
+ require.True(ok)
+ require.Equal(res, tt.out)
+ } else {
+ require.False(ok)
+ require.Empty(res)
+ }
+ })
+ }
+}
diff --git a/services/groupware/pkg/groupware/route.go b/services/groupware/pkg/groupware/route.go
index 91a41e71be..0275705cfe 100644
--- a/services/groupware/pkg/groupware/route.go
+++ b/services/groupware/pkg/groupware/route.go
@@ -58,6 +58,12 @@ const (
QueryParamUndesirable = "undesirable"
QueryParamMarkAsSeen = "markAsSeen"
QueryParamSort = "sort"
+ QueryParamMailboxes = "mailboxes"
+ QueryParamEmails = "emails"
+ QueryParamAddressbooks = "addressbooks"
+ QueryParamContacts = "contacts"
+ QueryParamCalendars = "calendars"
+ QueryParamEvents = "events"
HeaderParamSince = "if-none-match"
)
@@ -154,7 +160,7 @@ func (g *Groupware) Route(r chi.Router) {
r.Get("/", g.GetCalendars)
r.Route("/{calendarid}", func(r chi.Router) {
r.Get("/", g.GetCalendarById)
- r.Get("/events", g.GetEventsInCalendar)
+ r.Get("/events", g.GetEventsInCalendar) //NOSONAR
})
})
r.Route("/events", func(r chi.Router) {
@@ -169,10 +175,16 @@ func (g *Groupware) Route(r chi.Router) {
})
})
r.Route("/changes", func(r chi.Router) {
- r.Get("/contacts", g.GetContactsChanges)
+ r.Get("/", g.GetChanges)
r.Get("/mailboxes", g.GetMailboxChanges)
r.Get("/emails", g.GetEmailChanges)
+ r.Get("/addressbooks", g.GetAddressBookChanges)
+ r.Get("/contacts", g.GetContactsChanges)
+ r.Get("/calendars", g.GetCalendarChanges)
+ r.Get("/events", g.GetEventChanges)
})
+ r.Get("/objects", g.GetObjects)
+ r.Post("/objects", g.GetObjects)
})
})
diff --git a/services/idm/ldif/demousers.ldif.tmpl b/services/idm/ldif/demousers.ldif.tmpl
index 881c90f9f2..f2e81a1a48 100644
--- a/services/idm/ldif/demousers.ldif.tmpl
+++ b/services/idm/ldif/demousers.ldif.tmpl
@@ -175,6 +175,7 @@ objectClass: groupOfNames
objectClass: openCloudObject
objectClass: top
cn: programmers
+mail: programmers@example.org
description: Computer Programmers
openCloudUUID: ce4aa240-dd94-11ef-82b8-4f4828849072
member: uid=alan,ou=users,o=libregraph-idm