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